From f5ac212bf39dbfc4c983f765796c55b68570e5d4 Mon Sep 17 00:00:00 2001 From: Aaron Koppel Date: Mon, 19 Oct 2026 01:18:05 -0400 Subject: [PATCH] Prepare the official v1.0 release and remove retired experiments --- LICENSE | 9 + README.md | 16 +- experiments/moving-resonators/index.html | 55 - experiments/moving-resonators/player.html | 60 - experiments/moving-resonators/player.js | 1321 ------------------- experiments/moving-resonators/style.css | 212 --- experiments/moving-resonators/version.json | 4 - experiments/moving-resonators/wav-worker.js | 74 -- index.html | 18 +- package-lock.json | 4 +- package.json | 2 +- player.html | 20 +- player.js | 3 +- scripts/stage-experiment.py | 30 - social-preview.png | Bin 0 -> 24681 bytes social-preview.svg | 11 + tests/README.md | 19 +- tests/browser/player.spec.cjs | 2 +- tests/player.test.cjs | 7 +- 19 files changed, 74 insertions(+), 1793 deletions(-) create mode 100644 LICENSE delete mode 100644 experiments/moving-resonators/index.html delete mode 100644 experiments/moving-resonators/player.html delete mode 100644 experiments/moving-resonators/player.js delete mode 100644 experiments/moving-resonators/style.css delete mode 100644 experiments/moving-resonators/version.json delete mode 100644 experiments/moving-resonators/wav-worker.js delete mode 100644 scripts/stage-experiment.py create mode 100644 social-preview.png create mode 100644 social-preview.svg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..514df3c --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Aaron Koppel + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index ba7e0cd..a038af3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Bells are instruments of distance. Their sound travels far, and that distance is tinged with a special kind of melancholy—the kind that comes after a struggle. I have wanted a music which studies this journey within sound itself: the collision of waves harmonic and inharmonic, and the long decay that follows. Distance gives resonance. -What resonates? [*Open*](https://stereo-images.github.io/open/) answers that question in a multitude of ways. In each performance, every tone is a new physical event, ringing in a particular way and gesturing towards the next. As a browser installation, wherever I go, I carry that distance with me. +What resonates? [*Open*](https://stereo-images.github.io/open/) answers that question in a multitude of ways. In each performance, every tone is a new physical event, ringing in a particular way and gesturing towards the next. Because Open lives in a browser, I carry that distance with me wherever I go. *Open* renders low, slow bell tones anchored to familiar harmony. Yet the tones themselves are inharmonic—the music resolves from moment to moment, but never within the moment itself. @@ -25,7 +25,7 @@ Short and Long seek a natural tonic ending after their target duration. Infinite ### Technical Architecture -Current release: **v1.1** (1.1.0). Musical engine: **1**; the synthesis and musical rules are unchanged by the replay feature. +Official release: **v1.0** (1.0.0). Musical engine: **1**. *Open* uses the standard Web Audio API with no runtime dependencies, using a custom procedural engine to synthesize the environment live. @@ -72,19 +72,11 @@ Save the audio for listening and keep its original filename or replay link to pr * **Timing:** Export reproduces the intended performance, excluding browser stalls and manual Stop fades. A timed run includes its complete natural ending and reverb tail, so its WAV can be appreciably longer than the selected time. Infinite exports the same first 30 minutes of planned events plus their remaining decay, while live playback continues. To start a new performance, press Play again. * **Rendering:** WAV rendering runs independently through `OfflineAudioContext`, with encoding in a worker. Revisit and the browser tab title show preparation, rendering, encoding, and completion or failure. Longer runs take time to render; pressing Shift + E again while busy does not queue another export. The shortcut also works while the length menu or root tone dial has focus, and is ignored while typing in the replay field. New runs use the sample rate captured from the live audio context (commonly 44.1 or 48 kHz); a complete replay link or filename restores its recorded WAV sample rate. Export does not interrupt playback. Small rendering differences across browsers or devices are possible; the performance is shared, rather than a recording of the device output. -**Long exports need memory.** For 30 minutes of stereo audio alone, the uncompressed render buffer is about 635 MB at 44.1 kHz or 691 MB at 48 kHz; the WAV adds about 318 MB or 346 MB. The natural ending, decay, browser, and synthesis add overhead. Leave plenty of available memory and close other demanding tabs or apps before a long export. Available system memory does not guarantee the browser can complete it; if an export fails or the tab closes, start a shorter run and try again. These are calculated data sizes, not measured peak RAM requirements. +**Long exports need memory.** Plan for Long exports of up to about 35 minutes, though this is not a hard duration limit. For 35 minutes of stereo audio alone, the uncompressed render buffer is about 741 MB at 44.1 kHz or 806 MB at 48 kHz; the WAV adds about 370 MB or 403 MB. Longer endings, decay, browser internals, and synthesis can add to these requirements. Leave plenty of available memory and close other demanding tabs or apps before a long export. Available system memory does not guarantee the browser can complete it; if an export fails or the tab closes, start a shorter run and try again. These are calculated data sizes, not measured peak RAM requirements. --- ### License -MIT License - -Copyright (c) 2026 Aaron Koppel - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +[MIT License](LICENSE). Copyright (c) 2026 Aaron Koppel. diff --git a/experiments/moving-resonators/index.html b/experiments/moving-resonators/index.html deleted file mode 100644 index ff26d94..0000000 --- a/experiments/moving-resonators/index.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Open — Experimental - - - -
-

Open Audio Player

- -
-
- -
-
-
- - - - diff --git a/experiments/moving-resonators/player.html b/experiments/moving-resonators/player.html deleted file mode 100644 index 83f321a..0000000 --- a/experiments/moving-resonators/player.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - Open — Experimental Player - - - -
-

Open

- -
-
-
- - -
- -
- - -
- -
- - -
-
-
- - -
- -
-
By Aaron Koppel
-
Stereo Images, 2026
-
-
- - - - \ No newline at end of file diff --git a/experiments/moving-resonators/player.js b/experiments/moving-resonators/player.js deleted file mode 100644 index 4e48841..0000000 --- a/experiments/moving-resonators/player.js +++ /dev/null @@ -1,1321 +0,0 @@ -// --- START OF SCRIPT --- -/* ============================================================ - OPEN — Moving Resonators Experiment - - Lifecycle: bounded voice tracking, tail-aware completion, and cancellable - teardown; retains the existing mobile background stop policy. - - Music Updates Applied: 110Hz Floor/415.30Hz Ceiling, - 0.20 runDensity cap, and Anti-Doubling FM math. - - Export: live and offline rendering share a planned performance, including - the live ending and the complete decay of its longest voice. - ============================================================ */ - -(() => { - "use strict"; - - // --- ANTI-DOUBLING KILL SWITCH --- - if (window.__OPEN_PLAYER_KILL__) { - console.log("Open Player: Stopping previous instance..."); - window.__OPEN_PLAYER_KILL__(); - } - - let disposed = false; - const removeListeners = []; - const pendingRecordings = new Map(); - const cancelEncoders = new Set(); - function listen(target, type, handler, options) { - if (!target || disposed) return; - target.addEventListener(type, handler, options); - removeListeners.push(() => target.removeEventListener(type, handler, options)); - } - function dispose() { - if (disposed) return; - disposed = true; - removeListeners.splice(0).forEach(remove => remove()); - stopAllManual(true); - for (const cleanup of pendingRecordings.values()) cleanup(); - for (const cancel of [...cancelEncoders]) cancel(); - if (audioContext) { - try { audioContext.close().catch(() => {}); } catch {} - audioContext = null; - } - cachedImpulseBuffer = null; - sessionSnapshot = null; - bridgeAudioEl?.remove(); - bridgeAudioEl = null; - } - window.__OPEN_PLAYER_KILL__ = dispose; - - const STATE_KEY = "open_player_settings:experiment/moving-resonators"; // schema-stable key: don't tie this to the script version - - // ========================= - // TUNING - // ========================= - const MELODY_FLOOR_HZ = 110.00; // A2 - const MELODY_CEILING_HZ = 415.30; // G#4 - const DRONE_FLOOR_HZ = 87.31; // F2 - const DRONE_GAIN_MULT = 0.70; - const MASTER_VOL = 0.30; - const REVERB_RETURN_LEVEL = 0.80; - - // Scheduler - const LOOKAHEAD = 1.5; - const SCHEDULER_INTERVAL_MS = 80; - const MAX_EVENTS_PER_TICK = 900; - - // Global flag for mobile hard reset - let closeCtxAfterStop = false; - - // ========================= - // UTILS - // ========================= - const $ = (id) => document.getElementById(id); - - function clampFreqMin(freq, floorHz) { - while (freq < floorHz) freq *= 2; - return freq; - } - function clamp01(x) { return Math.max(0, Math.min(1, x)); } - - function announce(msg) { - if (disposed) return; - const live = $("playerStatus"); - if (!live) return; - if (live._lastMsg === msg) return; - live._lastMsg = msg; - live.textContent = msg; - } - - function isTypingTarget(el) { - if (!el) return false; - const tag = (el.tagName || "").toUpperCase(); - return tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || el.isContentEditable === true; - } - - // ========================= - // DEVICE DETECTION - // ========================= - function isPlayerPage() { return !!$("playNow"); } - - function isMobileDevice() { - const ua = navigator.userAgent || ""; - const isBasicMobile = /iPhone|iPad|iPod|Android/i.test(ua); - const isIPadOS = (navigator.maxTouchPoints > 0) && /Macintosh/i.test(ua); - return isBasicMobile || isIPadOS; - } - - // ========================= - // STATE & CONTROLS - // ========================= - function loadState() { try { return JSON.parse(localStorage.getItem(STATE_KEY)); } catch { return null; } } - function saveState(state) { try { localStorage.setItem(STATE_KEY, JSON.stringify(state)); } catch {} } - - function readControls() { - return { - songDuration: $("songDuration")?.value ?? "60", - tone: $("tone")?.value ?? "110", - updatedAt: Date.now() - }; - } - - function applyControls(state) { - const sd = $("songDuration"); - const tone = $("tone"); - const hzReadout = $("hzReadout"); - - if (sd) { - const allowed = new Set(["60", "300", "600", "1800", "infinite"]); - const v = state?.songDuration != null ? String(state.songDuration) : "60"; - sd.value = allowed.has(v) ? v : "60"; - } - - let toneVal = 110; - if (state?.tone != null) { - const n = Number(state.tone); - if (Number.isFinite(n)) toneVal = Math.max(110, Math.min(200, n)); - } - if (tone) tone.value = String(toneVal); - if (hzReadout) hzReadout.textContent = String(toneVal); - } - - function setButtonState(state) { - const playBtn = $("playNow"); - const stopBtn = $("stop"); - const toneInput = $("tone"); - const playing = (state === "playing"); - - if (playBtn) { - playBtn.classList.toggle("filled", playing); - playBtn.setAttribute("aria-pressed", playing ? "true" : "false"); - } - if (stopBtn) { - stopBtn.classList.toggle("filled", !playing); - stopBtn.setAttribute("aria-pressed", playing ? "false" : "true"); - } - if (toneInput) toneInput.disabled = playing; - - announce(playing ? "Playing" : "Stopped"); - } - - // ========================= - // AUDIO CORE - // ========================= - let audioContext = null; - let bus = null; - let bridgeAudioEl = null; - let teardownTimer = null; - let cleanupInterval = null; - let startRequest = 0; - const activeVoices = new Set(); - // Allow the per-voice lowpass to settle before disconnecting its output. - const VOICE_SETTLE_SECONDS = 0.1; - - function registerVoice(ctx, nodes, endTime) { - if (ctx !== audioContext || !bus) return; - activeVoices.add({ nodes, releaseAt: endTime + VOICE_SETTLE_SECONDS }); - bus.lastVoiceEnd = Math.max(bus.lastVoiceEnd, endTime + VOICE_SETTLE_SECONDS); - } - - function cleanupFinishedVoices() { - if (!audioContext || !bus) return; - const now = audioContext.currentTime; - for (const voice of activeVoices) { - if (now < voice.releaseAt) continue; - for (const node of voice.nodes) { - try { node.disconnect(); } catch {} - activeNodes.delete(node); - } - activeVoices.delete(voice); - } - // Use the audio clock: a suspended context must not lose its pending tail. - if (isEndingNaturally && now >= bus.lastVoiceEnd + bus.tailSeconds) { - stopAllManual(true); - } - } - - // Active node tracking - const activeNodes = new Set(); - - function trackNode(ctx, n) { - if (n && ctx === audioContext) activeNodes.add(n); - return n; - } - - function killAllActiveNodes(now = 0) { - for (const n of Array.from(activeNodes)) { - try { n.stop?.(now); } catch {} - try { n.disconnect?.(); } catch {} - activeNodes.delete(n); - } - activeVoices.clear(); - } - - let cachedImpulseBuffer = null; - - function ensureAudioContext() { - if (audioContext && audioContext.state !== "closed") return; - const Ctx = window.AudioContext || window.webkitAudioContext; - audioContext = new Ctx(); - } - - function ensureBridge() { - if (bridgeAudioEl) return; - bridgeAudioEl = document.createElement("audio"); - bridgeAudioEl.id = "open-airplay-bridge"; - bridgeAudioEl.setAttribute("playsinline", "true"); - bridgeAudioEl.setAttribute("aria-hidden", "true"); - bridgeAudioEl.loop = true; - bridgeAudioEl.muted = false; // Keep the audio bridge active. - Object.assign(bridgeAudioEl.style, { - position: "fixed", width: "1px", height: "1px", opacity: "0.01", - left: "-9999px", zIndex: "-1", pointerEvents: "none" - }); - document.body.appendChild(bridgeAudioEl); - } - - function createImpulseResponse(ctx, seed = 0, cache = true) { - if (cache && cachedImpulseBuffer && cachedImpulseBuffer.sampleRate === ctx.sampleRate) return cachedImpulseBuffer; - - const duration = 10.0; // same tail length on mobile and desktop, per artist preference - - const decay = 2.8, rate = ctx.sampleRate; - const length = Math.floor(rate * duration); - const impulse = ctx.createBuffer(2, length, rate); - const r = mulberry32((seed ^ 0xC0FFEE) >>> 0); - for (let ch = 0; ch < 2; ch++) { - const data = impulse.getChannelData(ch); - for (let i = 0; i < length; i++) { - data[i] = (r() * 2 - 1) * Math.pow(1 - i / length, decay); - } - } - if (cache) cachedImpulseBuffer = impulse; - return impulse; - } - - // Parallel coloration, upstream of the existing reverb. - // Slow independent LFOs move the filter centers throughout each note. - function createMovingResonators(ctx, input, output, endTime, directOutput, startTime = ctx.currentTime) { - const nodes = [], lfos = []; - const own = node => { nodes.push(node); return node; }; - const dispose = () => { - for (const lfo of lfos) { try { lfo.stop(ctx.currentTime); } catch {} } - for (const node of nodes) { try { node.disconnect(); } catch {} } - }; - try { - const blend = own(ctx.createGain()); - blend.gain.value = 0.18; // Per band; bandpass peaks remain unity at their centers. - blend.connect(output); - // Expose the same moving overtones directly, without adding a second bank - // or changing the original pre-reverb route. Both follow the existing send. - if (directOutput) blend.connect(directOutput); - const start = startTime; - for (const [frequency, speed, depth] of [ - [420, 0.037, 480], [1050, 0.023, 600], [2400, 0.017, 420] - ]) { - const filter = own(ctx.createBiquadFilter()); - filter.type = "bandpass"; - filter.frequency.value = frequency; - filter.Q.value = 3; - const lfo = own(ctx.createOscillator()); - lfos.push(lfo); - lfo.type = "sine"; - lfo.frequency.value = speed; - const modulation = own(ctx.createGain()); - modulation.gain.value = depth; // Cents, so movement is proportional to pitch. - lfo.connect(modulation); - modulation.connect(filter.detune); - input.connect(filter); - filter.connect(blend); - lfo.start(start); - if (Number.isFinite(endTime)) lfo.stop(endTime); - } - return { - // At the lowest center (~318 Hz), Q=3 rings out well within this allowance. - tailSeconds: 0.1, - dispose - }; - } catch (error) { - dispose(); - throw error; - } - } - - const spatialStates = new WeakMap(); - function initializeNoteDrift(ctx, seed) { - spatialStates.set(ctx, { random: mulberry32((seed ^ 0x57E2E0) >>> 0), nodes: [] }); - } - - // One trajectory per note, shared by its FM partials and both output paths. - function createNoteDrift(ctx, destination, wetSend, time, duration, plannedPan) { - const state = spatialStates.get(ctx); - const random = state.random; - const pan = trackNode(ctx, ctx.createStereoPanner()); - const level = trackNode(ctx, ctx.createGain()); - level.gain.value = Math.SQRT2; // Preserve the mono voice's centered level. - pan.pan.setValueAtTime(0, time); - const target = plannedPan === undefined ? choosePan(random) : plannedPan; - if (target !== null) pan.pan.linearRampToValueAtTime(target, time + duration); - pan.connect(level); - level.connect(destination); - level.connect(wetSend); - registerVoice(ctx, [pan, level], time + duration); - if (ctx !== audioContext) state.nodes.push(pan, level); - return pan; - } - - function disposeNoteDrift(ctx) { - const state = spatialStates.get(ctx); - if (state) for (const node of state.nodes) node.disconnect(); - spatialStates.delete(ctx); - } - - function teardownBusHard() { - clearTimeout(teardownTimer); - teardownTimer = null; - clearInterval(cleanupInterval); - cleanupInterval = null; - if (!audioContext || !bus) return; - try { bus.masterGain.gain.cancelScheduledValues(audioContext.currentTime); } catch {} - try { bus.masterGain.gain.setValueAtTime(0, audioContext.currentTime); } catch {} - - killAllActiveNodes(audioContext.currentTime); - bus.resonators?.dispose(); - disposeNoteDrift(audioContext); - - try { bus.reverbReturn.disconnect(); } catch {} - try { bus.reverbSend.disconnect(); } catch {} - try { bus.reverbPreDelay.disconnect(); } catch {} - try { bus.reverbNode.disconnect(); } catch {} - try { bus.reverbLP.disconnect(); } catch {} - try { bus.masterGain.disconnect(); } catch {} - try { bus.streamDest.disconnect(); } catch {} - bus.streamDest.stream.getTracks().forEach(track => track.stop()); - - // Release the bridge stream to prevent lingering iOS audio hardware activity. - if (bridgeAudioEl?.srcObject) { - try { bridgeAudioEl.pause(); } catch {} - try { bridgeAudioEl.srcObject.getTracks().forEach(t => t.stop()); } catch {} - try { bridgeAudioEl.srcObject = null; } catch {} - } - - bus = null; - } - - function buildMixBus(seed = sessionSeed) { - ensureAudioContext(); - teardownBusHard(); - - // Own each allocation immediately: setup can fail before `bus` exists. - const nodes = []; - const own = node => { nodes.push(node); return node; }; - let resonators; - try { - const masterGain = own(audioContext.createGain()); - masterGain.gain.value = MASTER_VOL; - masterGain.connect(audioContext.destination); - - const streamDest = own(audioContext.createMediaStreamDestination()); - masterGain.connect(streamDest); - - const reverbPreDelay = own(audioContext.createDelay(0.1)); - reverbPreDelay.delayTime.value = 0.015; - - const reverbNode = own(audioContext.createConvolver()); - reverbNode.buffer = createImpulseResponse(audioContext); - - const reverbLP = own(audioContext.createBiquadFilter()); - reverbLP.type = "lowpass"; - reverbLP.frequency.value = 4200; - reverbLP.Q.value = 0.7; - - const reverbSend = own(audioContext.createGain()); - reverbSend.gain.value = 0.0; - - const reverbReturn = own(audioContext.createGain()); - reverbReturn.gain.value = REVERB_RETURN_LEVEL; - - // Schedule the whole performance ahead of the current render quantum. - const origin = audioContext.currentTime + 0.05; - initializeNoteDrift(audioContext, seed); - reverbSend.connect(reverbPreDelay); - resonators = createMovingResonators(audioContext, reverbSend, reverbPreDelay, undefined, masterGain, origin); - reverbPreDelay.connect(reverbNode); - reverbNode.connect(reverbLP); - reverbLP.connect(reverbReturn); - reverbReturn.connect(masterGain); - - bus = { - origin, masterGain, reverbSend, reverbReturn, streamDest, - reverbPreDelay, reverbNode, reverbLP, resonators, - lastVoiceEnd: audioContext.currentTime, - tailSeconds: reverbNode.buffer.duration + reverbPreDelay.delayTime.value + 0.25 + resonators.tailSeconds - }; - cleanupInterval = setInterval(cleanupFinishedVoices, 250); - - ensureBridge(); - bridgeAudioEl.srcObject = streamDest.stream; - } catch (error) { - clearInterval(cleanupInterval); - cleanupInterval = null; - resonators?.dispose(); - disposeNoteDrift(audioContext); - for (const node of nodes) { - try { node.disconnect(); } catch {} - if (node.stream) for (const track of node.stream.getTracks()) track.stop(); - } - bus = null; - throw error; - } - } - - // ========================= - // LIVE RECORDING - // ========================= - let mediaRecorder = null; - let isRecording = false; - - function stopRecording() { - const recorder = mediaRecorder; - mediaRecorder = null; - isRecording = false; - if (recorder && recorder.state !== "inactive") { - try { recorder.stop(); } catch { - pendingRecordings.get(recorder)?.(); - announce("Recording could not be saved"); - } - } - } - - function setRecordUI(on) { - // Recording is an intentionally undocumented, keyboard-only feature (Shift+R). - // Feedback stays on the same invisible aria-live channel as everything else - // rather than adding any visible UI. - announce(on ? "Recording started" : "Recording saved"); - } - - function toggleRecording() { - if (disposed || !bus?.streamDest?.stream) return; - - if (isRecording) { - stopRecording(); - return; - } - - // Each recording owns its asynchronous callbacks, chunks, and MIME type. - const recordedChunks = []; - let recorder; - try { - const types = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg"]; - const mimeType = types.find(t => window.MediaRecorder && MediaRecorder.isTypeSupported(t)) || ""; - recorder = new MediaRecorder(bus.streamDest.stream, mimeType ? { mimeType } : undefined); - } catch (e) { - announce("Recording unavailable in this browser"); - return; - } - - let failed = false; - const cleanup = () => { - recordedChunks.length = 0; - recorder.ondataavailable = recorder.onerror = recorder.onstop = null; - pendingRecordings.delete(recorder); - }; - pendingRecordings.set(recorder, cleanup); - recorder.ondataavailable = e => { if (e.data.size > 0) recordedChunks.push(e.data); }; - recorder.onerror = () => { - failed = true; - if (mediaRecorder === recorder) { - stopRecording(); - announce("Recording failed"); - } - }; - recorder.onstop = () => { - if (mediaRecorder === recorder) { - mediaRecorder = null; - isRecording = false; - } - if (disposed || !recordedChunks.length) { - cleanup(); - if (!disposed && !isRecording) announce(failed ? "Recording failed" : "Recording contained no audio"); - return; - } - const blob = new Blob(recordedChunks, { type: recorder.mimeType || recordedChunks[0].type || "audio/webm" }); - cleanup(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const extension = blob.type.includes("ogg") ? "ogg" : blob.type.includes("mp4") ? "m4a" : "webm"; - a.download = `open-live-${Date.now()}.${extension}`; - document.body.appendChild(a); - a.click(); - if (!isRecording) { - if (failed) announce("Recording failed; partial audio saved"); - else setRecordUI(false); - } - setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 100); - }; - - try { recorder.start(250); } catch { - cleanup(); - announce("Recording could not start"); - return; - } - mediaRecorder = recorder; - isRecording = true; - setRecordUI(true); - } - - // ========================= - // RNG - // ========================= - let sessionSeed = 0; - let rng = Math.random; - function mulberry32(seed) { - let a = seed >>> 0; - return function() { - a |= 0; a = (a + 0x6D2B79F5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - } - function setSeed(seed) { sessionSeed = (seed >>> 0); rng = mulberry32(sessionSeed); } - function rand() { return rng(); } - function chance(p) { return rand() < p; } - - // ========================= - // MUSICAL STRUCTURE - // ========================= - function circDist(a, b) { const d = Math.abs(a - b); return Math.min(d, 7 - d); } - - let isPlaying = false; - let isEndingNaturally = false; - let timerInterval = null; - - let sessionStartTime = 0; - let sessionSnapshot = null; - let liveCursor = 0; - let liveNextGroup = null; - let liveTimeShift = 0; - - // The live musical rules have one owner. Planning draws all musical and - // spatial choices before rendering; neither renderer consumes these streams. - function createPerformanceGenerator(seed, baseFreq, duration) { - const rand = mulberry32(seed); - const chance = p => rand() < p; - const spatialRandom = mulberry32((seed ^ 0x57E2E0) >>> 0); - let nextTimeA = 0.05, patternIdxA = 0, notesSinceModulation = 0; - let circlePosition = 0, isMinor = false; - const runDensity = 0.05 + rand() * 0.20; - const noteDur = (1 / runDensity) * 2.5; - let phraseStep = 0, phraseCount = 0, arcLen = 6, arcPos = -1, arcClimaxAt = 4; - let tension = 0, lastCadenceType = "none", currentCadenceType = "none"; - let lastDroneStart = -9999, lastDroneDur = 0, finished = false; - function startNewArc() { - arcLen = 4 + Math.floor(rand() * 5); - arcClimaxAt = Math.max(2, arcLen - 2 - Math.floor(rand() * 2)); - arcPos = -1; - tension = clamp01(tension * 0.4 + 0.05); - } - - function cadenceRepeatPenalty(type) { - if (type !== lastCadenceType) return 0.0; - if (type === "authentic") return 0.30; - return 0.18; - } - - function pickCadenceTypeForPhrase() { - const nearClimax = (arcPos === arcClimaxAt); - const lateArc = (arcPos >= arcLen - 2); - let w = { evaded: 0.20, half: 0.28, plagal: 0.12, deceptive: 0.18, authentic: 0.22 }; - - if (arcPos < arcClimaxAt) { w.authentic = 0.05; w.evaded += 0.2; w.half += 0.1; } - w.authentic += tension * 0.25; w.deceptive += tension * 0.10; w.evaded -= tension * 0.18; - - if (nearClimax) { w.authentic += 0.25; w.deceptive += 0.10; w.evaded -= 0.20; } - if (lateArc && tension > 0.45) { w.authentic += 0.22; w.evaded -= 0.15; } - if (isMinor) { w.deceptive += 0.05; w.plagal -= 0.02; } - - for (const k of Object.keys(w)) w[k] = Math.max(0.001, w[k] - cadenceRepeatPenalty(k)); - - const keys = Object.keys(w); - const sum = keys.reduce((a, k) => a + w[k], 0); - let r = rand() * sum; - for (const k of keys) { r -= w[k]; if (r <= 0) return k; } - return "authentic"; - } - - function cadenceTargets(type) { - switch (type) { - case "authentic": return { pre: 6, end: 0, wantLT: true }; - case "half": return { pre: 1, end: 4, wantLT: false }; - case "plagal": return { pre: 3, end: 0, wantLT: false }; - case "deceptive": return { pre: 6, end: 5, wantLT: true }; - case "evaded": return { pre: 6, end: 2, wantLT: true }; - default: return { pre: 2, end: 0, wantLT: false }; - } - } - - function getScaleNote(baseFreq, scaleIndex, circlePos, minorMode, opts = {}) { - let pos = circlePos % 12; if (pos < 0) pos += 12; - let semitones = (pos * 7) % 12; - let rootOffset = semitones; if (minorMode) rootOffset = (semitones + 9) % 12; - const majorIntervals = [0, 2, 4, 5, 7, 9, 11]; - const minorIntervals = [0, 2, 3, 5, 7, 8, 10]; - const len = 7; - const octave = Math.floor(scaleIndex / len); - const degree = ((scaleIndex % len) + len) % len; - let intervals = minorMode ? minorIntervals : majorIntervals; - if (minorMode && opts.raiseLeadingTone && degree === 6) { intervals = minorIntervals.slice(); intervals[6] = 11; } - const noteValue = rootOffset + intervals[degree] + (octave * 12); - return baseFreq * Math.pow(2, noteValue / 12); - } - - function updateHarmonyState() { - const r = rand(); - let pressure = Math.min(1.0, notesSinceModulation / 48.0); - if (arcPos === arcClimaxAt) pressure *= 2.5; - pressure = Math.min(1.0, pressure); - if (r < pressure * 0.35) { - if (chance(0.2)) isMinor = !isMinor; - else circlePosition += (chance(0.5) ? 1 : -1); - notesSinceModulation = 0; - } - } - - function degreeFromIdx(idx) { - const base = Math.floor(idx / 7) * 7; - return ((idx - base) % 7 + 7) % 7; - } - - function shouldUseThirdDrone({ atCadenceZone, tensionVal, cadenceType, melodyDeg }) { - if (atCadenceZone) return false; - if (tensionVal >= 0.55) return false; - if (cadenceType === "half" || cadenceType === "deceptive" || cadenceType === "evaded") return false; - return (melodyDeg === 0 || melodyDeg === 2 || melodyDeg === 4); - } - - - startNewArc(); - const initialArcLen = arcLen, initialClimax = arcClimaxAt; - arcPos++; - currentCadenceType = pickCadenceTypeForPhrase(); - - return { - density: runDensity, arcLen: initialArcLen, arcClimaxAt: initialClimax, - get nextTime() { return nextTimeA; }, - next() { - if (finished) return null; - const group = { time: nextTimeA, notes: [], ending: false }; - let appliedDur = noteDur; - let pressure = Math.min(1.0, notesSinceModulation / 48.0); - updateHarmonyState(); - - if (duration !== Infinity && nextTimeA >= duration + LOOKAHEAD) { - if (patternIdxA % 7 === 0) { - let fEnd = getScaleNote(baseFreq, patternIdxA, circlePosition, isMinor); - while (fEnd > MELODY_CEILING_HZ && patternIdxA > -14) { - patternIdxA -= 7; - fEnd = getScaleNote(baseFreq, patternIdxA, circlePosition, isMinor); - } - fEnd = clampFreqMin(fEnd, MELODY_FLOOR_HZ); - group.notes.push(planBell(fEnd, nextTimeA, 25.0, 0.5, 0, 0, rand, spatialRandom)); - finished = true; - group.ending = true; - return group; - } - } - - phraseStep = (phraseStep + 1) % 16; - if (phraseStep === 0) { - phraseCount++; - arcPos = (arcPos + 1); - if (arcPos >= arcLen) startNewArc(); - currentCadenceType = pickCadenceTypeForPhrase(); - } - - const isCadence = (phraseStep >= 13); - if (chance(phraseStep === 15 ? 0.85 : 0.2)) appliedDur *= 1.2; - - if (isCadence) { - const cadenceDegrees = [0, 1, 3, 4, 5]; - const currentOctave = Math.floor(patternIdxA / 7) * 7; - let deg = patternIdxA - currentOctave; - deg = ((deg % 7) + 7) % 7; - let best = cadenceDegrees[0]; - let bestD = circDist(deg, best); - for (let i = 1; i < cadenceDegrees.length; i++) { - const t = cadenceDegrees[i]; const d = circDist(deg, t); - if (d < bestD || (d === bestD && chance(0.5))) { best = t; bestD = d; } - } - let targetDeg = best; - if (!chance(0.6)) { - const dir = chance(0.65) ? -1 : 1; - targetDeg = (targetDeg + dir + 7) % 7; - } - let delta = targetDeg - deg; - if (delta > 3) delta -= 7; if (delta < -3) delta += 7; - patternIdxA = currentOctave + deg + delta; - - const ct = currentCadenceType; - const cadencePlan = cadenceTargets(ct); - - if (phraseStep === 14 && chance(0.70)) { - const curOct = Math.floor(patternIdxA / 7) * 7; - const curDeg = ((patternIdxA - curOct) % 7 + 7) % 7; - let deltaPre = cadencePlan.pre - curDeg; - if (deltaPre > 3) deltaPre -= 7; if (deltaPre < -3) deltaPre += 7; - patternIdxA += deltaPre; - } - - if (phraseStep === 15) { - const curOct = Math.floor(patternIdxA / 7) * 7; - const curDeg = ((patternIdxA - curOct) % 7 + 7) % 7; - let deltaEnd = cadencePlan.end - curDeg; - if (deltaEnd > 3) deltaEnd -= 7; if (deltaEnd < -3) deltaEnd += 7; - - if (chance(0.35)) { - patternIdxA += deltaEnd; - } else if (chance(0.25)) { - patternIdxA += (deltaEnd > 0 ? deltaEnd - 1 : deltaEnd + 1); - } - - if(ct === "authentic") tension = clamp01(tension - 0.22); - else tension = clamp01(tension + 0.10); - lastCadenceType = ct; - } - } else { - let currentEvalFreq = getScaleNote(baseFreq, patternIdxA, circlePosition, isMinor); - let upChance = 0.5; - if (currentEvalFreq >= MELODY_CEILING_HZ * 0.8) { - upChance = 0.15; - } else if (currentEvalFreq <= MELODY_FLOOR_HZ * 1.2) { - upChance = 0.85; - } - patternIdxA += (rand() < upChance ? 1 : -1); - } - - const cadencePlan = currentCadenceType ? cadenceTargets(currentCadenceType) : null; - const wantLT = cadencePlan ? cadencePlan.wantLT : false; - const degNow = degreeFromIdx(patternIdxA); - const raiseLT = isMinor && isCadence && wantLT && (degNow === 6); - - let freq = getScaleNote(baseFreq, patternIdxA, circlePosition, isMinor, { raiseLeadingTone: raiseLT }); - - while (freq > MELODY_CEILING_HZ && patternIdxA > -14) { - patternIdxA -= 7; - freq = getScaleNote(baseFreq, patternIdxA, circlePosition, isMinor, { raiseLeadingTone: raiseLT }); - } - freq = clampFreqMin(freq, MELODY_FLOOR_HZ); - - const isArcStart = (arcPos === 0 && phraseStep === 0); - const isClimax = (arcPos === arcClimaxAt && phraseStep === 0); - const atPhraseStart = (phraseStep === 0); - - let droneProb = 0.04; - if (atPhraseStart) droneProb = 0.18; - - const canStartDrone = (nextTimeA >= lastDroneStart + lastDroneDur * 0.65); - - if (canStartDrone && (isArcStart || isClimax || chance(droneProb))) { - const ct = currentCadenceType || "authentic"; - let droneRootDegree = 0; - if (!isArcStart && !isClimax) { - if (ct === "half") droneRootDegree = 4; - else if (ct === "deceptive") droneRootDegree = chance(0.6) ? 0 : 5; - else if (ct === "plagal") droneRootDegree = chance(0.6) ? 3 : 0; - else droneRootDegree = 0; - } - - const melodyDegNow = degreeFromIdx(patternIdxA); - const useThirdColor = shouldUseThirdDrone({ - atCadenceZone: (phraseStep >= 13), - tensionVal: tension, - cadenceType: ct, - melodyDeg: melodyDegNow - }); - - const curRegister = Math.floor(patternIdxA / 7); - const droneOct = Math.min(curRegister - 1, 0); - const droneIdx = droneOct * 7 + droneRootDegree; - - let droneRootFreq = getScaleNote(baseFreq, droneIdx, circlePosition, isMinor); - droneRootFreq = clampFreqMin(droneRootFreq, DRONE_FLOOR_HZ); - - const t0 = Math.max(nextTimeA - 0.05, 0); - let droneDur = isArcStart ? 32.0 : 22.0; - - lastDroneStart = t0; - lastDroneDur = droneDur; - - const baseVol = (isArcStart || isClimax) ? 0.40 : 0.28; - const quality = isMinor ? "min" : "maj"; - - group.notes.push(...planDrone(droneRootFreq, t0, droneDur, baseVol, quality, useThirdColor, rand, spatialRandom)); - } - - const isDroneSolo = (arcPos === 0 && phraseStep < 12 && phraseCount > 0); - if (!isDroneSolo) { - group.notes.push(planBell(freq, nextTimeA, appliedDur, 0.4, pressure, tension, rand, spatialRandom)); - } - - notesSinceModulation++; - nextTimeA += (1 / runDensity) * (0.95 + rand() * 0.1); - return group; - } - }; - } - - function createPerformance(seed, tone, durationInput) { - const duration = durationInput === "infinite" ? Infinity : Number(durationInput); - if (![60, 300, 600, 1800, Infinity].includes(duration)) throw new Error("Invalid duration"); - const generator = createPerformanceGenerator(seed, tone, duration); - const groups = []; - let lastVoiceEnd = 0; - while (duration !== Infinity || generator.nextTime < 1800) { - const group = generator.next(); - if (!group) break; - for (const note of group.notes) { - lastVoiceEnd = Math.max(lastVoiceEnd, note.time + note.duration); - if (note.voices) { - note.voices.forEach(Object.freeze); - Object.freeze(note.voices); - } - Object.freeze(note); - } - Object.freeze(group.notes); - groups.push(Object.freeze(group)); - if (groups.length > 10000) throw new Error("Performance exceeded planning limit"); - } - return Object.freeze({ - seed, tone, duration: durationInput, density: generator.density, - arcLen: generator.arcLen, arcClimaxAt: generator.arcClimaxAt, - groups: Object.freeze(groups), - // Include per-voice settling, resonator settling, the full IR and pre-delay. - exportDuration: Math.max(duration === Infinity ? 1800 : duration, lastVoiceEnd) - + VOICE_SETTLE_SECONDS + 0.1 + 10 + 0.015 + 0.25, - continuation: duration === Infinity ? generator : null - }); - } - - // Only plain note data is retained. Audio nodes are created near playback time. - function choosePan(random) { return random() < 0.65 ? (random() * 2 - 1) * 0.22 : null; } - - function planBell(freq, time, duration, volume, instability, tensionAmt, rand, spatialRandom) { - freq = clampFreqMin(freq, MELODY_FLOOR_HZ); - - const numVoices = 2 + Math.floor(rand() * 2); - let totalAmp = 0; - const isFractured = (tensionAmt > 0.75); - const FRACTURE_RATIOS = [Math.SQRT2, 1.618, 2.414, 2.718, 3.1415]; - const ratioFuzz = isFractured ? 0.08 : 0.0; - - const baseRatio = isFractured - ? FRACTURE_RATIOS[Math.floor(rand() * FRACTURE_RATIOS.length)] - : (1.5 + rand() * 2.5); - - const voices = Array.from({ length: numVoices }, () => { - let mRatio = baseRatio; - if (isFractured) mRatio += (rand() - 0.5) * ratioFuzz; - const mIndex = 1.0 + (tensionAmt * 2.0) + (rand() * 3.0); - const v = { modRatio: mRatio, modIndex: mIndex, amp: rand() }; - totalAmp += v.amp; - return v; - }); - - - for (const voice of voices) { - voice.frequency = freq + (rand() - 0.5) * (2 + instability * (isFractured ? 15 : 10)); - } - return { kind: "bell", freq, time, duration, volume, voices, totalAmp, pan: choosePan(spatialRandom) }; - } - - function planDrone(rootFreq, time, duration, baseVolume, quality, includeThird, random, spatialRandom) { - const f0 = clampFreqMin(rootFreq, DRONE_FLOOR_HZ); - const pitches = [[f0, 0.50], [f0 * Math.pow(2, 7 / 12), 0.30]]; - if (includeThird) pitches.push([f0 * Math.pow(2, (quality === "min" ? 3 : 4) / 12), 0.20]); - return pitches.map(([freq, level]) => ({ - kind: "bass", freq, time, duration, volume: baseVolume * DRONE_GAIN_MULT * level, - detune: (random() - 0.5) * 8, pan: choosePan(spatialRandom) - })); - } - - // ========================= - // SYNTH - // ========================= - function scheduleNote(ctx, destination, wetSend, freq, time, duration, volume, instability = 0, tensionAmt = 0, planned = null) { - const note = planned || planBell(freq, time, duration, volume, instability, tensionAmt, rand, spatialStates.get(ctx).random); - freq = note.freq; - const { voices, totalAmp } = note; - - const spatialInput = createNoteDrift(ctx, destination, wetSend, time, duration, note.pan); - voices.forEach(voice => { - const carrier = trackNode(ctx, ctx.createOscillator()); - const modulator = trackNode(ctx, ctx.createOscillator()); - const modGain = trackNode(ctx, ctx.createGain()); - const ampGain = trackNode(ctx, ctx.createGain()); - const filter = trackNode(ctx, ctx.createBiquadFilter()); - - filter.type = "lowpass"; - filter.frequency.value = Math.min(freq * 3.5, 6000); - filter.Q.value = 0.6; - - carrier.frequency.value = voice.frequency; - modulator.frequency.value = freq * voice.modRatio; - - modGain.gain.setValueAtTime(freq * voice.modIndex, time); - modGain.gain.exponentialRampToValueAtTime(freq * 0.01, time + (duration * 0.3)); - - ampGain.gain.setValueAtTime(0.0001, time); - ampGain.gain.exponentialRampToValueAtTime((voice.amp / totalAmp) * volume, time + 0.01); - ampGain.gain.exponentialRampToValueAtTime(0.0001, time + duration); - - modulator.connect(modGain); - modGain.connect(carrier.frequency); - carrier.connect(ampGain); - ampGain.connect(filter); - filter.connect(spatialInput); - - modulator.start(time); carrier.start(time); - modulator.stop(time + duration); carrier.stop(time + duration); - registerVoice(ctx, [carrier, modulator, modGain, ampGain, filter], time + duration); - }); - } - - function scheduleBassVoice(ctx, destination, wetSend, freq, time, duration, volume, random = rand, planned = null) { - const carrier = trackNode(ctx, ctx.createOscillator()); - const modulator = trackNode(ctx, ctx.createOscillator()); - const modGain = trackNode(ctx, ctx.createGain()); - const ampGain = trackNode(ctx, ctx.createGain()); - const lp = trackNode(ctx, ctx.createBiquadFilter()); - - carrier.type = "sine"; - modulator.type = "sine"; - carrier.frequency.value = freq; - modulator.frequency.value = freq * 2.0; - modulator.detune.value = planned ? planned.detune : (random() - 0.5) * 8; - - modGain.gain.setValueAtTime(0, time); - modGain.gain.linearRampToValueAtTime(freq * 1.8, time + (duration * 0.5)); - modGain.gain.linearRampToValueAtTime(0, time + duration); - - ampGain.gain.setValueAtTime(0.0001, time); - ampGain.gain.exponentialRampToValueAtTime(volume, time + 2.0); - ampGain.gain.exponentialRampToValueAtTime(0.0001, time + duration); - - lp.type = "lowpass"; - lp.frequency.setValueAtTime(600, time); - lp.Q.value = 0.6; - - modulator.connect(modGain); modGain.connect(carrier.frequency); - carrier.connect(ampGain); ampGain.connect(lp); - lp.connect(createNoteDrift(ctx, destination, wetSend, time, duration, planned?.pan)); - - modulator.start(time); carrier.start(time); - modulator.stop(time + duration); carrier.stop(time + duration); - registerVoice(ctx, [carrier, modulator, modGain, ampGain, lp], time + duration); - } - - function scheduleDroneChord(ctx, destination, wetSend, rootFreq, time, duration, baseVolume, quality, includeThird = true, random = rand) { - let f0 = clampFreqMin(rootFreq, DRONE_FLOOR_HZ); - const thirdRatio = (quality === "min") ? Math.pow(2, 3/12) : Math.pow(2, 4/12); - const fifthRatio = Math.pow(2, 7/12); - const vol = baseVolume * DRONE_GAIN_MULT; - - scheduleBassVoice(ctx, destination, wetSend, f0, time, duration, vol * 0.50, random); - scheduleBassVoice(ctx, destination, wetSend, f0 * fifthRatio, time, duration, vol * 0.30, random); - if (includeThird) { - scheduleBassVoice(ctx, destination, wetSend, f0 * thirdRatio, time, duration, vol * 0.20, random); - } - } - - // ========================= - // SCHEDULER - // ========================= - function renderGroup(ctx, master, wetSend, group, origin) { - for (const note of group.notes) { - const time = origin + note.time; - if (note.kind === "bell") { - scheduleNote(ctx, master, wetSend, note.freq, time, note.duration, note.volume, 0, 0, note); - } else { - scheduleBassVoice(ctx, master, wetSend, note.freq, time, note.duration, note.volume, rand, note); - } - } - } - - function nextLiveGroup() { - if (liveCursor < sessionSnapshot.groups.length) return sessionSnapshot.groups[liveCursor++]; - return sessionSnapshot.continuation?.next() || null; - } - - function scheduler() { - if (!isPlaying || !audioContext || !bus) return; - const now = audioContext.currentTime; - const boundary = now + LOOKAHEAD; - let events = 0; - while (liveNextGroup && events++ < MAX_EVENTS_PER_TICK) { - // Keep the next phrase event after a stall, with no overdue-node backlog. - // This shift belongs only to live playback, never to the stored score. - const earliest = Math.min(liveNextGroup.time, ...liveNextGroup.notes.map(n => n.time)); - const start = sessionStartTime + liveTimeShift + earliest; - if (start < now) liveTimeShift += now + 0.05 - start; - if (sessionStartTime + liveTimeShift + liveNextGroup.time >= boundary) break; - renderGroup(audioContext, bus.masterGain, bus.reverbSend, liveNextGroup, sessionStartTime + liveTimeShift); - if (liveNextGroup.ending) { liveNextGroup = null; beginNaturalEnd(); return; } - liveNextGroup = nextLiveGroup(); - } - } - - function handleVisibilityChange(e) { - if (!isMobileDevice()) return; - - const type = e?.type || ""; - const isBackgrounding = - document.hidden || - type === "pagehide" || - type === "freeze" || - type === "blur"; - - if (!isBackgrounding) return; - - if (isPlaying || isEndingNaturally || bus || audioContext) { - closeCtxAfterStop = true; - stopAllManual(true, "Stopped (background)"); - closeCtxAfterStop = false; - } - } - - // ========================= - // CONTROLS - // ========================= - async function startFromUI() { - if (disposed) return; - let request = ++startRequest; - try { - ensureAudioContext(); - const ctx = audioContext; - if (ctx.state === "suspended") await ctx.resume(); - if (request !== startRequest || audioContext !== ctx) return; - if (ctx.state !== "running") throw new Error("Audio context is not running"); - - stopAllManual(true); - request = startRequest; - - isEndingNaturally = false; - const seed = (crypto?.getRandomValues ? crypto.getRandomValues(new Uint32Array(1))[0] : Date.now()) >>> 0; - let tone = Number($("tone")?.value ?? 110); - if (!Number.isFinite(tone)) tone = 110; - tone = Math.max(110, Math.min(200, tone)); - const duration = $("songDuration")?.value ?? "60"; - const performance = createPerformance(seed, tone, duration); - buildMixBus(seed); - setSeed(seed); - // Created once per run; exports keep this ID even after Stop or a new Play. - const runId = `${seed.toString(16).padStart(8, "0")}-${Date.now().toString(36)}`; - sessionSnapshot = Object.freeze({ ...performance, sampleRate: ctx.sampleRate, runId }); - liveCursor = 0; - liveTimeShift = 0; - liveNextGroup = nextLiveGroup(); - isPlaying = true; - sessionStartTime = bus.origin; - automateMix(bus.masterGain, bus.reverbSend, sessionStartTime, performance.density); - if (bridgeAudioEl) bridgeAudioEl.play().catch(()=>{}); - - setButtonState("playing"); - - if (timerInterval) clearInterval(timerInterval); - timerInterval = setInterval(scheduler, SCHEDULER_INTERVAL_MS); - scheduler(); - } catch (error) { - if (request === startRequest) { - stopAllManual(true, "Playback could not start. Press Play to retry."); - } - } - } - - function stopAllManual(instant = false, statusMsg = "Stopped") { - startRequest++; - clearTimeout(teardownTimer); - teardownTimer = null; - isPlaying = false; - isEndingNaturally = false; - liveNextGroup = null; - if (timerInterval) clearInterval(timerInterval); - - stopRecording(); - - if (!instant && bus?.masterGain && audioContext) { - const t = audioContext.currentTime; - try { - bus.masterGain.gain.cancelScheduledValues(t); - bus.masterGain.gain.setValueAtTime(bus.masterGain.gain.value, t); - bus.masterGain.gain.linearRampToValueAtTime(0, t + 0.10); - } catch {} - const stoppedBus = bus; - teardownTimer = setTimeout(() => { - if (bus === stoppedBus) teardownBusHard(); - }, 150); - } else { - teardownBusHard(); - } - - if (instant && closeCtxAfterStop && audioContext) { - try { audioContext.close(); } catch {} - audioContext = null; - } - - setButtonState("stopped"); - announce(statusMsg); - } - - function beginNaturalEnd() { - isEndingNaturally = true; - isPlaying = false; - if (timerInterval) clearInterval(timerInterval); - setButtonState("stopped"); - } - - // ========================= - // EXPORT WAV (The current performance) - // ========================= - let isExporting = false; - async function renderWavExport() { - if (disposed) return; - if (!sessionSnapshot) { announce("Press Play once before exporting"); return; } - if (isExporting) { announce("WAV export already in progress"); return; } - isExporting = true; - try { - await renderWavSession({ ...sessionSnapshot }); - } catch (error) { - announce("WAV export failed. Press Play with a shorter duration to try again."); - } finally { - isExporting = false; - } - } - - function automateMix(master, send, origin, density) { - master.gain.setValueAtTime(0, origin); - master.gain.linearRampToValueAtTime(MASTER_VOL, origin + 0.1); - const targetSend = 0.65 - 0.25 * clamp01((density - 0.05) / 0.20); - send.gain.setTargetAtTime(targetSend, origin, 2.5); - } - - async function renderWavSession(sessionSnapshot) { - const sampleRate = sessionSnapshot.sampleRate; - const exportDuration = sessionSnapshot.exportDuration; - const offlineCtx = new OfflineAudioContext(2, Math.ceil(sampleRate * exportDuration), sampleRate); - const offlineMaster = offlineCtx.createGain(); - offlineMaster.gain.value = MASTER_VOL; - offlineMaster.connect(offlineCtx.destination); - - const offlinePreDelay = offlineCtx.createDelay(0.1); - offlinePreDelay.delayTime.value = 0.015; - const offlineReverb = offlineCtx.createConvolver(); - // The same deterministic room at the session's native sample rate. - offlineReverb.buffer = createImpulseResponse(offlineCtx, 0, false); - const offlineReverbLP = offlineCtx.createBiquadFilter(); - offlineReverbLP.type = "lowpass"; - offlineReverbLP.frequency.value = 4200; - offlineReverbLP.Q.value = 0.7; - const offlineSend = offlineCtx.createGain(); - offlineSend.gain.value = 0; - const offlineReturn = offlineCtx.createGain(); - offlineReturn.gain.value = REVERB_RETURN_LEVEL; - - let renderedBuffer, offlineResonators; - try { - initializeNoteDrift(offlineCtx, sessionSnapshot.seed); - offlineSend.connect(offlinePreDelay); - offlineResonators = createMovingResonators(offlineCtx, offlineSend, offlinePreDelay, exportDuration, offlineMaster, 0); - offlinePreDelay.connect(offlineReverb); - offlineReverb.connect(offlineReverbLP); - offlineReverbLP.connect(offlineReturn); - offlineReturn.connect(offlineMaster); - automateMix(offlineMaster, offlineSend, 0, sessionSnapshot.density); - for (const group of sessionSnapshot.groups) renderGroup(offlineCtx, offlineMaster, offlineSend, group, 0); - renderedBuffer = await offlineCtx.startRendering(); - } finally { - offlineResonators?.dispose(); - disposeNoteDrift(offlineCtx); - } - if (disposed) return; - const wavBlob = await bufferToWave(renderedBuffer); - if (disposed) return; - const url = URL.createObjectURL(wavBlob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = `open-run-${sessionSnapshot.runId}-${Date.now()}.wav`; - document.body.appendChild(a); - a.click(); - setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 150); - announce("WAV downloaded"); - } - - function bufferToWave(abuffer) { - return new Promise((resolve, reject) => { - if (disposed) { reject(new Error("Player disposed")); return; } - const worker = new Worker("wav-worker.js"); - let offset = 0; - let finished = false; - let idleTicks = 0; - // Count foreground checks, not elapsed wall time: a frozen/background tab - // must not turn a healthy encoder into a timeout when it resumes. - const watchdog = setInterval(() => { - if (document.hidden) { idleTicks = 0; return; } - if (++idleTicks >= 30) finish(new Error("WAV encoder stopped responding")); - }, 1000); - const cancel = () => finish(new Error("Player disposed")); - cancelEncoders.add(cancel); - function finish(error, blob) { - if (finished) return; - finished = true; - clearInterval(watchdog); - cancelEncoders.delete(cancel); - worker.onmessage = worker.onerror = worker.onmessageerror = null; - worker.terminate(); - if (error) reject(error); - else resolve(blob); - } - worker.onerror = (event) => { - event.preventDefault(); - finish(new Error("WAV encoder failed")); - }; - worker.onmessageerror = () => finish(new Error("WAV encoder message failed")); - worker.onmessage = ({ data }) => { - if (finished) return; - idleTicks = 0; - if (data.type === "error") { finish(new Error(data.message)); return; } - if (data.type === "done") { finish(null, data.blob); return; } - if (data.type !== "ready") { finish(new Error("Invalid WAV encoder response")); return; } - try { - // Copy and transfer one small chunk at a time. AudioBuffer-owned - // storage stays intact; the UI never scans the entire recording. - const count = Math.min(65536, abuffer.length - offset); - if (count <= 0) throw new Error("Unexpected WAV encoder request"); - const channels = Array.from({ length: abuffer.numberOfChannels }, (_, ch) => { - const samples = new Float32Array(count); - abuffer.copyFromChannel(samples, ch, offset); - return samples; - }); - worker.postMessage({ type: "samples", offset, channels }, channels.map(ch => ch.buffer)); - offset += count; - } catch (error) { finish(error); } - }; - try { - worker.postMessage({ type: "start", length: abuffer.length, - sampleRate: abuffer.sampleRate, channels: abuffer.numberOfChannels }); - } catch (error) { finish(error); } - }); - } - - // ========================= - // INIT & LISTENERS - // ========================= - function initialize() { - // player.js only ever runs on player.html (see the + diff --git a/player.js b/player.js index 8c687f8..699f728 100644 --- a/player.js +++ b/player.js @@ -1428,7 +1428,8 @@ function bufferToWave(abuffer) { return new Promise((resolve, reject) => { if (disposed) { reject(new Error("Player disposed")); return; } - const worker = new Worker("wav-worker.js"); + // Keep this cache tag aligned with the player.html asset tags. + const worker = new Worker("wav-worker.js?v=1.0"); let offset = 0; let finished = false; let idleTicks = 0; diff --git a/scripts/stage-experiment.py b/scripts/stage-experiment.py deleted file mode 100644 index 606e5d8..0000000 --- a/scripts/stage-experiment.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -"""Stage the experimental player snapshot for the existing main-branch Pages site. -Run in a main checkout, review the diff, then commit/push main to publish. -""" -import json -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -REF = 'refs/remotes/origin/experiment/moving-resonators' -DEST = ROOT / 'experiments' / 'moving-resonators' -FILES = ('index.html', 'player.html', 'style.css', 'player.js', 'wav-worker.js') - -def git(*args): - return subprocess.check_output(['git', '-C', str(ROOT), *args], text=True) - -sha = git('rev-parse', '--verify', REF).strip() -# Resolve one commit before reading files so every asset comes from the same version. -assets = {name: git('show', f'{sha}:{name}') for name in FILES} -assets['index.html'] = assets['index.html'].replace('Open', 'Open — Experimental').replace('"open_player",', '"open_player_experiment",') -assets['player.html'] = assets['player.html'].replace('Open — Player', 'Open — Experimental Player') -old_key = 'const STATE_KEY = "open_player_settings";' -if old_key not in assets['player.js']: - raise SystemExit('Settings key changed; review preview isolation before publishing.') -assets['player.js'] = assets['player.js'].replace(old_key, 'const STATE_KEY = "open_player_settings:experiment/moving-resonators";') -DEST.mkdir(parents=True, exist_ok=True) -for name, content in assets.items(): - (DEST / name).write_text(content) -(DEST / 'version.json').write_text(json.dumps({'branch': 'experiment/moving-resonators', 'commit': sha}, indent=2) + '\n') -print(f'Staged {sha} at {DEST}') diff --git a/social-preview.png b/social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..ad89e2ae7d6a307dc06af17cb3c69bb283510c62 GIT binary patch literal 24681 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 literal 0 HcmV?d00001 diff --git a/social-preview.svg b/social-preview.svg new file mode 100644 index 0000000..62a9a0b --- /dev/null +++ b/social-preview.svg @@ -0,0 +1,11 @@ + + Open — Bell Music for Desktop Listening + A minimal black-on-white title card for Aaron Koppel's browser instrument. + + + OPEN + + Bell Music for Desktop Listening + By Aaron Koppel + + diff --git a/tests/README.md b/tests/README.md index 538a97f..39de5a1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,11 +20,10 @@ Additional regressions cover stalled scheduler callbacks, pauses in the audio cl Replay checks cover seed and legacy-filename input, full filename/link round trips, sample-rate restoration, validation without interrupting playback, URL loading without autoplay, fresh seeds on Play, clipboard fallback, and pending-resume -cancellation. Frozen engine-1 score hashes guard both EP performances: Short -Study (`d5f9fea7`, 110 Hz, Short) and Long Study (`d1a0b62b`, 121 Hz, Long). -These fixtures were generated with September commit -`400bde65e914e3419f84d783f54a2dc2d3cf54e8` and checked against the recordings' -durations and opening carrier frequencies. Hashes normalize numbers to eight +cancellation. Frozen engine-1 score hashes guard a Short fixture +(`d5f9fea7`, 110 Hz) and a Long fixture (`d1a0b62b`, 121 Hz). +These fixtures were generated with commit +`400bde65e914e3419f84d783f54a2dc2d3cf54e8`. Hashes normalize numbers to eight decimal places to allow last-bit math differences across JavaScript engines. They verify planned musical data; native rendering is covered separately by the browser suite, including a page @@ -38,7 +37,7 @@ The sparse interface, musical probabilities, synthesis envelopes, live ending ru If the scheduler resumes after an event's start time has passed, it schedules the next event 50 ms ahead of the current audio clock and keeps the current phrase state. It does not generate missed events. Normal scheduling, clock suspension, and the live ending rules retain their previous behavior. Export now follows that same planned ending. -WAV encoding runs in a dedicated worker. At most 65,536 frames per channel are copied and transferred in each message; the next chunk is sent only after the worker acknowledges the previous one. The worker builds the final file from Blob parts, preserving the original 16-bit conversion without a full-size WAV ArrayBuffer on the main thread. The full OfflineAudioContext render buffer is still required (roughly 635–691 MB for 30 minutes before the ending and tail, at 44.1–48 kHz), so long exports remain memory intensive. +WAV encoding runs in a dedicated worker. At most 65,536 frames per channel are copied and transferred in each message; the next chunk is sent only after the worker acknowledges the previous one. The worker builds the final file from Blob parts, preserving the original 16-bit conversion without a full-size WAV ArrayBuffer on the main thread. The full OfflineAudioContext render buffer is still required (roughly 741–806 MB for 35 minutes of rendered audio at 44.1–48 kHz), so long exports remain memory intensive. ## Root Tone dial @@ -90,7 +89,7 @@ The instance disposer removes event listeners, stops live audio, clears recorder The export renders stereo audio at the session's native sample rate with 32-bit float samples, then encodes 16-bit PCM. The render length is derived from the longest planned voice plus per-voice settling (100 ms), resonator settling (100 ms), the full impulse response (10 seconds), pre-delay (15 ms), and return-filter margin (250 ms). Fixed durations also include the live engine's resolution beyond the selected duration. Infinite schedules only events before 30 minutes and includes their full decay. -For an actual rendered duration `T` seconds and sample rate `R`, render bytes = `ceil(T * R) * 2 * 4`; WAV bytes = `ceil(T * R) * 2 * 2 + 44`. Thirty minutes without an ending or tail therefore needs 635.04 MB of sample data at 44.1 kHz or 691.20 MB at 48 kHz, plus a 317.52 MB or 345.60 MB WAV. These are payload sizes, not measured RAM peaks. +For an actual rendered duration `T` seconds and sample rate `R`, render bytes = `ceil(T * R) * 2 * 4`; WAV bytes = `ceil(T * R) * 2 * 2 + 44`. For 35 minutes of rendered audio, this means 740.88 MB of sample data at 44.1 kHz or 806.40 MB at 48 kHz, plus a 370.44 MB or 403.20 MB WAV. The README uses about 35 minutes for Long-export memory planning, not as a hard duration cap; longer performances scale proportionally. These are decimal MB payload sizes, not measured RAM peaks. The render buffer remains available during encoding while WAV Blob parts accumulate. One stereo input chunk contains up to 524,288 bytes and its encoded PCM contains up to 262,144 bytes. Chunking bounds these transfers, not the total export footprint. Offline synthesis nodes, reverb buffers, live playback, browser internals, and temporary copies add overhead. Long exports remain available with a README memory advisory. @@ -105,12 +104,6 @@ A 50 ms scheduling lead-in gives live nodes a common future origin, avoiding lat Regressions check multiple seeds and durations, full voice modulation/envelopes and panning, room samples and LFO start times, zero seeds, repeat exports after Stop/control changes, exporting during replacement Play, stalls, and bounded Infinite history. Native browser tests compare repeat WAV headers and PCM samples and inspect tail silence. Native DSP summation can round differently: the repeat comparison permits at most two 16-bit PCM steps and requires RMS difference below 0.01% of the signal; it does not require identical file bytes. These establish shared scheduling and repeatable rendering in the tested browser; they do not claim a byte-identical recording of physical device output. -## Online experimental player - -The stable player is served at `/open/player.html`; the experiment is served at `/open/experiments/moving-resonators/player.html` on the same GitHub Pages domain. The preview is a staged snapshot of `experiment/moving-resonators`, with its source commit recorded in `experiments/moving-resonators/version.json`. Its title, popup name, and localStorage settings key are isolated; audio code is otherwise copied unchanged. - -To refresh the online preview, fetch `origin/experiment/moving-resonators`, run `python scripts/stage-experiment.py` in a main checkout, review the generated changes, and commit/push main. A branch push alone does not refresh the published snapshot. Existing GitHub Pages deployment publishes both players together; no Pages settings changes are needed. Keep the root player assets unchanged when refreshing only the experiment. - ## Moving resonators The main player now includes three parallel bandpass filters from the existing reverb send into its pre-delay, before convolution. The filtered blend also feeds the master directly, exposing its changing overtones without another trip through reverb. The original reverb send path and direct voices remain connected. Centers are 420, 1050, and 2400 Hz, with Q=3 and a shared gain of 0.18 after summation. Independent sine LFOs move detune by ±480, ±600, and ±420 cents at 0.037, 0.023, and 0.017 Hz. Motion continues across notes and decays; it resets on a new session. diff --git a/tests/browser/player.spec.cjs b/tests/browser/player.spec.cjs index 344ad9f..7cce6f9 100644 --- a/tests/browser/player.spec.cjs +++ b/tests/browser/player.spec.cjs @@ -253,7 +253,7 @@ test('real WAV rendering and worker encoding allow playback interaction', async // Slow worker acknowledgements to exercise interaction during encoding reliably. // All DSP, PCM conversion, transfers, and the resulting download remain native. const worker = await fs.readFile(path.join(__dirname, '../../wav-worker.js'), 'utf8'); - await page.route('**/wav-worker.js', route => route.fulfill({ + await page.route('**/wav-worker.js?v=1.0', route => route.fulfill({ contentType: 'text/javascript', body: `const send = self.postMessage.bind(self); self.postMessage = data => data.type === 'ready' ? setTimeout(() => send(data), 10) : send(data);\n${worker}` diff --git a/tests/player.test.cjs b/tests/player.test.cjs index 99d4c59..8d4c709 100644 --- a/tests/player.test.cjs +++ b/tests/player.test.cjs @@ -77,7 +77,7 @@ function harness(source = fs.readFileSync(path.join(__dirname, '../player.js'), } class Worker { constructor(url) { - assert.equal(url, 'wav-worker.js'); + assert.equal(url, 'wav-worker.js?v=1.0'); this.messages = []; this.terminated = false; workers.push(this); const self = { postMessage: data => queueMicrotask(() => { @@ -177,9 +177,8 @@ function dialHarness(savedTone = '155') { return h; } -test('replay restores both EP scores without changing musical choices', async () => { - // Frozen from the September engine, independently checked against the EP - // recordings' durations and opening carriers before adding replay. +test('replay restores frozen Short and Long scores without changing musical choices', async () => { + // Frozen from the September engine before adding replay. const fixtures = [ ['d5f9fea7', '110', 'short', 404.615480396285, '572bdecd635a3c3f6506cfe721b3b91e52a70a3b752a73cedcd14f44ebe3087f'], ['d1a0b62b', '121', 'long', 947.6810024881768, '63e6d2960f072c300c8a3d672844d4dc6e2ea47702b2fca0b4c1444a11aa34a4']