Audio OnlineSample gallery ↗

Audio Online — Step 1: engine and dependency report

Status: awaiting Curt's sign-off. Nothing has been vendored and no app code exists yet. Date: 2026-10-30. Spike harness and raw results: spikes/step1/.

---

0. Requirements no candidate can meet (read first)

  1. The sandbox cannot run Web Audio. HashBin SDK v0.4.0's createSandbox() runs code only in a

classic worker inside the opaque iframe. The iframe window runs only the SDK's fixed bootstrap. OfflineAudioContext, AudioContext and AudioWorklet are undefined in that worker in all three engines (spike, §2.1). "Mix in the sandbox iframe's window" would need a new SDK mode or an app-owned frame bootstrap (Livelier does this). Proposed resolution: do all rendering in the worker with our own deterministic DSP. Native Web Audio is then used only for playback on the editor origin. This also gives better determinism (item 3).

  1. WebCodecs is not available in the sandbox worker on Chromium. Chromium reports

isSecureContext: false for the SDK's blob worker in an opaque frame, so AudioEncoder and crypto.subtle are absent. Firefox and WebKit expose them. Proposed resolution: the sandbox returns PCM, and compressed encoding runs on the editor origin. That code is ours and only processes bounded Float32Array data, never user code.

  1. Native Web Audio is not bit-exact across engines. Every node except a plain buffer copy gives

different bytes in Chromium, Firefox and WebKit. Firefox's resampler is materially different: the peak of resampled white noise is 1.31 there versus 1.94 elsewhere (§2.2). Within one engine it is repeatable. Rendering with native nodes would therefore meet the brief's per-engine rule, but never a cross-engine one.

  1. No permissive neural voice stack exists off the shelf. Every maintained browser wrapper for

Kokoro (kokoro-js), Kitten TTS (kitten-tts-js) and Piper (piper-tts-web and its forks) converts text to phonemes with eSpeak NG, which is GPL-3.0. phonemizer@1.2.1 is labelled Apache-2.0 on npm, but its 1.3 MB bundle is a compiled eSpeak NG. Upstream Piper's successor (OHF-Voice/piper1-gpl) is GPL-3.0 as well. The model weights themselves are permissive. Curt must choose one of three options: accept a GPL phonemizer that is downloaded on demand, have us write an original CMUdict-based G2P, or skip neural voices in v1 (§4.3, decision D3).

  1. eSpeak NG cannot be the bundled voice under MIT-only terms. It is GPL-3.0. The only small,

permissive bundled engine with acceptable quality is SVOX Pico (Apache-2.0). It supports 6 languages instead of 100+. Debian classes Pico's language files as non-free (§4.1).

  1. Neural-voice determinism is unverified. VITS (Piper) and Kokoro's vocoder contain random-noise

ops. Bit-exact output needs either seeded or zeroed noise inputs (to verify in Step 3) or a verified render cache keyed by (text, voice, model hash). I can't promise it yet.

  1. Not verified on real Safari. WebKit results come from Playwright WebKit 27.2. Driving Safari

would need *Allow Remote Automation*, a system setting I must not change. Historically Safari's AudioEncoder support lagged, so WAV export is the guaranteed fallback.

  1. Strudel compatibility is not on offer. Strudel is AGPL-3.0, so the pattern language must be an

original implementation of a Tidal-style mini-notation subset. It won't be Strudel-compatible.

---

1. Recommendation in brief

produces a timeline JSON graph. An original JS renderer in the same worker realizes it with deterministic DSP. The worker returns bounded PCM (Float32Array per channel, with declared length and sample rate).

and Math.sqrt matched too) plus our own dmath sin/cos/exp/log/tanh. Third-party JS synths (ZzFX, jsfxr) load unmodified, with a module-scope Math shadow prepended at load time, so the vendored files stay byte-identical. WASM engines (Pyodide/numpy, Pico) are already bit-exact across engines. Result: identical bytes on Chromium, Firefox and WebKit, which is stronger than the brief asks. The spike proved this for every engine-side piece we would ship (§2.2).

