author | Alan Dipert
<alan@dipert.org> 2025-10-06 01:30:52 UTC |
committer | Alan Dipert
<alan@dipert.org> 2025-10-06 01:30:52 UTC |
parent | 81aae7067bb12ebc8c6bfd13f0ae674c34579207 |
.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