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