encoding, and muxing with original code extended from ManimOnline's manim-video.js.

render takes milliseconds. Python documents add about 16.4 MB of downloads and about 1.2 s of warm startup.

voices on demand:** Kokoro-82M (86 MB, Apache-2.0) and/or Kitten TTS nano (24 MB, Apache-2.0) on onnxruntime-web (MIT, 14.8 MB WASM). The phonemizer depends on decision D3.

---

2. Spikes (run 2026-10-30 on macOS 27.0.1, Apple silicon)

Engines: Chromium 156.0.8078.4 (Playwright 1.64.1), Firefox 159.0 (desktop build through WebDriver BiDi, because Playwright's Firefox Nightly 157 fails at launch on this Mac with "Could not find profile folder"; hashbin.org's owned-app doc reports the same failure), and WebKit 27.2 (Playwright). The page used the real vendored SDK v0.4.0, a sibling-style CSP (SDK bootstrap hash, blob:, 'wasm-unsafe-eval') and real engines throughout, with no mocks. Reproduce with:

spikes/step1/fetch-assets.sh
PW=../GnuPlotOnline/node_modules/playwright node spikes/step1/run.cjs chromium webkit firefox-bidi
node spikes/step1/compare.cjs

It ran three contexts on each engine: (A) the editor origin, (B) a raw sandbox="allow-scripts" srcdoc iframe window with its own hashed bootstrap (Livelier-style; not SDK), and (C) the SDK's opaque worker.

2.1 Web Audio and platform APIs per context

ChromiumFirefoxWebKit
(C) SDK worker: OfflineAudioContext / AudioContext / AudioWorkletNode / AudioBufferundefinedundefinedundefined
(C) SDK worker: isSecureContextfalsetruetrue
(C) SDK worker: AudioEncoderundefinedOpus, VorbisOpus, AAC, pcm-s16
(C) SDK worker: crypto.subtleundefinedpresentpresent
(C) SDK worker: indexedDBSecurityErrorSecurityErrorSecurityError
(C) SDK worker: cachesReferenceErrorNS_ERROR_FAILUREusable (opaque partition)
(C) SDK worker: SharedArrayBuffer (no COOP/COEP)undefinedundefinedundefined
(B) opaque window: OfflineAudioContext rendersyesyesyes
(B) opaque window: audioWorklet.addModule(blob:)worksworksfails ("Cross-origin script load denied")
(A)/(B) addModule(data:)blocked by CSP (expected; sibling CSPs omit data: from script-src)blockedblocked
(A) editor: addModule(blob:)worksworksworks
(B) output equals (A) output, per engineyesyesyes

Conclusions: no Web Audio is available in the sandbox. A window-mode sandbox would get OfflineAudioContext, but not worklets on WebKit. WebKit's opaque worker can reach a caches object. That is its own opaque partition, not the editor's, but the app should still delete storage APIs before running user code, as PythonOnline's harden() does. The browser isolation test will check this.

2.2 Determinism

Each case rendered twice per engine. Every case repeated byte-for-byte within each engine. The question is whether bytes match *across* engines:

StageCross-engine bytesNotes
Native OfflineAudioContext: buffer copyidentical
Native: sine osc, gain automation, biquad, delay feedback, convolver, stereo pannerdifferpeak/RMS agree to ~1e-5
Native: sawtooth (band-limited tables)differFF peak 0.988 vs 0.980
Native: DynamicsCompressordifferFF RMS 0.767 vs 0.768
Native: resampling (16 kHz buffer → 48 kHz) and playbackRate 1.5differ materiallyFF peak 1.31 vs 1.94 on white noise
JS Math.sin/cos/tan/exp/log/atan2/cbrt/sinh/expm1/log1p/log10/hypot (Float64)differeach engine has its own libm
JS Math.pow, Math.sqrt, Math.froundidenticalMath.tanh: Chromium = WebKit ≠ Firefox
Pure-arithmetic JS DSP (own sin/exp; oscillators, seeded noise, RBJ biquad, feedback delay, Freeverb-style reverb, compressor)identical2 s at 48 kHz in 16–36 ms
numpy 2.4.6 in Pyodide 314.0.7 (sin/exp/tanh synthesis, default_rng noise, rfft, libm vector)identicalWASM libm is the same everywhere
SVOX Pico WASM (5.77 s utterance)identical
abcjs 6.7.2 parse + setUpAudio() note eventsidenticalworks in the worker; no DOM
jsfxr 1.4.1 (pickupCoin, seeded Math.random)identical
ZzFX 1.4.0 coin, laseridenticalboom (shape 4 uses Math.tan) differs on Chromium. A Math shadow fixes it.
WebCodecs Opus/AAC encoderepeatable per enginedifferent encoders per engine

