git » bitbop.git » master » tree

[master] / music.js

(function () {
  if (typeof window === 'undefined') {
    console.warn('bitbop music: window not available.');
    return;
  }

  if (!window.Pizzicato) {
    console.warn('bitbop music: Pizzicato library not loaded.');
    return;
  }

  const Pz = window.Pizzicato;

  const NOTE_INDEX = {
    C: 0,
    'C#': 1,
    Db: 1,
    D: 2,
    'D#': 3,
    Eb: 3,
    E: 4,
    F: 5,
    'F#': 6,
    Gb: 6,
    G: 7,
    'G#': 8,
    Ab: 8,
    A: 9,
    'A#': 10,
    Bb: 10,
    B: 11
  };

  function normalizeNoteName(note) {
    if (typeof note === 'number') {
      return String(note);
    }
    if (typeof note !== 'string') {
      return '';
    }
    return note.trim().toUpperCase();
  }

  function isNumberLike(value) {
    return typeof value === 'number' || (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value)));
  }

  function noteToFrequency(noteInput) {
    if (isNumberLike(noteInput)) {
      return Number(noteInput);
    }

    const note = String(noteInput).trim();
    const match = note.match(/^([A-Ga-g])([#b]?)(-?\d)$/);
    if (!match) {
      throw new Error('Invalid note format: ' + noteInput);
    }

    const [, letterRaw, accidentalRaw, octaveRaw] = match;
    const letter = letterRaw.toUpperCase();
    const accidental = accidentalRaw || '';
    const pitchKey = accidental === '' ? letter : letter + accidental;
    const semitone = NOTE_INDEX[pitchKey];

    if (typeof semitone !== 'number') {
      throw new Error('Unsupported note: ' + noteInput);
    }

    const octave = parseInt(octaveRaw, 10);
    const absoluteIndex = semitone + (octave * 12);
    const a4Index = NOTE_INDEX.A + (4 * 12);
    const diff = absoluteIndex - a4Index;
    const frequency = 440 * Math.pow(2, diff / 12);
    return Number(frequency.toFixed(4));
  }

  function resolvePath(root, path) {
    if (!path) {
      return { parent: null, key: '', value: root };
    }

    const segments = path.split('.').filter(Boolean);
    let parent = null;
    let value = root;

    for (const key of segments) {
      parent = value;
      value = value ? value[key] : undefined;
      if (value === undefined) {
        break;
      }
    }

    return { parent, key: segments[segments.length - 1] || '', value };
  }

  class MusicEngine {
    constructor(pizzicato) {
      this.pizzicato = pizzicato;
      this.registry = new Map();
      this.reverseRegistry = new WeakMap();
      this.nextHandle = 1;
      this.waveform = 'sine';
      this.volume = 0.8;
      this.defaultAttack = 0.01;
      this.defaultRelease = 0.2;
      this.libraryHandle = this.register(pizzicato, { kind: 'library' });
    }

    async resume() {
      const context = this.pizzicato.context;
      if (context && context.state === 'suspended') {
        await context.resume();
      }
    }

    register(value, meta = {}) {
      if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
        return value;
      }

      const existing = this.reverseRegistry.get(value);
      if (existing) {
        return existing;
      }

      const handle = this.nextHandle++;
      const kind = meta.kind || this.identify(value);
      this.registry.set(handle, { value, meta: { ...meta, kind } });
      this.reverseRegistry.set(value, handle);
      return handle;
    }

    identify(value) {
      if (!value) {
        return 'unknown';
      }
      if (this.pizzicato.Util && this.pizzicato.Util.isSound && this.pizzicato.Util.isSound(value)) {
        return 'sound';
      }
      if (this.pizzicato.Util && this.pizzicato.Util.isEffect && this.pizzicato.Util.isEffect(value)) {
        return 'effect';
      }
      if (value === this.pizzicato) {
        return 'library';
      }
      return 'object';
    }

    resolve(handle) {
      const entry = this.registry.get(handle);
      if (!entry) {
        throw new Error('Unknown music handle: ' + handle);
      }
      return entry;
    }

    wrapReturn(result) {
      if (result === undefined) {
        return undefined;
      }
      if (result === null) {
        return null;
      }
      if (Array.isArray(result)) {
        return result.map(item => this.wrapReturn(item));
      }
      if (typeof result === 'object' || typeof result === 'function') {
        return this.register(result);
      }
      return result;
    }

    setWaveform(type) {
      if (typeof type === 'string' && type.trim()) {
        this.waveform = type.trim();
      }
    }

    toFrequency(input) {
      return noteToFrequency(input);
    }

    normalizeNote(note) {
      return normalizeNoteName(note);
    }

    setVolume(value) {
      if (typeof value === 'number' && value >= 0 && value <= 1) {
        this.volume = value;
        this.pizzicato.volume = value;
      }
    }

    setEnvelope(envelope = {}) {
      if (typeof envelope === 'object') {
        if (typeof envelope.attack === 'number') {
          this.defaultAttack = Math.max(0, envelope.attack);
        }
        if (typeof envelope.release === 'number') {
          this.defaultRelease = Math.max(0, envelope.release);
        }
      }
    }

    play(note, duration = 0.5, startOffset = 0, options = {}) {
      const frequency = noteToFrequency(note);
      const normalizedNote = normalizeNoteName(note);
      const sound = new this.pizzicato.Sound({
        source: 'wave',
        options: {
          type: options.type || this.waveform,
          frequency,
          attack: typeof options.attack === 'number' ? options.attack : this.defaultAttack,
          release: typeof options.release === 'number' ? options.release : this.defaultRelease
        }
      });

      if (typeof options.volume === 'number') {
        sound.volume = Math.max(0, Math.min(1, options.volume));
      } else {
        sound.volume = this.volume;
      }

      const handle = this.register(sound, { kind: 'sound', note: normalizedNote });

      const startPlayback = () => {
        try {
          sound.play();
        } catch (err) {
          console.error('bitbop music: failed to play note', err);
        }
        if (duration && duration > 0) {
          const stopTimer = setTimeout(() => {
            this.stop(handle);
          }, duration * 1000);
          const entry = this.registry.get(handle);
          if (entry) {
            entry.meta.stopTimer = stopTimer;
          }
        }
      };

      if (startOffset && startOffset > 0) {
        const startTimer = setTimeout(startPlayback, startOffset * 1000);
        const entry = this.registry.get(handle);
        if (entry) {
          entry.meta.startTimer = startTimer;
        }
      } else {
        startPlayback();
      }

      return handle;
    }

    stop(identifier) {
      if (identifier === undefined || identifier === null) {
        this.stopAll();
        return;
      }

      if (typeof identifier === 'number' && this.registry.has(identifier)) {
        this.dispose(identifier, { silent: true });
        return;
      }

      const targetNote = normalizeNoteName(identifier);
      if (!targetNote) {
        return;
      }

      const handles = Array.from(this.registry.entries())
        .filter(([, entry]) => entry.meta && entry.meta.note === targetNote)
        .map(([handle]) => handle);

      handles.forEach(handle => this.dispose(handle, { silent: true }));
    }

    stopAll() {
      const handles = Array.from(this.registry.entries())
        .filter(([, entry]) => entry.meta && entry.meta.kind === 'sound')
        .map(([handle]) => handle);
      handles.forEach(handle => this.dispose(handle, { silent: true }));
    }

    dispose(handle, { silent = false } = {}) {
      const entry = this.registry.get(handle);
      if (!entry) {
        if (!silent) {
          console.warn('bitbop music: unknown handle', handle);
        }
        return;
      }

      const { value, meta } = entry;

      if (meta.startTimer) {
        clearTimeout(meta.startTimer);
      }
      if (meta.stopTimer) {
        clearTimeout(meta.stopTimer);
      }

      if (meta.kind === 'sound' && value && typeof value.stop === 'function') {
        try {
          value.stop();
        } catch (err) {
          if (!silent) {
            console.error('bitbop music: failed to stop sound', err);
          }
        }
      }

      this.registry.delete(handle);
      if (value && (typeof value === 'object' || typeof value === 'function')) {
        this.reverseRegistry.delete(value);
      }
    }

    invoke(path, args = []) {
      const { parent, value } = resolvePath(this.pizzicato, path);
      if (typeof value !== 'function') {
        throw new Error('Pizzicato path is not callable: ' + path);
      }
      const result = value.apply(parent || this.pizzicato, args);
      return this.wrapReturn(result);
    }

    getGlobal(path) {
      const { value } = resolvePath(this.pizzicato, path);
      return this.wrapReturn(value);
    }

    setGlobal(path, newValue) {
      const { parent, key } = resolvePath(this.pizzicato, path);
      if (!parent || !key) {
        throw new Error('Cannot set property at path: ' + path);
      }
      parent[key] = newValue;
      return this.wrapReturn(newValue);
    }

    create(path, args = []) {
      const { parent, value } = resolvePath(this.pizzicato, path);
      if (typeof value !== 'function') {
        throw new Error('Cannot construct non-function at path: ' + path);
      }
      const instance = new value(...args);
      return this.register(instance);
    }

    call(handle, method, args = []) {
      const entry = this.resolve(handle);
      const target = entry.value;
      const fn = target ? target[method] : undefined;
      if (typeof fn !== 'function') {
        throw new Error('Handle ' + handle + ' has no method ' + method);
      }
      const result = fn.apply(target, args);
      return this.wrapReturn(result);
    }

    get(handle, property) {
      const entry = this.resolve(handle);
      const result = entry.value[property];
      return this.wrapReturn(result);
    }

    set(handle, property, value) {
      const entry = this.resolve(handle);
      entry.value[property] = value;
      return this.wrapReturn(entry.value[property]);
    }

    listHandles() {
      return Array.from(this.registry.entries()).map(([handle, entry]) => ({
        handle,
        kind: entry.meta.kind,
        note: entry.meta.note || null
      }));
    }
  }

  window.bitbopMusic = new MusicEngine(Pz);
})();