git » bitbop.git » commit 5a2e685

add music engine and persist editor

author Alan Dipert
2025-10-06 01:30:52 UTC
committer Alan Dipert
2025-10-06 01:30:52 UTC
parent 81aae7067bb12ebc8c6bfd13f0ae674c34579207

add music engine and persist editor

.gitmodules +3 -0
app.js +234 -11
index.html +2 -1
music.js +386 -0
third_party/pizzicato +1 -0

diff --git a/.gitmodules b/.gitmodules
index 770ace3..7c4c49b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -4,3 +4,6 @@
 [submodule "ace-builds"]
 	path = ace-builds
 	url = https://github.com/ajaxorg/ace-builds.git
+[submodule "third_party/pizzicato"]
+	path = third_party/pizzicato
+	url = https://github.com/alemangui/pizzicato.git
diff --git a/app.js b/app.js
index 0f5f960..5749d5e 100644
--- a/app.js
+++ b/app.js
@@ -1,7 +1,175 @@
 const STEPS_PER_TICK = 2048;
 const TIME_BUDGET_MS = 8;
+const EDITOR_STORAGE_KEY = 'bitbop.editorContent';
+const EDITOR_SAVE_DELAY_MS = 300;
+const DEFAULT_STARTER_CODE = "// bitbop.studio\n// Try Music.play('C4', 0.5);\n";
 