Interpretation: anything that uses only basic float operations or WASM gives identical bytes on all three engines. Anything that touches an engine's libm or native DSP does not.

2.3 Encoders (AudioEncoder.isConfigSupported, editor origin)

CodecChromium 156Firefox 159WebKit 27.2
Opus 48 kHz stereoyesyesyes
AAC-LC mp4a.40.2 44.1/48 kHzyesnoyes
Vorbisnoyesno
FLAC / MP3nonono
pcm-s16nonoyes

All encoders gave identical bytes on repeat. Opus in WebM is the only lossy format every engine can write. AAC/M4A is available on Chromium and WebKit.

2.4 Speed and startup (warm local server, inside the SDK worker)

ChromiumFirefoxWebKit
Pure-JS DSP chain, 2 s mono 48 kHz16 ms25 ms36 ms
Pico TTS, 5.77 s of speech163 ms213 ms165 ms
abcjs load + parse34 ms23 ms29 ms
Pyodide boot / numpy load / first run1166 / 197 / 320 ms1240 / 237 / 350 ms1157 / 193 / 319 ms
(reference) native OfflineAudioContext, 60 s 8-voice mix187× real time192×149×

Everything in the worker renders far faster than real time. Pyodide's cost is its downloads: pyodide.asm.wasm 9,598,218 + python_stdlib.zip 2,545,637 + pyodide.asm.mjs 1,250,344 + numpy wheel 2,960,568 bytes ≈ 16.4 MB, plus about 1.2 s of startup. Notation-only documents avoid both.

---

3. Candidate matrix

Sizes are raw bytes (gzip where measured). "Worker" means it runs in the SDK's opaque classic worker. Release dates are for the named version.

3.1 Timeline / mixer

CandidateRendersLicenseSizeWorker> real timeDeterministicVerdict
Native OfflineAudioContextDSP/mixplatform0no (window only)yes, ~150–190×per engine only; cross-engine noplayback only
tone@15.1.22 Offline (2025-04-27)music/DSPMIT345,500 (79,291 gz)no, needs (Offline)AudioContextyesas nativereject
numpy mixing in Pyodide 314.0.7DSP/mixBSD-3 / MPL-2.0~16.4 MB with runtimeyesyescross-engine identicalavailable as Samples(array), not the core mixer
Original JS renderer (proposed)mix, filters, delay, reverb, compressor, automation, resamplerMIT (ours)est. 30–60 KByesyes, ~50–100×cross-engine identical (spike)recommend

3.2 Music

CandidateRendersLicenseSizeWorkerDeterministicMaintenanceVerdict
abcjs@6.7.2 (2026-10-16)ABC parse → note events; synth needs Web Audio + remote soundfontsMIT511,968 (147,981 gz)parser yes (spike); synth noevents identicalactive, officialrecommend, parser only; we synthesize notes
@strudel/core@1.2.6, @strudel/mini@1.2.6, superdough@1.3.0 (2026-01)patterns + Web Audio synthAGPL-3.0-or-later574 KB + 252 KB + 393 KB unpackedsuperdough no—active (Codeberg)reject; original mini-notation instead
@csound/browser@7.0.0 (2026-10-22) + @csound/wasm-bin@7.0.0full DSP languageApache-2.0 wrapper + LGPL-2.1 WASM4.9 MB JS; wasm-bin package 183 MB unpacked (all variants)unproven (uses standardized-audio-context)likely (WASM)active, officialdefer (possible on-demand later)
@grame/faustwasm@0.19.6 (2026-10-30)DSP compilerLGPL-2.129.9 MB unpackedcompiler probably yeslikelyactive, officialdefer
webchuck@1.2.11 (2026-02-20)ChucKMIT wrapper; ChucK core MIT/GPL dual~47 KB JS + wasm from CCRMAno, needs AudioWorklet—officialreject
music21 10.5.2 (PyPI)music logicBSD-320,120,956 wheel; not in Pyodide lockyes (pure Python)yesactivedefer; optional on-demand micropip later

