git » bitbop.git » master » tree

[master] / app.js

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 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;

  function clearSchedule() {
    if (scheduledHandle !== null) {
      clearTimeout(scheduledHandle);
      scheduledHandle = null;
    }
  }

  function reset() {
    running = false;
    interpreter = null;
    clearSchedule();
  }

  function initApi(interp, globalObject) {
    attachConsole(interp, globalObject);
    attachMusic(interp, globalObject, musicEngine);
  }

  function scheduleNextTick() {
    if (!running || scheduledHandle !== null) return;
    scheduledHandle = setTimeout(() => {
      scheduledHandle = null;
      tick();
    }, 0);
  }

  function tick() {
    if (!running || !interpreter) return;
    const start = performance.now();
    let steps = 0;
    let hasMore = true;

    try {
      while (
        hasMore &&
        steps < STEPS_PER_TICK &&
        performance.now() - start < TIME_BUDGET_MS
      ) {
        hasMore = interpreter.step();
        steps += 1;
      }
    } catch (err) {
      console.error('bitbop runtime error:', err);
      reset();
      return;
    }

    if (hasMore) {
      scheduleNextTick();
    } else {
      reset();
      console.info('bitbop program finished.');
    }
  }

  function run(code) {
    reset();
    try {
      interpreter = new Interpreter(code, initApi);
      running = true;
    } catch (err) {
      console.error('bitbop compile error:', err);
      reset();
      return;
    }
    scheduleNextTick();
  }

  return { run };
}

document.addEventListener('DOMContentLoaded', () => {
  const editor = ace.edit('editor-pane', {
    mode: 'ace/mode/javascript',
    theme: 'ace/theme/monokai',
    fontSize: '12px',
    tabSize: 2,
    useSoftTabs: true,
    showPrintMargin: false
  });

  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', 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,
    music: musicEngine,
    storage: {
      flush: flushSave,
      schedule: scheduleSave,
      key: EDITOR_STORAGE_KEY
    }
  };
});