-function createRunner() {
+function createStorageAdapter(key) {
+  let enabled = true;
+
+  if (typeof localStorage === 'undefined') {
+    enabled = false;
+    console.warn('bitbop storage unavailable: localStorage missing');
+  } else {
+    try {
+      const probeKey = key + '.probe';
+      localStorage.setItem(probeKey, '1');
+      localStorage.removeItem(probeKey);
+    } catch (err) {
+      enabled = false;
+      console.warn('bitbop storage unavailable:', err);
+    }
+  }
+
+  return {
+    load() {
+      if (!enabled || typeof localStorage === 'undefined') return null;
+      try {
+        return localStorage.getItem(key);
+      } catch (err) {
+        console.warn('bitbop storage load failed:', err);
+        enabled = false;
+        return null;
+      }
+    },
+    save(value) {
+      if (!enabled || typeof localStorage === 'undefined') return;
+      try {
+        localStorage.setItem(key, value);
+      } catch (err) {
+        console.warn('bitbop storage save failed:', err);
+        enabled = false;
+      }
+    }
+  };
+}
+
+function attachConsole(interp, globalObject) {
+  const consoleObject = interp.createObject(interp.OBJECT);
+  const logFunction = interp.createNativeFunction(function () {
+    const args = Array.from(arguments).map(arg => interp.pseudoToNative(arg));
+    console.log('[bitbop]', ...args);
+  });
+  const errorFunction = interp.createNativeFunction(function () {
+    const args = Array.from(arguments).map(arg => interp.pseudoToNative(arg));
+    console.error('[bitbop]', ...args);
+  });
+
+  interp.setProperty(consoleObject, 'log', logFunction);
+  interp.setProperty(consoleObject, 'error', errorFunction);
+  interp.setProperty(globalObject, 'console', consoleObject);
+}
+
+function attachMusic(interp, globalObject, musicEngine) {
+  if (!musicEngine) {
+    return;
+  }
+
+  const musicObject = interp.createObject(interp.OBJECT);
+
+  const toNative = value => interp.pseudoToNative(value);
+  const toPseudo = value => {
+    if (value === undefined) {
+      return interp.UNDEFINED;
+    }
+    return interp.nativeToPseudo(value);
+  };
+  const wrap = (fn, { returnsValue = false } = {}) =>
+    interp.createNativeFunction(function () {
+      const nativeArgs = Array.from(arguments).map(arg => toNative(arg));
+      const result = fn.apply(null, nativeArgs);
+      if (!returnsValue) {
+        return interp.UNDEFINED;
+      }
+      return toPseudo(result);
+    });
+
+  interp.setProperty(
+    musicObject,
+    'play',
+    wrap((note, duration, offset, options) => musicEngine.play(note, duration, offset, options || {}), {
+      returnsValue: true
+    })
+  );
+  interp.setProperty(musicObject, 'stop', wrap(identifier => musicEngine.stop(identifier)));
+  interp.setProperty(musicObject, 'stopAll', wrap(() => musicEngine.stopAll()));
+  interp.setProperty(musicObject, 'dispose', wrap(handle => musicEngine.dispose(handle)));
+  interp.setProperty(musicObject, 'setWaveform', wrap(type => musicEngine.setWaveform(type)));
+  interp.setProperty(musicObject, 'setVolume', wrap(value => musicEngine.setVolume(value)));
+  interp.setProperty(
+    musicObject,
+    'setEnvelope',
+    wrap((attackOrEnvelope, release) => {
+      if (attackOrEnvelope && typeof attackOrEnvelope === 'object') {
+        musicEngine.setEnvelope(attackOrEnvelope);
+        return;
+      }
+      if (typeof attackOrEnvelope === 'number' || typeof release === 'number') {
+        musicEngine.setEnvelope({ attack: attackOrEnvelope, release });
+      }
+    })
+  );
+  interp.setProperty(
+    musicObject,
+    'create',
+    wrap((path, args) => musicEngine.create(path, Array.isArray(args) ? args : []), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'call',
+    wrap((handle, method, args) => musicEngine.call(handle, method, Array.isArray(args) ? args : []), {
+      returnsValue: true
+    })
+  );
+  interp.setProperty(
+    musicObject,
+    'get',
+    wrap((handle, property) => musicEngine.get(handle, property), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'set',
+    wrap((handle, property, value) => musicEngine.set(handle, property, value), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'invoke',
+    wrap((path, args) => musicEngine.invoke(path, Array.isArray(args) ? args : []), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'getGlobal',
+    wrap(path => musicEngine.getGlobal(path), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'setGlobal',
+    wrap((path, value) => musicEngine.setGlobal(path, value), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'listHandles',
+    wrap(() => musicEngine.listHandles(), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'toFrequency',
+    wrap(input => musicEngine.toFrequency(input), { returnsValue: true })
+  );
+  interp.setProperty(
+    musicObject,
+    'normalizeNote',
+    wrap(note => musicEngine.normalizeNote(note), { returnsValue: true })
+  );
+
+  if (typeof musicEngine.libraryHandle !== 'undefined') {
+    interp.setProperty(musicObject, 'library', toPseudo(musicEngine.libraryHandle));
+  }
+
+  interp.setProperty(globalObject, 'Music', musicObject);
+}
+
+function createRunner(musicEngine) {
   let interpreter = null;
   let running = false;
   let scheduledHandle = null;
@@ -20,13 +188,8 @@ function createRunner() {
   }
 
   function initApi(interp, globalObject) {
-    const consoleObject = interp.createObject(interp.OBJECT);
-    const logFunction = interp.createNativeFunction(function () {
-      const args = Array.from(arguments).map(arg => interp.pseudoToNative(arg));
-      console.log('[bitbop]', ...args);
-    });
-    interp.setProperty(consoleObject, 'log', logFunction);
-    interp.setProperty(globalObject, 'console', consoleObject);
+    attachConsole(interp, globalObject);
+    attachMusic(interp, globalObject, musicEngine);
   }
 
   function scheduleNextTick() {
@@ -92,14 +255,74 @@ document.addEventListener('DOMContentLoaded', () => {
     showPrintMargin: false
   });
 
-  const runner = createRunner();
+  const storage = createStorageAdapter(EDITOR_STORAGE_KEY);
+  const storedCode = storage.load();
+  const initialCode = storedCode !== null && storedCode !== undefined && storedCode !== ''
+    ? storedCode
+    : DEFAULT_STARTER_CODE;
+  let lastSavedCode = initialCode;
+  let saveTimer = null;
+
+  editor.setValue(initialCode, -1);
+
+  function flushSave() {
+    if (saveTimer !== null) {
+      clearTimeout(saveTimer);
+      saveTimer = null;
+    }
+    const current = editor.getValue();
+    if (current === lastSavedCode) {
+      return;
+    }
+    storage.save(current);
+    lastSavedCode = current;
+  }
+
+  function scheduleSave() {
+    if (saveTimer !== null) {
+      clearTimeout(saveTimer);
+    }
+    saveTimer = setTimeout(() => {
+      saveTimer = null;
+      const current = editor.getValue();
+      if (current === lastSavedCode) {
+        return;
+      }
+      storage.save(current);
+      lastSavedCode = current;
+    }, EDITOR_SAVE_DELAY_MS);
+  }
+
+  editor.session.on('change', scheduleSave);
+  window.addEventListener('beforeunload', flushSave);
+  window.addEventListener('pagehide', flushSave);
+
+  const musicEngine = window.bitbopMusic;
+  const runner = createRunner(musicEngine);
   const playButton = document.getElementById('play');
 
   if (playButton) {
-    playButton.addEventListener('click', () => {
+    playButton.addEventListener('click', async () => {
+      if (musicEngine && typeof musicEngine.resume === 'function') {
+        try {
+          await musicEngine.resume();
+        } catch (err) {
+          console.warn('bitbop music: resume rejected', err);
+        }
+      }
+      flushSave();
       runner.run(editor.getValue());
     });
   }
 
-  window.bitbop = { editor, runner };
+  window.bitbop = {
+    editor,
+    runner,
+    music: musicEngine,
+    storage: {
+      flush: flushSave,
+      schedule: scheduleSave,
+      key: EDITOR_STORAGE_KEY
+    }
+  };
 });
diff --git a/index.html b/index.html
index ffe387e..3b6e90e 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,8 @@
   <script src="ace-builds/src/ace.js"></script>
   <script src="ace-builds/src/mode-javascript.js"></script>
   <script src="ace-builds/src/theme-monokai.js"></script>
+  <script src="third_party/pizzicato/distr/Pizzicato.min.js"></script>
+  <script src="music.js"></script>
   <script src="app.js"></script>
 </head>
 <body>
@@ -44,4 +46,3 @@
   </div>
 </body>
 </html>
-
diff --git a/music.js b/music.js
new file mode 100644
index 0000000..81cbecb
--- /dev/null
+++ b/music.js
@@ -0,0 +1,386 @@
+(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);
+})();
diff --git a/third_party/pizzicato b/third_party/pizzicato
new file mode 160000
index 0000000..4c3826e
--- /dev/null
+++ b/third_party/pizzicato
@@ -0,0 +1 @@
+Subproject commit 4c3826e46f112743fa0653b6bad56006b98da537