3.3 SFX

CandidateLicenseSizeWorkerDeterministicMaintenanceVerdict
zzfx@1.4.0 (2026-10-01)MIT10,301 (3,569 gz)yes; needs an AudioContext stub at import (it creates one at module load)identical with seeded Math.random + Math shadowofficial, activerecommend
jsfxr@1.4.1 (2026-05-05)Unlicense (public domain)35,898 (8,659 gz)yes (classic script)identical (seeded)activerecommend
sfxr@0.0.2 (npm, 2022)ISC———stalereject (use jsfxr)
numpy / own synthesis——yesidentical—via audio-lite sources
Faust / CsoundLGPLlarge———defer

3.4 Voice

CandidateLicenseSizeWorkerDeterministicMaintenanceVerdict
@echogarden/svoxpico-wasm@0.2.0 (2024-10-26) + Pico lingware (naggety/picotts, from AOSP external/svox)Apache-2.0 (lingware: see §4.1)wasm 950,029 + js 165,036 + en-US 650,668 + 777,396 = 2,543,129yes (ESM via hashbinSandbox.import, locateFile override needed)identical (spike)engine frozen since 2009; wrapper 2024recommend as bundled voice
@echogarden/espeak-ng-emscripten@0.3.5 (2025-08-19)GPL-3.0js 841,996 + data 24,014,553 (all languages; ~12.4 MB gz). English-only repack est. 1–2 MB (needs an Emscripten file_packager step).likelylikely (WASM)active forkonly if Curt accepts GPL
espeak-ng@1.0.2 (2023-11-24)GPL-3.0-or-later18.7 MB unpacked——stalereject
mespeak@2.0.2GPL (eSpeak 1.x)———stalereject
klatt-syn@1.0.9 (2026-06-26)MIT22,118 (+ dsp-collection)yesyes (pure JS; check libm use)activeoptional robot voice. Has no text front end; that would need original letter-to-sound rules (public-domain NRL rules).
sam-js@0.3.1no license (reverse-engineered SoftVoice SAM)21,626———reject
@echogarden/flite-wasi@0.1.1 (2023)BSD-4-Clause (advertising clause)21,530,456WASI shim needed—stalereject
Kokoro-82M v1.0 ONNX (onnx-community/Kokoro-82M-v1.0-ONNX) via kokoro-js@1.2.1 (2025-05-03) or own loaderweights Apache-2.0; kokoro-js Apache-2.0; phonemizer = eSpeak NG (GPL)model_q8f16.onnx 86,033,585; fp32 325,532,232; voice 522,240 each; kokoro.web.js 2,135,146 (bundles transformers.js + ORT JS + eSpeak)expected (WASM EP, single thread: no SharedArrayBuffer)unverified (noise source in vocoder)weights active; kokoro-js quiet since 2025-05recommend on demand (quality)
Kitten TTS nano 0.8 int8 (KittenML/kitten-tts-nano-0.8-int8)Apache-2.0; eSpeak phonemes24,369,971 + voices.npz 3,278,902expectedunverifiedmodel updated 2026-10; JS wrapper kitten-tts-js@0.1.2 unofficialrecommend on demand (size)
Piper (@mintplex-labs/piper-tts-web@1.0.5, fork of @diffusionstudio/vits-web@1.0.3)MIT wrapper; piper-phonemize = eSpeak NG (GPL); per-voice dataset licensese.g. en_US-ljspeech-medium 63,531,379 (dataset public domain), en_US-libritts_r-medium 78,580,914 (CC BY 4.0). Avoid lessac (Blizzard licence), ryan and hfc (NC).expectedunverified (VITS noise_scale)upstream archived; successor GPLnot recommended
onnxruntime-web@1.31.1 (2026-10-26)MITort-wasm-simd-threaded.wasm 14,848,852; ort.wasm.min.mjs 53,725expected; threads unavailableWASM, likely per seedofficialruntime for neural voices (pin a ≥2-week-old patch at Step 3)
sherpa-onnx@2.0.1 (2026-10-26)Apache-2.015.4 MB unpacked——official, activealternative runtime; still uses eSpeak data for Piper/Kokoro
Web Speech API—————excluded by brief

3.5 Encoding / containers

CandidateLicenseSizeVerdict
WAV writer (16-bit, 24-bit, 32-bit float), originalMIT~2 KBrecommend; always available, deterministic
WebCodecs AudioEncoder on the editor originplatform0recommend: Opus everywhere, AAC on Chromium/WebKit (§2.3)
Muxers extended from ManimOnline's manim-video.js (MP4/WebM written by hand in ~150 lines) + new Ogg page writerMIT (ours)~5 KBrecommend: .webm (Opus), .m4a (AAC), .opus (Ogg)
mp4-muxer@5.2.2, webm-muxer@5.1.4MIT34 KB / 29 KB tgzdeprecated ("superseded by Mediabunny"), so reject
mediabunny@1.65.0 (2026-10-30)MPL-2.011.3 MB unpackedreject (more than needed)
libflacjs@5.6.0 (2026-07-21)MIT wrapper, libFLAC BSD-3—optional later (lossless compressed fallback)
@breezystack/lamejs@1.2.7, lamejs@1.2.1LGPL-3.0—reject

Fallback when AudioEncoder is missing: WAV export stays available, and compressed export is disabled with an explanation. Gallery previews use <audio> with two <source> elements, WebM/Opus first and M4A/AAC second, both generated by render-gallery.mjs on macOS Chromium.

---

4. Voice size and licensing detail

4.1 Bundled: SVOX Pico

distributed by AOSP as Apache-2.0, but Debian packages them as *non-free*: "Claims to be Apache-2.0 but the source code is not included. At least, it is redistributable." There is no restriction on redistribution or commercial use, only a missing "preferred form for modification". Curt should accept this explicitly (D2).

add 0.86–1.2 MB. Output is 16 kHz mono 16-bit. The spike utterance hit full scale (peak 32768), so the renderer applies headroom and a deterministic 16→48 kHz resampler.

4.2 Neural (on demand; not part of "Available offline")

ModelBytes to download (model + voices + ORT wasm + JS)Weights licensePhonemizer
Kitten TTS nano 0.8 int824.4 + 3.3 + 14.8 + ~1.3 ≈ 43.8 MBApache-2.0eSpeak NG
Kokoro-82M v1.0 q8f1686.0 + 0.5/voice + 14.8 + ~2.1 ≈ 103.5 MBApache-2.0eSpeak NG
Piper ljspeech-medium63.5 + 14.8 + phonemize wasm (not measured) ≈ 80–90 MBMIT model, PD dataseteSpeak NG

The editor origin downloads these after confirmation, verifies them against pinned SHA-256 hashes, and stores them in a separate cache (audio-voices-v1). They reach the sandbox through the SDK proxy, as Pyodide does. kokoro.web.js loads ORT WASM from jsDelivr by default, so wasmPaths must point at our verified copy.

4.3 The G2P decision (D3)

GPL-3.0) as a separate, unmodified, downloaded module with its own notice. Whether this is "mere aggregation" under GPL is a legal judgment for Curt. Fastest path.

ARPAbet→IPA, plus original letter-to-sound fallback rules. Fits the house license and adds about 2–3 days of work. Pronunciation may differ slightly from what Kokoro/Kitten were trained on.

My recommendation is (b), using (a) temporarily only if Curt is comfortable with it.

---

5. Test tolerances (follow from §2.2)

match a committed golden hash on all three engines, not only within one. This applies to every gallery example except neural voices. The diagnostic checks below still run, so a mismatch is easy to read: - duration: exact sample count; - channels: exact; - silence boundaries: within ±10 ms at −60 dBFS; - peak/RMS per 100 ms window: ±0.1 dB.

compare duration to within one codec frame plus priming (Opus pre-skip 312; AAC 1024/2112) and RMS to within 1 dB.

Otherwise, cache equality keyed by (text, voice, model hash), and tests assert duration ±5% and non-silence only.

---

6. (a) Recommended dependency list

Bundled and offline-cached. Pinned and hash-verified. Where siblings do so, it is fetched from the CDN rather than committed (see §8).

#DependencyLicenseSizeRole
1HashBin SDK v0.4.0 (hashbin-sdk-v0.4.0.mjs, hashbin-cache-v0.4.0.js)HashBin37,633 + cachesandbox, publish, verified cache
2Pyodide 314.0.7 (same pin as PythonOnline, the newest sibling)MPL-2.0 (+ CPython PSF)~13.4 MBPython scenes only
3numpy 2.4.6 (Pyodide wheel)BSD-32,960,568Samples, numpy synthesis
4abcjs 6.7.2 (abcjs-basic-min.js)MIT511,968ABC parser
5ZzFX 1.4.0 (ZzFX.js)MIT10,301ZzFX notation
6jsfxr 1.4.1 (sfxr.js)Unlicense35,898sfxr notation
7@echogarden/svoxpico-wasm 0.2.0 + Pico en-US lingwareApache-2.0 (lingware caveat §4.1)2,543,129bundled voice
8Ace 1.4.1, lz-string 1.5.0, Nayuki QR (as in siblings)BSD / MIT / MITas siblingseditor, sharing

Original code (MIT): audio-lite.py, the timeline schema, the renderer/DSP, dmath, the resampler, the WAV reader/writer, the muxers (extending ManimOnline), the pattern mini-notation and built-in drum kit, ABC instruments, the detector and the gallery.

On demand, after confirmation (not offline):

#DependencyLicenseSize
9onnxruntime-web 1.31.x (single-threaded WASM)MIT14.8 MB wasm
10Kitten TTS nano 0.8 int8Apache-2.027.6 MB
11Kokoro-82M v1.0 q8f16 + chosen voicesApache-2.086.0 MB + 0.5 MB each
12G2P per D3: CMUdict (BSD-style) or phonemizer 1.2.1 (eSpeak NG, GPL-3.0)see §4.30.8 MB gz / 1.3 MB

Rejected: Tone.js, Strudel, WebChucK, native-Web-Audio rendering, eSpeak NG as the bundled voice (unless D2 says otherwise), SAM, Flite WASI, mp4-muxer/webm-muxer, mediabunny and lamejs. Csound, Faust and music21 are deferred.

Decisions for Curt

encode on the editor origin.

(Pico default, eSpeak as an opt-in GPL download).

decodeAudioData, which differs per engine. Recommendation: accept WAV only (deterministic) in v1, decoded in the sandbox. Larger audio goes through CID samples.

than committed to the repo, as the brief literally says.

---

7. (c) audio-lite API proposal

Split: Python *describes* and JS *renders*. construct() builds a plain-JSON timeline. Raw numpy arrays travel as transferred Float32Array buffers referenced by id. The JS renderer realizes the timeline in the same worker. Notation-only documents build the same timeline directly in JS.

from audio import *

class Intro(Scene):
    sample_rate = 48000            # 44100 | 48000
    channels = 2
    seed = 1                       # all randomness derives from this
    # length = 12                  # optional; otherwise the last clip end plus effect tails

    def construct(self):
        self.say("Welcome to Audio Online.", voice="pico:en-US", track="voice")   # advances self.time
        theme = abc("X:1\nT:Theme\nK:C\nCEG c2|", instrument="pluck")
        self.add(theme, at=0.5, track="music", gain=-12).fade_in(1)
        boom = Sine(55).env(adsr(0.01, 0.2, 0, 0.3)).dur(0.6) + Noise(seed=1).lowpass(800).dur(0.3)
        self.add(boom, at=self.time, track="sfx", pan=-0.3)
        self.wait(1)
        self.track("music").fx(Reverb(room=0.4), Compressor(-18, 4)).duck(under="voice", amount=-9)
        self.master.fx(Limiter(-1))

- oscillators: Sine/Square/Saw/Triangle(freq); - noise: Noise(color="white|pink|brown", seed=); - envelopes: adsr(a, d, s, r), Envelope([(t, v), …], curve=); - samples: Samples(array, rate), Sample(cid="…"), Sample("kick.wav") (uploaded file); - notation: abc(text, instrument=), zzfx([...]), sfxr(params | preset, seed=), pattern("bd*2 [~ sn] hh*4", bpm=, kit=); - speech: Speech(text, voice=); - Silence(seconds).

- .dur(s), [a:b] (seconds, unlike Pydub's ms), .loop(n | until=), .gain(db), .pan(p); - .fade_in(s), .fade_out(s), .reverse(), .speed(r), .env(e); - .lowpass/.highpass/.bandpass(f, q), .fx(*effects); - a + b mixes (as in the brief's sketch, not Pydub's concatenation), a >> b sequences, a * n repeats.

- self.add(clip, at=self.time, track="main", gain=0, pan=0) returns a placement handle; - self.play(clip) adds the clip and advances self.time by its length; - self.wait(s), self.say(text, voice=, …) (equivalent to play(Speech(...))), self.time.

- self.track(name) supports .fx(...), .gain(db), .pan(p), .duck(under=, amount=, attack=, release=) and automation, e.g. .automate("gain", [(0, -60), (2, 0)]); - self.master takes the same effects.

Reverb(room, damp, mix) (Freeverb-style, our implementation), Compressor(threshold, ratio, attack, release, makeup), Limiter(ceiling), Gain, Drive. All of them are deterministic JS.

Pico is synchronous WASM in the same worker, so a bridge synthesizes and caches during construct(). Neural voices are async, so construct() runs in a collect pass that records speech requests with estimated lengths. The app then synthesizes them and re-runs construct() (it is deterministic because seeded) until the lengths are stable, at most 3 passes. Users write ordinary synchronous code either way.

source. Tracebacks use the user file name with no prepended lines, as Gnuplot's diagnostics do.

Timeline JSON (abbreviated):

{"format":"audio-timeline","version":1,"sampleRate":48000,"channels":2,"seed":1,"length":null,
 "sources":{"s1":{"kind":"osc","wave":"sine","freq":55,"dur":0.6,"env":{"adsr":[0.01,0.2,0,0.3]},"line":9},
            "s2":{"kind":"speech","text":"Welcome to Audio Online.","voice":"pico:en-US","line":6},
            "s3":{"kind":"buffer","ref":0,"rate":22050}},
 "clips":[{"id":"c1","source":"s1","track":"sfx","at":1.82,"gain":0,"pan":-0.3,"fade":[0,0],"slice":[0,null],"loop":1,"fx":[],"line":10}],
 "tracks":{"music":{"fx":[{"type":"reverb","room":0.4},{"type":"compressor","threshold":-18,"ratio":4}],"duck":{"under":"voice","amount":-9}}},
 "master":{"fx":[{"type":"limiter","ceiling":-1}]}}

Saved documents follow the brief: { format: 'hashbin-audio', version: 1, src, type: 'python'|'abc'|'zzfx'|'sfxr'|'pattern', settings: { sampleRate, channels, scene, renderer }, files }.

---

8. Sibling conventions and conflicts (flagged, not silently resolved)

curtcox.github.io/livelier, is not in OWNED_APPS, and uses its own window bootstrap with 'unsafe-eval'. The B13 migration commits on Gnuplot, Manim, Python, Pages, sheets and modl (2026-10-30) are newer for hosting and domain conventions. I'll follow B13 for hosting, migration and legacy notices, and Gnuplot's layout as the brief directs. Audio Online does not need 'unsafe-eval'.

the brief specifies hashbin-audio with a settings key. I'll follow the brief.

hashbin-cache-v0.4.0.js. ManimOnline, PythonOnline and Livelier do. The brief asks for the vendored cache helper, so I'll follow Manim/Python. The script name stays Gnuplot's scripts/update-offline.mjs (Manim's is update-offline-hashes.mjs; Livelier uses npm run offline).

serve Pyodide from a pinned jsDelivr URL plus a verified cache rather than committing it, which conflicts with the brief's "vendored WASM is committed" (D6).

and gnuplot.hashbin.page in another.

Desktop Firefox through WebDriver BiDi works and would be the local recipe. CI on Linux keeps the bundled Firefox.

(opaque partition) to the worker. I'll copy PythonOnline's harden() step.

---

9. What happens after sign-off (unchanged from the brief, adjusted for the above)

  1. Skeleton from Gnuplot conventions, plus the deterministic renderer, WAV export and the three-engine

CI, with one path end to end: Python → timeline → WAV.

  1. Notations (ABC, ZzFX, sfxr, pattern), then Pico, then effects/tracks/stems, then neural voices on

demand (D3/D4).

  1. Gallery and covers.
  2. hashbin.org registry entry and staging.

Stopping here for sign-off on D1–D7.

---

10. Sign-off (2026-10-30)

Curt approved every recommendation:

---

11. Corrections found while building Step 3 (2026-10-30)

The Step 1 plan held, but four findings changed details. Each one has a test.

  1. ZzFX also needs a source rewrite, not only a Math shim. It uses the ** operator in four

places. No prelude can intercept **, and engines evaluate it differently. That was the real cause of the boom mismatch in §2.2. The loader rewrites exactly those four expressions to Math.pow and refuses any file whose hash or ** usage differs. The vendored file stays byte-identical.

  1. Pico keeps state between utterances. It is held in C globals that pico_resetEngine and even a

new engine leave behind, so one utterance changed the next. The WASM heap is now restored to its just-loaded snapshot before every utterance, as Gnuplot Online does around each run.

  1. jsfxr 1.4.1 emits NaN as its first sample with the platform Math too. Non-finite samples

become silence.

  1. WebKit's AAC encoder returns an ES descriptor as its "description". Chromium returns the bare

AudioSpecificConfig. The M4A muxer handles both.

Two small additions:

exceeds full scale.

---

12. Realignment with the brief (2026-10-30)

A review against the original brief found these gaps after Step 3, and closed them:

Sample(cid="…") were missing. Both are now in, as decided under D5. CID samples are fetched and SHA-512-verified on the editor origin and handed to the sandbox as bytes; inline CIDs decode in the sandbox.

- clip .speed() (tape-style resampling) and .pan(); - .loop(until=); - Envelope(curve="linear"|"smooth"|"exp"); - Peak, LowShelf and HighShelf. The proposal said Shelf; two names are clearer than a mode argument.

- five original examples were added: door, sound-design hallway, CID wavetable, uploaded loop and neural narrator, for 16 in all; - gallery.html with covers and lazy previews; - scripts/render-gallery.mjs, whose --check mode is part of npm run check.

Previews are AAC (.m4a), which Safari, Chrome and Firefox play (Firefox through the operating system's AAC decoder). Encoding needs Chromium's platform AAC encoder (macOS/Windows), so CI checks fingerprints and does not re-encode.

- Staging must rehash the offline manifest after it writes the configured migration module, as it already does for Pages. Otherwise the hosted service worker refuses that file. - The legacy notice is deliberately not installed yet. It would announce a move to audio.hashbin.page before that site exists.

One latent bug surfaced while testing: Pyodide caches imported modules, so a second render with a different engine object kept calling the first render's _audio_js bridge. The worker always reuses one engine and was unaffected; Node rendering with per-render samples was. AudioPython.run now drops the cached module each run. All existing golden hashes are unchanged.