From 36883fa323d23541f7af97a63ef6223f7a1cece1 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:20:29 +0800 Subject: [PATCH 1/9] docs(audio): specify the audio surface, API and asset pipeline --- AUDIO.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++ DESIGN.md | 8 ++- README.md | 15 +++++ RUNTIMES.md | 6 ++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 AUDIO.md diff --git a/AUDIO.md b/AUDIO.md new file mode 100644 index 0000000..086f4d3 --- /dev/null +++ b/AUDIO.md @@ -0,0 +1,168 @@ +# Pocket Audio — SFX, BGM and a tiny synth as a host surface + +PocketJS is a **closed, deterministic world** (see DETERMINISM.md): frame +content is a pure function of frame index + inputs, and byte-exact goldens +rely on it. Audio is inherently side-effecting wall-clock output, so it lives +**entirely outside the core** as its own surface: + +- Mounted as **`globalThis.audio`** — not `ui.*`. Every `ui.*` op maps 1:1 to + a `pocketjs_core::Ui` method; audio has *no* core implementation, no wasm + export, no DrawList op, and never will. +- **Capability = surface** (RUNTIMES.md): a host that doesn't mount + `globalThis.audio` gives the guest no way to make noise. Goldens, input + tapes and headless test hosts never mount it, so replay stays byte-exact by + construction — the same contract as the DevTools ops (18–22). +- All ops are **synchronous fire-and-forget**: they enqueue intent and return + `undefined`. Nothing about playback state ever flows back into the guest. + +## The surface (spec ops 26–31) + +Op codes are pinned in `spec/spec.ts` `OP` (append-only, shared registry with +the `ui` surface; `test/contract.ts` guards drift). Sounds are identified by +**string key** — the bare `name` from `sounds.json`, resolved host-side to a +pak entry (`audio:sfx.` / `audio:bgm.`), mirroring +`loadTileTexture`. + +| op | JS (`audio.*`) | semantics | +|---|---|---| +| 26 | `playSfx(key, volume, pan)` | one-shot SFX from the pak. `volume` 0..1 (multiplied by sfx × master gain), `pan` −1..1 (0 = center). Unknown key: silent no-op. | +| 27 | `playSynth(wave, freq, freqEnd, durMs, attackMs, releaseMs, volume)` | one-shot procedural voice. `wave` = `ENUMS.Waveform` ordinal; linear frequency sweep `freq → freqEnd` Hz over `durMs`; linear attack/release envelope in ms. Routed through the sfx bus. | +| 28 | `playBgm(key, loop, fadeMs, volume)` | start/switch the (single) music track. `loop` 0\|1; `fadeMs` cross-fades from the current track where the host can, else cuts. Same key as the playing track: no-op (phase is kept). | +| 29 | `stopBgm(fadeMs)` | fade the track to silence and release it. | +| 30 | `pauseBgm(paused)` | freeze / resume the track cursor (idempotent). | +| 31 | `setChannelVolume(channel, volume)` | live bus gain. `channel` = `ENUMS.AudioChannel` ordinal: 0 master, 1 sfx, 2 bgm. Hosts ramp ~10 ms to avoid clicks. | + +Plus one dunder, web-only: `__unlock()` — resume the `AudioContext` after the +first user gesture (see *Hosts* below). + +## Using it — `@pocketjs/framework/audio` + +PocketJS runtime APIs come from `@pocketjs/framework/*`; state and control +flow come from your framework package (`solid-js` / `vue`): + +```tsx +import { onButtonPress, BTN } from "@pocketjs/framework/input"; +import { + defineSfx, playSfx, playBgm, setVolume, setMuted, +} from "@pocketjs/framework/audio"; + +// A code-defined retro blip — no asset, no pak entry. +defineSfx("blip", { wave: "square", freq: 880, durMs: 40, releaseMs: 20 }); + +playBgm("theme1", { loop: true, fadeMs: 300 }); // baked from sounds.json +onButtonPress(BTN.X, () => playSfx("blip")); // resolves the defineSfx +onButtonPress(BTN.SELECT, () => setMuted(true)); +``` + +### API reference + +Every function returns `void` and is a **silent no-op when the host has no +audio** — apps must never branch on audio availability (see *Determinism*). + +| function | behavior | +|---|---| +| `playSfx(name, opts?)` | `opts: { volume?: 0..1 = 1, pan?: −1..1 = 0 }`. If `name` was registered with `defineSfx`, routes to `playSynth`; otherwise plays the pak SFX `audio:sfx.`. | +| `defineSfx(name, desc)` | register a `SynthDesc` under a name. Re-defining replaces. Registrations live in app JS only — nothing is baked or shipped. | +| `playSynth(desc)` | play an unnamed `SynthDesc` immediately. | +| `playBgm(name, opts?)` | `opts: { loop? = true, fadeMs? = 0, volume? = 1 }`. Plays `audio:bgm.`; switching tracks cross-fades over `fadeMs`; calling with the already-playing name is a no-op. | +| `pauseBgm()` / `resumeBgm()` | freeze/unfreeze the track cursor. Idempotent; `resumeBgm()` after `stopBgm()` is a no-op (the track is gone). | +| `stopBgm(opts?)` | `opts: { fadeMs? = 0 }`. | +| `setVolume(channel, v)` | `channel: "master" \| "sfx" \| "bgm"`, `v` clamped to 0..1. Applied live to playing sounds. | +| `getVolume(channel)` | the runtime-held value (identical on every host — it is *not* read back from hardware). | +| `setMuted(on)` / `isMuted()` | runtime-level: mute pushes master gain 0 to the host and remembers the user's master volume; unmute restores it. | +| `audioSupported()` | whether an audio host is mounted. **Presentation only** — use it to grey out a volume slider, never to branch app state. | + +Volumes compose multiplicatively: a `playSfx` at `volume: 0.5` on an sfx bus +at `0.8` with master `1.0` plays at 0.4. + +### `SynthDesc` — the 8-bit palette + +```ts +interface SynthDesc { + wave: "square" | "pulse25" | "pulse12" | "triangle" | "saw" | "sine" | "noise"; + freq: number; // Hz at note-on + freqEnd?: number; // Hz at note-off (linear sweep); default = freq + durMs: number; // total voice length, envelope included + attackMs?: number; // linear fade-in; default 0 + releaseMs?: number; // linear fade-out; default 15 (click-free tail) + volume?: number; // 0..1, default 1 +} +``` + +`pulse25`/`pulse12` are 25 % / 12.5 % duty pulses; `noise` is a 15-bit LFSR. +Both hosts render a descriptor with the **same waveform math** (the PSP mixer +generates it per-sample; the web host pre-renders it once into a cached +`AudioBuffer`), so a blip sounds the same everywhere. Descriptors travel as +op numbers — a synth sound costs zero pak bytes. + +Sweep + envelope cover the classic vocabulary: laser = `square` 1760→110 Hz, +pickup = `pulse25` 440→880 Hz, explosion = `noise` with a long release. + +## Assets — `sounds.json` → SND pak entries + +Sound files are baked at build time so the PSP does **zero decoding**. Add an +optional `/sounds.json` (same pattern as `sprites.json`): + +```json +{ + "click.wav": { "name": "click" }, + "theme1.wav": { "name": "theme1", "bgm": true, "loop": true, "loopStart": 0, "rate": 22050 } +} +``` + +Files resolve against ``, `assets/sounds/`, then `assets/`. Each +entry is decoded (RIFF/WAVE, PCM 8/16-bit), downmixed to mono, resampled to +`rate` (default 22050 Hz), and packed as an SND pak entry under +`audio:sfx.` or (with `"bgm": true`) `audio:bgm.`. Old hosts skip +unknown `audio:` keys — forward compatible. + +SND entry layout (constants in `spec/spec.ts`): + +| off | field | value | +|---|---|---| +| 0 | `u32 magic` | `0x44534B50` (`'PKSD'`) | +| 4 | `u16 version` | 1 | +| 6 | `u16 flags` | bit 0 = loop (BGM loops from `loopStart`) | +| 8 | `u32 sampleRate` | 22050 default, 11025 allowed | +| 12 | `u32 frameCount` | mono sample count | +| 16 | `u32 loopStart` | sample index (iff loop flag) | +| 20 | `u32 reserved` | 0 | +| 24 | data | `frameCount` × s16 LE mono | + +**Budget:** the pak is `include_bytes!`-ed into the EBOOT's `.rodata`, so BGM +costs binary size, not heap. 22050 Hz s16 mono = 44.1 KB/s → 1 MB ≈ 23 s +(11025 Hz halves that). v1 rule: **BGM = loopable chiptune-length clips, +≤ ~1 MB per track** — the build warns above that. ADPCM compression is future +work (the `version` field is the hinge); real streaming (ms0:/UMD IO) is +explicitly out of scope. + +## Hosts + +| host | implementation | +|---|---| +| browser (`host-web/audio.js`) | WebAudio: `master → destination`, `sfx`/`bgm` `GainNode` buses; SFX/BGM are `AudioBufferSourceNode`s built straight from the SND PCM (no `decodeAudioData`); fades are `linearRampToValueAtTime`. **Autoplay is a browser policy, not a bug:** the context starts locked; the engine unlocks on the first keydown/pointerdown. Until then SFX are dropped and the last `playBgm` is remembered and started (from the top) at unlock. | +| PSP (`native/src/audio.rs`) | one hardware channel (44100 Hz stereo, reserved once at boot) + a software mixer thread (priority above the main worker, 1024-sample blocks ≈ 23 ms). Fixed pool of 8 voices (4 SFX + 1 BGM + 3 synth), **oldest-of-kind stealing** when full. The mixer is allocation-free and integer-only (Q15 gains, 16.16 phase accumulators); PCM plays directly from `.rodata`. JS ops push into a lock-free SPSC command ring. Trigger→ear ≈ 25–45 ms. Cost: < 2 % of the 333 MHz CPU at full polyphony. | +| sim (`host-sim/`) | a recorder: every op appends `{ t: "audio", frame, op, args }` to `Trace.audio`, so journeys can assert *when* sounds fire — deterministically. | +| goldens / tapes / QuickJS-headless | nothing mounted → every runtime call is one null-check no-op. Zero test churn. | + +PPSSPP renders the same channel-API path; its blocking-output timing is +emulated, so tune latency on hardware (psplink), not by ear in the emulator. + +## Determinism contract + +1. Audio never enters replayable core state — no `Ui` method, no wasm export, + no DrawList op. `bun run golden` and `bun run tape:check` are unchanged by + this feature, byte for byte. +2. The *command stream* is deterministic even though the *output* is not: + the sim host records it, and `test/sim.test.ts` asserts a chaos run and a + clean run produce identical `Trace.audio` — same ops, same frames. +3. `getVolume`/`isMuted` read runtime-owned JS state, never hardware. Apps + must not branch app state on `audioSupported()` — a muted PSP and a + browser tab must stay pixel-identical. + +## What v1 explicitly punts + +Per-SFX handles (stop/retune a playing one-shot — add later as +generation-tagged handles, the `freeTexture` pattern), streamed or +ADPCM-compressed BGM, 3D/positional audio, DSP effect chains (reverb, +filters, ducking), per-voice priorities. diff --git a/DESIGN.md b/DESIGN.md index 1077eb0..cb9f849 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -247,6 +247,11 @@ sweep runs inside frame() → core.tick(1/60): anims → layout if dirty → Dra → ge::render → sceGuFinish/Sync/WaitVblank/Swap`. Backends never call sceGuStart/Finish (display list owned by main.rs, dreamcart contract). +**Audio.** Sound is deliberately *not* part of the `ui.*` contract: it is a +separate, optional host surface `globalThis.audio` (spec ops 26–31, no core +implementation, never mounted by goldens/tapes) so replay determinism holds by +construction. Full contract + API in [AUDIO.md](AUDIO.md). + ## Memory (the blocker fix [R]) rust-psp installs a `#[global_allocator]` that makes **one kernel object per @@ -364,4 +369,5 @@ pak (base64-in-JS is the known QuickJS boot killer). Kinetic scroll views, CLUT/swizzled textures, render-to-texture opacity groups (per-vertex alpha propagation instead — wrong on overlap, fine for demos), kerning, `hover:`, percentage sizes beyond `-full`, 3DS/Android hosts, -`rounded-full` on runtime-sized nodes. +`rounded-full` on runtime-sized nodes. Audio (AUDIO.md): streamed/ADPCM BGM, +3D/positional audio, DSP effect chains, per-SFX voice handles. diff --git a/README.md b/README.md index d036bfd..767118f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,21 @@ On-demand device screenshots (📷 in the panel) work on every host — on real hardware the raw VRAM rides the usbhostfs mount and the bridge encodes the PNG desktop-side. +## Audio + +Sound effects, looping BGM, per-bus volume and a tiny 8-bit synth +([AUDIO.md](AUDIO.md)): `@pocketjs/framework/audio` speaks to an optional +`globalThis.audio` host surface — WebAudio in the browser, a software mixer +thread on the PSP — and stays entirely outside the deterministic core, so +goldens and input tapes are untouched. + +```tsx +import { defineSfx, playSfx, playBgm } from "@pocketjs/framework/audio"; + +defineSfx("blip", { wave: "square", freq: 880, durMs: 40 }); // no asset needed +playBgm("theme1", { loop: true, fadeMs: 300 }); // baked from sounds.json +``` + ## Determinism + the sim host Time is a frame counter, not the wall clock ([DETERMINISM.md](DETERMINISM.md)): diff --git a/RUNTIMES.md b/RUNTIMES.md index 701b2e2..0efe504 100644 --- a/RUNTIMES.md +++ b/RUNTIMES.md @@ -84,6 +84,12 @@ code-generated into Rust, with a drift guard that byte-compares the generated file in CI (`test/contract.ts`). A surface is versioned by append: codes are never renumbered, never reused. +Not every surface fronts a core simulation. The audio surface +(`globalThis.audio`, spec ops 26–31, [AUDIO.md](AUDIO.md)) is a *device +surface*: ops-only, no events, no core state — playback is a host-side effect, +which is exactly what keeps sound outside the deterministic replay world while +still spec-pinned and drift-guarded like every other vocabulary. + **Capability = surface.** A guest can affect exactly what its mounted surfaces express — nothing else. There is no ambient filesystem, network, or process access. Sandboxing is not a bolted-on policy; it falls out of the From 05ff79434db50fa5572027daaec8ad9ff52ba825 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:30:56 +0800 Subject: [PATCH 2/9] feat(audio): spec ops 26-31 and runtime API (@pocketjs/framework/audio) --- core/src/spec.rs | 28 ++++ package.json | 4 +- spec/gen-rust.ts | 2 + spec/spec.ts | 80 +++++++++++ src/audio.ts | 18 +++ src/index.ts | 7 + src/sound.ts | 282 ++++++++++++++++++++++++++++++++++++ test/audio.test.ts | 352 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 src/audio.ts create mode 100644 src/sound.ts create mode 100644 test/audio.test.ts diff --git a/core/src/spec.rs b/core/src/spec.rs index 626d18c..8caea6f 100644 --- a/core/src/spec.rs +++ b/core/src/spec.rs @@ -78,6 +78,12 @@ pub mod op { pub const LOAD_TILE_TEXTURE: u8 = 23; pub const FREE_TEXTURE: u8 = 24; pub const UPLOAD_IMG_ENTRY: u8 = 25; + pub const PLAY_SFX: u8 = 26; + pub const PLAY_SYNTH: u8 = 27; + pub const PLAY_BGM: u8 = 28; + pub const STOP_BGM: u8 = 29; + pub const PAUSE_BGM: u8 = 30; + pub const SET_CHANNEL_VOLUME: u8 = 31; } /// Property ids (u8, stable, append-only). Groups: @@ -290,6 +296,28 @@ pub enum Easing { CubicBezier = 7, } +/// audio bus selector for `audio.setChannelVolume` (AUDIO.md). +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AudioChannel { + Master = 0, + Sfx = 1, + Bgm = 2, +} + +/// procedural synth waveform for `audio.playSynth`/`SynthDesc.wave` (AUDIO.md). +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Waveform { + Square = 0, + Pulse25 = 1, + Pulse12 = 2, + Triangle = 3, + Saw = 4, + Sine = 5, + Noise = 6, +} + /// PSM texture pixel formats — MUST equal rust-psp TexturePixelFormat /// (sceGuTexMode arg; verified against rust-psp/psp/src/sys/gu.rs). /// PSM_T8 (CLUT8) uploads as: 1024-byte palette (256 x u32 ABGR), then diff --git a/package.json b/package.json index f1f9cb7..8118820 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "exports": { ".": "./src/index.ts", "./animation": "./src/animation.ts", + "./audio": "./src/audio.ts", "./clock": "./src/clock.ts", "./config": "./src/config.ts", "./components": "./src/components.ts", @@ -58,6 +59,7 @@ "./solid/renderer": "./src/renderer-solid.ts", "./vue-vapor": "./src/index-vue-vapor.ts", "./vue-vapor/animation": "./src/animation.ts", + "./vue-vapor/audio": "./src/audio.ts", "./vue-vapor/clock": "./src/clock.ts", "./vue-vapor/effects": "./src/effects.ts", "./vue-vapor/components": "./src/components-vue-vapor.ts", @@ -78,7 +80,7 @@ "serve": "bun host-web/serve.ts", "golden": "bun test/golden.ts", "e2e": "bun test/e2e-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts test/audio.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/spec/gen-rust.ts b/spec/gen-rust.ts index a7e7f86..9c1ef8e 100644 --- a/spec/gen-rust.ts +++ b/spec/gen-rust.ts @@ -235,6 +235,8 @@ export function generateRust(): string { GradDir: "gradient direction (`bg-gradient-to-t|b|l|r`).", Easing: "animation easing. Spring/SpringBouncy ignore durMs (physics decide); OutBack overshoots ~10%.", + AudioChannel: "audio bus selector for `audio.setChannelVolume` (AUDIO.md).", + Waveform: "procedural synth waveform for `audio.playSynth`/`SynthDesc.wave` (AUDIO.md).", }; for (const [ename, variants] of Object.entries(ENUMS)) { put(`/// ${enumDocs[ename] ?? ename}`); diff --git a/spec/spec.ts b/spec/spec.ts index 22e9db5..3c7059a 100644 --- a/spec/spec.ts +++ b/spec/spec.ts @@ -131,6 +131,34 @@ export const OP = { uploadImgEntry: 25, // (blob) -> handle | -1. Upload a self-contained IMG // entry (compiler/pak.ts layout, v2: PSM_T8 palette + // optional RLE + filter flags parsed core-side). + // -- Audio surface (globalThis.audio — NOT ui.*): optional, default-off, + // NEVER implemented by pocketjs-core, never used by tests/goldens/tapes + // (see AUDIO.md). Shares this append-only op-code registry with the + // ui.* surface purely so numbers never collide; these codes never + // cross the ui.* FFI — hosts mount globalThis.audio separately. ------- + playSfx: 26, // (key: string, volume: f32, pan: f32) — one-shot + // SFX from the pak (resolved host-side to + // audio:sfx., mirroring loadTileTexture). + // volume 0..1 (x sfx x master gain); pan -1..1 + // (0 = center). Unknown key: silent no-op. + playSynth: 27, // (wave: ENUMS.Waveform, freq: f32, freqEnd: f32, + // durMs: f32, attackMs: f32, releaseMs: f32, + // volume: f32) — one-shot procedural voice: linear + // frequency sweep freq -> freqEnd over durMs, linear + // attack/release envelope in ms. Routed through the + // sfx bus. + playBgm: 28, // (key: string, loop: 0|1, fadeMs: f32, volume: f32) + // — start/switch the single music track (resolved + // host-side to audio:bgm.). fadeMs cross-fades + // from the current track where the host can, else + // cuts. Same key as the playing track: no-op (phase + // kept). + stopBgm: 29, // (fadeMs: f32) — fade the track to silence and + // release it. + pauseBgm: 30, // (paused: 0|1) — freeze/resume the track cursor + // (idempotent). + setChannelVolume: 31, // (channel: ENUMS.AudioChannel, volume: f32) — live + // bus gain. Hosts ramp ~10ms to avoid clicks. } as const; // --------------------------------------------------------------------------- @@ -377,6 +405,18 @@ export const ENUMS = { Linear: 0, EaseIn: 1, EaseOut: 2, EaseInOut: 3, OutBack: 4, Spring: 5, SpringBouncy: 6, CubicBezier: 7, }, + /** + * Audio bus selector for `audio.setChannelVolume` (op 31, AUDIO.md). Audio + * has no core implementation — this enum never crosses the ui.* FFI. + */ + AudioChannel: { Master: 0, Sfx: 1, Bgm: 2 }, + /** + * Procedural waveform for `audio.playSynth` (op 27) / `SynthDesc.wave` + * (AUDIO.md). pulse25/pulse12 are 25%/12.5% duty pulses; noise is a + * 15-bit LFSR. Hosts render the same waveform math so a synth sound is + * identical everywhere. + */ + Waveform: { Square: 0, Pulse25: 1, Pulse12: 2, Triangle: 3, Saw: 4, Sine: 5, Noise: 6 }, } as const; // --------------------------------------------------------------------------- @@ -519,6 +559,46 @@ export const TILESET_FLAG_LINEAR = 1 << 1; * stream on demand through the loadTileTexture op. */ export const keyTileset = (name: string): string => `ui:tile.${name}`; +// --------------------------------------------------------------------------- +// SND pak entry — baked SFX/BGM audio clips (AUDIO.md) +// --------------------------------------------------------------------------- +// Audio surface (globalThis.audio — NOT ui.*): optional, default-off, NEVER +// implemented by pocketjs-core, never used by tests/goldens/tapes (see +// AUDIO.md). Baked from an app's sounds.json: each entry is decoded +// (RIFF/WAVE, PCM 8/16-bit), downmixed to mono, resampled, and packed under +// audio:sfx. or (bgm: true) audio:bgm. — see keySfx/keyBgm below, +// mirroring the keyTileset pak-key convention above. Old hosts skip unknown +// audio: keys, so this is forward compatible. +// +// Header (24 bytes): +// off 0 u32 magic = 0x44534b50 bytes 'P','K','S','D' +// off 4 u16 version = 1 +// off 6 u16 flags bit 0 = loop (BGM loops from loopStart) +// off 8 u32 sampleRate 22050 default, 11025 allowed +// off 12 u32 frameCount mono sample count +// off 16 u32 loopStart sample index (iff loop flag) +// off 20 u32 reserved (0) +// off 24 data frameCount x s16 LE mono +// +// v1 budget: BGM = loopable chiptune-length clips, <= ~1 MB per track +// (22050 Hz s16 mono = 44.1 KB/s -> 1 MB ~= 23 s at that rate; 11025 Hz +// halves it). ADPCM compression is future work (the version field is the +// hinge); real streaming (ms0:/UMD IO) is explicitly out of scope. + +export const SND_MAGIC = 0x44534b50; // 'PKSD' LE +export const SND_VERSION = 1; +export const SND_HEADER_SIZE = 24; +export const SND_FLAG_LOOP = 1 << 0; +export const SND_RATE_DEFAULT = 22050; + +/** Pak key family for one-shot SND SFX entries — resolved host-side from the + * bare string key passed to `audio.playSfx` (AUDIO.md), mirroring + * keyTileset. */ +export const keySfx = (name: string): string => `audio:sfx.${name}`; +/** Pak key family for looping SND BGM entries — resolved host-side from the + * bare string key passed to `audio.playBgm`. */ +export const keyBgm = (name: string): string => `audio:bgm.${name}`; + // --------------------------------------------------------------------------- // Font slots // --------------------------------------------------------------------------- diff --git a/src/audio.ts b/src/audio.ts new file mode 100644 index 0000000..edefe2e --- /dev/null +++ b/src/audio.ts @@ -0,0 +1,18 @@ +// Audio-facing public API (AUDIO.md). + +export { + audioSupported, + defineSfx, + playSfx, + playSynth, + playBgm, + pauseBgm, + resumeBgm, + stopBgm, + setVolume, + getVolume, + setMuted, + isMuted, + type AudioOps, + type SynthDesc, +} from "./sound.ts"; diff --git a/src/index.ts b/src/index.ts index c3dc315..9797716 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ if (typeof (globalThis as { queueMicrotask?: unknown }).queueMicrotask !== "func import { detectHost, installFrameHandler, installHost, type HostOps } from "./host.ts"; import { initDevtools, wrapFrameHandler } from "./devtools.ts"; +import { initAudio, resetAudio, type AudioOps } from "./sound.ts"; import { createElement, registerTexture as rendererRegisterTexture, @@ -47,6 +48,9 @@ export interface RenderOptions { styles?: Record; /** App pack; defaults to globalThis.__pak when present. */ pak?: ArrayBuffer; + /** Audio surface (AUDIO.md) — optional, default-off; injected ops win over + * globalThis.audio. Omitted entirely on hosts with no audio. */ + audio?: AudioOps; } export type MountOptions = RenderOptions; @@ -123,6 +127,7 @@ function createLayer(style: Record): NodeMirror { export function render(code: () => unknown, opts: RenderOptions = {}): () => void { const host = detectHost(opts.ops); installHost(host); + initAudio(opts.audio); // AUDIO.md: globalThis.audio (or opts.audio), never ui.* setStyleResolver(resolveStyle); if (opts.styles) registerStyles(opts.styles); @@ -213,6 +218,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi host.ops.destroyNode(child.id); // recursive native destroy } runSweep(); // anything already detached this frame is garbage too + resetAudio(); // stop bgm + clear runtime audio state (test isolation) }; } @@ -234,6 +240,7 @@ export function mount(code: () => unknown, opts: MountOptions = {}): () => void ops, styles: opts.styles ?? DEFAULT_STYLE_IDS, pak: opts.pak, + audio: opts.audio, }); return dispose; } diff --git a/src/sound.ts b/src/sound.ts new file mode 100644 index 0000000..2e0d624 --- /dev/null +++ b/src/sound.ts @@ -0,0 +1,282 @@ +// Pocket Audio runtime (AUDIO.md) — the JS side of the `audio.*` surface. +// +// Audio lives ENTIRELY outside pocketjs-core: no Ui method, no wasm export, +// no DrawList op, and it never will (DETERMINISM.md). Mounted as +// `globalThis.audio` — NOT `ui.*` — so a host that doesn't set it gives the +// guest no way to make noise; goldens, input tapes and headless test hosts +// never mount it, keeping replay byte-exact by construction (same contract +// as the DevTools ops, DEVTOOLS.md). Framework-agnostic: shared by the Solid +// and Vue Vapor entry points (no solid-js imports here — src/audio.ts is the +// thin public shim). +// +// Every op is SYNCHRONOUS fire-and-forget: it enqueues intent and returns +// undefined — nothing about playback state ever flows back into the guest. +// Every public function here is a SILENT no-op when no host is mounted (or +// the host lacks a given method); apps must never branch on audio +// availability (see audioSupported()). + +import { ENUMS } from "../spec/spec.ts"; + +/** The `audio.*` op surface (spec OP 26..31). All methods are optional: a + * host that mounts `globalThis.audio` may implement any subset — a missing + * method is the same silent no-op as no host at all. */ +export interface AudioOps { + /** op 26: one-shot SFX from the pak (audio:sfx.). volume 0..1 + * (x sfx x master gain); pan -1..1 (0 = center). Unknown key: no-op. */ + playSfx?(key: string, volume: number, pan: number): void; + /** op 27: one-shot procedural voice. wave = ENUMS.Waveform ordinal; linear + * freq sweep freq -> freqEnd over durMs; linear attack/release envelope + * in ms. Routed through the sfx bus. */ + playSynth?( + wave: number, + freq: number, + freqEnd: number, + durMs: number, + attackMs: number, + releaseMs: number, + volume: number, + ): void; + /** op 28: start/switch the single music track (audio:bgm.). loop + * 0|1; fadeMs cross-fades from the current track where the host can, + * else cuts. Same key as the playing track: no-op (phase kept). */ + playBgm?(key: string, loop: number, fadeMs: number, volume: number): void; + /** op 29: fade the track to silence and release it. */ + stopBgm?(fadeMs: number): void; + /** op 30: freeze/resume the track cursor (idempotent). */ + pauseBgm?(paused: number): void; + /** op 31: live bus gain. channel = ENUMS.AudioChannel ordinal. Hosts ramp + * ~10ms to avoid clicks. */ + setChannelVolume?(channel: number, volume: number): void; + /** web-only dunder: resume the AudioContext after the first user gesture. + * Wired by the host itself (first keydown/pointerdown) — the runtime + * never calls this. */ + __unlock?(): void; +} + +/** A code-defined procedural sound — the 8-bit palette (AUDIO.md). No asset, + * no pak entry: descriptors travel as op numbers, so a synth sound costs + * zero pak bytes. */ +export interface SynthDesc { + wave: "square" | "pulse25" | "pulse12" | "triangle" | "saw" | "sine" | "noise"; + /** Hz at note-on. */ + freq: number; + /** Hz at note-off (linear sweep); default = freq. */ + freqEnd?: number; + /** Total voice length, envelope included. */ + durMs: number; + /** Linear fade-in; default 0. */ + attackMs?: number; + /** Linear fade-out; default 15 (click-free tail). */ + releaseMs?: number; + /** 0..1, default 1. */ + volume?: number; +} + +const WAVE_ORDINAL: Record = { + square: ENUMS.Waveform.Square, + pulse25: ENUMS.Waveform.Pulse25, + pulse12: ENUMS.Waveform.Pulse12, + triangle: ENUMS.Waveform.Triangle, + saw: ENUMS.Waveform.Saw, + sine: ENUMS.Waveform.Sine, + noise: ENUMS.Waveform.Noise, +}; + +interface AudioState { + ops: AudioOps | null; + volumes: { master: number; sfx: number; bgm: number }; + /** Mute is runtime-level only: it pushes master gain 0 to the host while + * volumes.master keeps holding the app's real value (the "remembered + * master") so unmute can restore it — and setVolume("master") while + * muted can keep updating that remembered value without unmuting. */ + muted: boolean; + synths: Map; + bgm: { key: string | null; playing: boolean; paused: boolean }; +} + +const state: AudioState = { + ops: null, + volumes: { master: 1, sfx: 1, bgm: 1 }, + muted: false, + synths: new Map(), + bgm: { key: null, playing: false, paused: false }, +}; + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +function clampPan(v: number): number { + return v < -1 ? -1 : v > 1 ? 1 : v; +} + +function pushChannelVolume(channel: number, volume: number): void { + state.ops?.setChannelVolume?.(channel, volume); +} + +// --------------------------------------------------------------------------- +// lifecycle (wired by index.ts render()/dispose — not part of the public API) +// --------------------------------------------------------------------------- + +/** + * Resolve the audio host: an explicitly injected AudioOps wins; otherwise + * `globalThis.audio` (mounted by the web host / a test); otherwise no audio. + * Pushes all three channel volumes to the host so its bus gains match + * runtime state (matters when the runtime already has non-default volumes, + * e.g. a hot-reloaded mount). + */ +export function initAudio(injected?: AudioOps): void { + state.ops = injected ?? (globalThis as { audio?: AudioOps }).audio ?? null; + pushChannelVolume(ENUMS.AudioChannel.Master, state.muted ? 0 : state.volumes.master); + pushChannelVolume(ENUMS.AudioChannel.Sfx, state.volumes.sfx); + pushChannelVolume(ENUMS.AudioChannel.Bgm, state.volumes.bgm); +} + +/** Tear down for test isolation / render() disposal: stops any playing BGM + * (fade 0) and resets every module-level field back to its default. */ +export function resetAudio(): void { + if (state.bgm.playing) stopBgm({ fadeMs: 0 }); + state.ops = null; + state.volumes.master = 1; + state.volumes.sfx = 1; + state.volumes.bgm = 1; + state.muted = false; + state.synths.clear(); + state.bgm.key = null; + state.bgm.playing = false; + state.bgm.paused = false; +} + +// --------------------------------------------------------------------------- +// public API (re-exported by src/audio.ts) +// --------------------------------------------------------------------------- + +/** Whether an audio host is mounted. PRESENTATION ONLY (e.g. grey out a + * volume slider) — never branch app state on this (DETERMINISM.md). */ +export function audioSupported(): boolean { + return state.ops !== null; +} + +/** Register a SynthDesc under `name`. Re-defining replaces. Registrations + * live in app JS only — nothing is baked or shipped. */ +export function defineSfx(name: string, desc: SynthDesc): void { + state.synths.set(name, desc); +} + +/** Play an unnamed SynthDesc immediately (op 27), filling in its defaults + * and mapping `wave` to its ENUMS.Waveform ordinal. */ +export function playSynth(desc: SynthDesc): void { + const freqEnd = desc.freqEnd ?? desc.freq; + const attackMs = desc.attackMs ?? 0; + const releaseMs = desc.releaseMs ?? 15; + const volume = clamp01(desc.volume ?? 1); + state.ops?.playSynth?.( + WAVE_ORDINAL[desc.wave], + desc.freq, + freqEnd, + desc.durMs, + attackMs, + releaseMs, + volume, + ); +} + +/** + * Play `name`: if it was registered with defineSfx(), routes to playSynth(); + * otherwise plays the pak SFX (op 26, `audio:sfx.` resolved host-side — + * the bare name crosses the FFI, mirroring loadTileTexture's pak keys). + */ +export function playSfx(name: string, opts?: { volume?: number; pan?: number }): void { + const synth = state.synths.get(name); + if (synth) { + const volume = opts?.volume !== undefined ? clamp01(opts.volume) : synth.volume; + playSynth(volume === synth.volume ? synth : { ...synth, volume }); + return; + } + const volume = clamp01(opts?.volume ?? 1); + const pan = clampPan(opts?.pan ?? 0); + state.ops?.playSfx?.(name, volume, pan); +} + +/** + * Start/switch the single music track (op 28, `audio:bgm.` resolved + * host-side). Calling with the already-playing name is a no-op (phase kept). + */ +export function playBgm( + name: string, + opts?: { loop?: boolean; fadeMs?: number; volume?: number }, +): void { + if (state.bgm.playing && state.bgm.key === name) return; + const loop = opts?.loop ?? true; + const fadeMs = opts?.fadeMs ?? 0; + const volume = clamp01(opts?.volume ?? 1); + state.ops?.playBgm?.(name, loop ? 1 : 0, fadeMs, volume); + state.bgm.key = name; + state.bgm.playing = true; + state.bgm.paused = false; +} + +/** Freeze the track cursor (op 30). Idempotent: a no-op while already + * paused or while nothing is playing. */ +export function pauseBgm(): void { + if (!state.bgm.playing || state.bgm.paused) return; + state.bgm.paused = true; + state.ops?.pauseBgm?.(1); +} + +/** Unfreeze the track cursor (op 30). Idempotent; a no-op after stopBgm() + * since the track is gone. */ +export function resumeBgm(): void { + if (!state.bgm.playing || !state.bgm.paused) return; + state.bgm.paused = false; + state.ops?.pauseBgm?.(0); +} + +/** Fade the track to silence and release it (op 29). No-op if nothing is + * playing. */ +export function stopBgm(opts?: { fadeMs?: number }): void { + if (!state.bgm.playing) return; + const fadeMs = opts?.fadeMs ?? 0; + state.ops?.stopBgm?.(fadeMs); + state.bgm.key = null; + state.bgm.playing = false; + state.bgm.paused = false; +} + +/** Set a bus's live gain (op 31), clamped to 0..1. `setVolume("master", v)` + * while muted updates the remembered master WITHOUT unmuting or pushing to + * the host (the host stays at 0 until setMuted(false)). */ +export function setVolume(channel: "master" | "sfx" | "bgm", v: number): void { + const clamped = clamp01(v); + if (channel === "master") { + state.volumes.master = clamped; + if (!state.muted) pushChannelVolume(ENUMS.AudioChannel.Master, clamped); + return; + } + state.volumes[channel] = clamped; + pushChannelVolume( + channel === "sfx" ? ENUMS.AudioChannel.Sfx : ENUMS.AudioChannel.Bgm, + clamped, + ); +} + +/** The runtime-held value for `channel` — identical on every host, never + * read back from hardware. Reflects the app's intended master volume even + * while muted (see setVolume). */ +export function getVolume(channel: "master" | "sfx" | "bgm"): number { + return state.volumes[channel]; +} + +/** Mute pushes master gain 0 to the host and remembers the current master + * volume; unmute restores it. Runtime-level only (see AUDIO.md + * Determinism contract — a muted PSP and an unmuted browser tab must stay + * pixel-identical app-state-wise). */ +export function setMuted(on: boolean): void { + if (on === state.muted) return; + state.muted = on; + pushChannelVolume(ENUMS.AudioChannel.Master, on ? 0 : state.volumes.master); +} + +export function isMuted(): boolean { + return state.muted; +} diff --git a/test/audio.test.ts b/test/audio.test.ts new file mode 100644 index 0000000..21bddb6 --- /dev/null +++ b/test/audio.test.ts @@ -0,0 +1,352 @@ +// test/audio.test.ts — Pocket Audio runtime (AUDIO.md): src/sound.ts's +// module-level semantics, plus one render()-level wiring check. +// +// Run: bun test --conditions=browser test/audio.test.ts +// (--conditions=browser: see renderer.test.ts — the SSR solid build no-ops.) + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) { + throw new Error("solid-js resolved to its SSR build — run: bun test --conditions=browser"); +} + +import { + audioSupported, + defineSfx, + getVolume, + isMuted, + pauseBgm, + playBgm, + playSfx, + playSynth, + resumeBgm, + setMuted, + setVolume, + stopBgm, + type AudioOps, +} from "../src/audio.ts"; +import { initAudio, resetAudio } from "../src/sound.ts"; +import { installHost, type Host, type HostOps } from "../src/host.ts"; +import { render as publicRender } from "../src/index.ts"; +import { createElement, resetRendererState } from "../src/renderer.ts"; +import { resetStyles } from "../src/styles.ts"; +import { resetInput } from "../src/input.ts"; +import { resetPack } from "../src/pak.ts"; +import { ROOT_ID } from "../spec/spec.ts"; + +type Call = [string, ...unknown[]]; + +/** Records every call, mirroring makeMockHost/makeDevHost in the other test + * files. Every method is implemented by default; individual methods are + * deleted per-test to exercise the "missing method" no-op contract. */ +function makeMockAudio(): AudioOps & { calls: Call[]; of(name: string): Call[] } { + const calls: Call[] = []; + const rec = + (name: string) => + (...args: unknown[]) => { + calls.push([name, ...args]); + }; + return { + calls, + of(name: string) { + return calls.filter((c) => c[0] === name); + }, + playSfx: rec("playSfx"), + playSynth: rec("playSynth"), + playBgm: rec("playBgm"), + stopBgm: rec("stopBgm"), + pauseBgm: rec("pauseBgm"), + setChannelVolume: rec("setChannelVolume"), + }; +} + +// --------------------------------------------------------------------------- +// (1) no host mounted: every public function is a silent no-op +// --------------------------------------------------------------------------- + +describe("no audio host mounted", () => { + beforeEach(() => { + resetAudio(); + }); + + test("every public function is callable and throws nothing", () => { + expect(audioSupported()).toBe(false); + expect(() => { + defineSfx("blip", { wave: "square", freq: 880, durMs: 40 }); + playSfx("blip"); + playSfx("unregistered"); + playSynth({ wave: "noise", freq: 220, durMs: 100 }); + playBgm("theme1"); + pauseBgm(); + resumeBgm(); + stopBgm(); + setVolume("master", 0.5); + setVolume("sfx", 2); // out of range — must clamp, not throw + setMuted(true); + setMuted(false); + isMuted(); + getVolume("bgm"); + }).not.toThrow(); + }); + + test("runtime-held volume/mute state still works with no host", () => { + setVolume("master", 0.4); + expect(getVolume("master")).toBe(0.4); + setMuted(true); + expect(isMuted()).toBe(true); + setMuted(false); + expect(isMuted()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// (2) injected mock AudioOps +// --------------------------------------------------------------------------- + +describe("injected AudioOps", () => { + let audio: ReturnType; + + beforeEach(() => { + resetAudio(); + audio = makeMockAudio(); + initAudio(audio); + }); + afterEach(() => { + resetAudio(); + }); + + test("initAudio pushes all three channel volumes", () => { + expect(audio.of("setChannelVolume")).toEqual([ + ["setChannelVolume", 0, 1], + ["setChannelVolume", 1, 1], + ["setChannelVolume", 2, 1], + ]); + }); + + test("audioSupported() is true once a host is mounted", () => { + expect(audioSupported()).toBe(true); + }); + + describe("playSfx / playSynth / defineSfx", () => { + test("registered name routes to playSynth with defaults filled + wave mapped", () => { + defineSfx("blip", { wave: "square", freq: 880, durMs: 40 }); + playSfx("blip"); + expect(audio.of("playSfx")).toEqual([]); + // wave ordinal (square = 0), freqEnd defaults to freq, attackMs 0, + // releaseMs 15, volume 1. + expect(audio.of("playSynth")).toEqual([["playSynth", 0, 880, 880, 40, 0, 15, 1]]); + }); + + test("unregistered name routes to the playSfx op with the bare name", () => { + playSfx("explosion", { volume: 0.5, pan: -1 }); + expect(audio.of("playSynth")).toEqual([]); + expect(audio.of("playSfx")).toEqual([["playSfx", "explosion", 0.5, -1]]); + }); + + test("playSynth fills defaults and maps every waveform ordinal", () => { + playSynth({ wave: "noise", freq: 100, freqEnd: 50, durMs: 500, attackMs: 5, releaseMs: 200, volume: 0.3 }); + expect(audio.of("playSynth")).toEqual([ + ["playSynth", 6 /* Noise */, 100, 50, 500, 5, 200, 0.3], + ]); + }); + + test("playSfx opts.volume overrides a registered synth's default volume", () => { + defineSfx("laser", { wave: "square", freq: 1760, freqEnd: 110, durMs: 80, volume: 1 }); + playSfx("laser", { volume: 0.2 }); + expect(audio.of("playSynth")).toEqual([["playSynth", 0, 1760, 110, 80, 0, 15, 0.2]]); + }); + }); + + describe("volume + mute", () => { + test("setVolume clamps to 0..1", () => { + setVolume("sfx", 5); + expect(getVolume("sfx")).toBe(1); + setVolume("sfx", -5); + expect(getVolume("sfx")).toBe(0); + }); + + test("mute pushes master 0 and unmute restores the prior master", () => { + setVolume("master", 0.8); + audio.calls.length = 0; + setMuted(true); + expect(isMuted()).toBe(true); + expect(audio.of("setChannelVolume")).toEqual([["setChannelVolume", 0, 0]]); + audio.calls.length = 0; + setMuted(false); + expect(isMuted()).toBe(false); + expect(audio.of("setChannelVolume")).toEqual([["setChannelVolume", 0, 0.8]]); + }); + + test("setVolume('master') while muted updates the remembered value without unmuting", () => { + setVolume("master", 0.8); + setMuted(true); + audio.calls.length = 0; + setVolume("master", 0.3); + expect(isMuted()).toBe(true); // still muted + expect(audio.of("setChannelVolume")).toEqual([]); // host stays at 0, no push + expect(getVolume("master")).toBe(0.3); // remembered value updated + audio.calls.length = 0; + setMuted(false); + expect(audio.of("setChannelVolume")).toEqual([["setChannelVolume", 0, 0.3]]); + }); + + test("setMuted is idempotent (no duplicate pushes)", () => { + setMuted(true); + audio.calls.length = 0; + setMuted(true); + expect(audio.of("setChannelVolume")).toEqual([]); + }); + }); + + describe("BGM", () => { + test("playBgm defaults: loop=true, fadeMs=0, volume=1", () => { + playBgm("theme1"); + expect(audio.of("playBgm")).toEqual([["playBgm", "theme1", 1, 0, 1]]); + }); + + test("same key while playing is a no-op", () => { + playBgm("theme1"); + audio.calls.length = 0; + playBgm("theme1", { fadeMs: 500 }); + expect(audio.of("playBgm")).toEqual([]); + }); + + test("switching key forwards fadeMs", () => { + playBgm("theme1"); + audio.calls.length = 0; + playBgm("theme2", { fadeMs: 300 }); + expect(audio.of("playBgm")).toEqual([["playBgm", "theme2", 1, 300, 1]]); + }); + + test("stopBgm forwards fadeMs", () => { + playBgm("theme1"); + audio.calls.length = 0; + stopBgm({ fadeMs: 250 }); + expect(audio.of("stopBgm")).toEqual([["stopBgm", 250]]); + }); + + test("pauseBgm is idempotent", () => { + playBgm("theme1"); + audio.calls.length = 0; + pauseBgm(); + pauseBgm(); + expect(audio.of("pauseBgm")).toEqual([["pauseBgm", 1]]); + }); + + test("resumeBgm after stopBgm is a no-op", () => { + playBgm("theme1"); + stopBgm(); + audio.calls.length = 0; + resumeBgm(); + expect(audio.of("pauseBgm")).toEqual([]); + }); + + test("resumeBgm restores the cursor after pauseBgm", () => { + playBgm("theme1"); + pauseBgm(); + audio.calls.length = 0; + resumeBgm(); + expect(audio.of("pauseBgm")).toEqual([["pauseBgm", 0]]); + }); + }); + + describe("resetAudio", () => { + test("stops a playing bgm and clears every field back to defaults", () => { + setVolume("master", 0.3); + setMuted(true); + defineSfx("blip", { wave: "square", freq: 880, durMs: 40 }); + playBgm("theme1"); + audio.calls.length = 0; + + resetAudio(); + expect(audio.of("stopBgm")).toEqual([["stopBgm", 0]]); + + // module state is back to defaults: playing the same key again is NOT + // a no-op (bgm state was cleared), and there is no host anymore. + expect(audioSupported()).toBe(false); + expect(isMuted()).toBe(false); + expect(getVolume("master")).toBe(1); + audio.calls.length = 0; + playBgm("theme1"); // no host now — silent no-op, no throw + expect(audio.of("playBgm")).toEqual([]); + }); + }); + + describe("mock host missing optional methods", () => { + test("a mock missing every method never throws", () => { + initAudio({}); + expect(() => { + defineSfx("blip", { wave: "square", freq: 880, durMs: 40 }); + playSfx("blip"); + playSfx("unregistered"); + playSynth({ wave: "sine", freq: 440, durMs: 10 }); + playBgm("theme1"); + pauseBgm(); + resumeBgm(); + stopBgm(); + setVolume("master", 0.5); + setMuted(true); + setMuted(false); + }).not.toThrow(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// (3) render()-level wiring +// --------------------------------------------------------------------------- + +function makeMockHost(): Host & { ops: HostOps } { + let nextId = ROOT_ID + 1; + const noop = () => {}; + const ops: HostOps = { + createNode: () => nextId++, + destroyNode: noop, + insertBefore: noop, + removeChild: noop, + setStyle: noop, + setProp: noop, + setText: noop, + replaceText: noop, + uploadTexture: () => 0, + setImage: noop, + setSprite: noop, + animate: () => 1, + cancelAnim: noop, + setFocus: noop, + measureText: () => 0, + }; + return { ops, kind: "injected", strict: true }; +} + +describe("render({ audio }) wiring (index.ts)", () => { + let host: Host & { ops: HostOps }; + + beforeEach(() => { + host = makeMockHost(); + installHost(host); + resetRendererState(); + resetStyles(); + resetPack(); + resetInput(); + resetAudio(); + }); + afterEach(() => { + resetAudio(); + }); + + test("render() wires opts.audio through initAudio, dispose() stops bgm + resets", () => { + const audio = makeMockAudio(); + const dispose = publicRender(() => createElement("view"), { ops: host.ops, audio }); + + expect(audioSupported()).toBe(true); + expect(audio.of("setChannelVolume").length).toBe(3); + + playBgm("theme1"); + audio.calls.length = 0; + + dispose(); + expect(audio.of("stopBgm")).toEqual([["stopBgm", 0]]); + expect(audioSupported()).toBe(false); // resetAudio() nulled the host + }); +}); From 0c4f720013055bea55e5d390ce1e57191cdb2aad Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:56:36 +0800 Subject: [PATCH 3/9] feat(audio): bake WAV files into SND pak entries via sounds.json --- compiler/pak.ts | 148 ++++++++++++++++++++++ package.json | 2 +- scripts/build.ts | 47 ++++++- test/sound-bake.test.ts | 271 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 test/sound-bake.test.ts diff --git a/compiler/pak.ts b/compiler/pak.ts index 0ab6654..64ab2e9 100644 --- a/compiler/pak.ts +++ b/compiler/pak.ts @@ -31,6 +31,10 @@ import { PAK_MAGIC, PAK_VERSION, PSM, + SND_FLAG_LOOP, + SND_HEADER_SIZE, + SND_MAGIC, + SND_VERSION, TEX_MAX_DIM, TILESET_ABSENT, TILESET_DIR_ENTRY_SIZE, @@ -384,6 +388,150 @@ export function placeholderImage(): DecodedImage { return { width: w, height: h, rgba }; } +// --------------------------------------------------------------------------- +// Sound entries (spec.ts "SND pak entry", AUDIO.md) +// --------------------------------------------------------------------------- + +export interface DecodedWav { + rate: number; + channels: number; + /** Interleaved samples, s16 (8-bit PCM is expanded to s16 range). */ + samples: Int16Array; +} + +/** + * Decode a RIFF/WAVE PCM file (build-time only) to interleaved s16 samples, + * any channel count/rate. Accepts format-1 PCM, 8-bit unsigned or 16-bit + * signed LE; 8-bit samples are expanded to s16. Throws a descriptive error on + * non-PCM or malformed input. + */ +export function decodeWav(bytes: Uint8Array): DecodedWav { + if (bytes.length < 12) throw new Error("wav: file too short for a RIFF header"); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const fourcc = (o: number) => String.fromCharCode(bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]); + if (fourcc(0) !== "RIFF") throw new Error("wav: bad RIFF magic"); + if (fourcc(8) !== "WAVE") throw new Error("wav: not a WAVE file"); + + let format = -1; + let channels = 0; + let rate = 0; + let bitsPerSample = 0; + let dataOff = -1; + let dataLen = 0; + let o = 12; + while (o + 8 <= bytes.length) { + const id = fourcc(o); + const size = dv.getUint32(o + 4, true); + const body = o + 8; + if (body + size > bytes.length) throw new Error(`wav: ${id} chunk overruns the file`); + if (id === "fmt ") { + if (size < 16) throw new Error("wav: fmt chunk too small"); + format = dv.getUint16(body, true); + channels = dv.getUint16(body + 2, true); + rate = dv.getUint32(body + 4, true); + bitsPerSample = dv.getUint16(body + 14, true); + } else if (id === "data") { + dataOff = body; + dataLen = size; + } + o = body + size + (size & 1); // chunks are word-aligned + } + if (format !== 1) throw new Error(`wav: unsupported format ${format} (only PCM=1 supported)`); + if (channels < 1) throw new Error(`wav: bad channel count ${channels}`); + if (bitsPerSample !== 8 && bitsPerSample !== 16) { + throw new Error(`wav: unsupported bit depth ${bitsPerSample} (only 8 or 16 supported)`); + } + if (dataOff < 0) throw new Error("wav: missing data chunk"); + + const bytesPerSample = bitsPerSample / 8; + const sampleCount = Math.floor(dataLen / bytesPerSample); + const samples = new Int16Array(sampleCount); + if (bitsPerSample === 16) { + for (let i = 0; i < sampleCount; i++) samples[i] = dv.getInt16(dataOff + i * 2, true); + } else { + // 8-bit PCM is unsigned, centered at 128 -> expand to the full s16 range. + for (let i = 0; i < sampleCount; i++) samples[i] = (bytes[dataOff + i] - 128) << 8; + } + return { rate, channels, samples }; +} + +/** + * Downmix interleaved multi-channel s16 samples to mono (averaging channels), + * then linear-interpolation resample fromRate -> toRate. Identity fast-path + * when already mono at the target rate (returns a copy, never the input + * array, so callers can always treat the result as owned). + */ +export function resampleMono( + samples: Int16Array, + channels: number, + fromRate: number, + toRate: number, +): Int16Array { + if (channels < 1) throw new Error(`resampleMono: bad channel count ${channels}`); + const frameCount = Math.floor(samples.length / channels); + if (channels === 1 && fromRate === toRate) return samples.slice(0, frameCount); + + const mono = new Float32Array(frameCount); + for (let i = 0; i < frameCount; i++) { + let sum = 0; + for (let c = 0; c < channels; c++) sum += samples[i * channels + c]; + mono[i] = sum / channels; + } + if (fromRate === toRate) { + const out = new Int16Array(frameCount); + for (let i = 0; i < frameCount; i++) out[i] = Math.max(-32768, Math.min(32767, Math.round(mono[i]))); + return out; + } + + const outFrames = Math.max(1, Math.round(frameCount * (toRate / fromRate))); + const step = frameCount > 1 ? (frameCount - 1) / Math.max(1, outFrames - 1) : 0; + const out = new Int16Array(outFrames); + for (let i = 0; i < outFrames; i++) { + const pos = i * step; + const i0 = Math.floor(pos); + const i1 = Math.min(i0 + 1, frameCount - 1); + const frac = pos - i0; + const v = mono[i0] * (1 - frac) + mono[i1] * frac; + out[i] = Math.max(-32768, Math.min(32767, Math.round(v))); + } + return out; +} + +export interface SoundEntryOpts { + /** BGM loops from loopStart (spec SND_FLAG_LOOP). Default false. */ + loop?: boolean; + /** Sample index the loop restarts from (iff loop). Default 0. */ + loopStart?: number; +} + +/** Encode an SND pak entry (see spec.ts "SND pak entry" for the 24-byte + * header layout): mono s16 PCM at sampleRate, optionally looped. */ +export function encodeSoundEntry( + pcm: Int16Array, + sampleRate: number, + opts: SoundEntryOpts = {}, +): Uint8Array { + const loop = opts.loop ?? false; + const loopStart = opts.loopStart ?? 0; + if (!Number.isInteger(sampleRate) || sampleRate <= 0) { + throw new Error(`pak sound: bad sampleRate ${sampleRate}`); + } + if (loopStart < 0 || loopStart > pcm.length) { + throw new Error(`pak sound: loopStart ${loopStart} out of range (0..${pcm.length})`); + } + const out = new Uint8Array(SND_HEADER_SIZE + pcm.length * 2); + const dv = new DataView(out.buffer); + dv.setUint32(0, SND_MAGIC, true); + dv.setUint16(4, SND_VERSION, true); + dv.setUint16(6, loop ? SND_FLAG_LOOP : 0, true); + dv.setUint32(8, sampleRate, true); + dv.setUint32(12, pcm.length, true); + dv.setUint32(16, loopStart, true); + dv.setUint32(20, 0, true); + for (let i = 0; i < pcm.length; i++) dv.setInt16(SND_HEADER_SIZE + i * 2, pcm[i], true); + return out; +} + // --------------------------------------------------------------------------- // Minimal PNG decoder (build-time only) // --------------------------------------------------------------------------- diff --git a/package.json b/package.json index 8118820..ee06355 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "serve": "bun host-web/serve.ts", "golden": "bun test/golden.ts", "e2e": "bun test/e2e-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts test/audio.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts test/audio.test.ts test/sound-bake.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/scripts/build.ts b/scripts/build.ts index 35fcbe7..65f738c 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -24,7 +24,7 @@ import { existsSync, statSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; import { pathToFileURL } from "node:url"; -import { PSM } from "../spec/spec.ts"; +import { PSM, SND_RATE_DEFAULT, keyBgm, keySfx } from "../spec/spec.ts"; import { FRAMEWORKS, frameworkVariantPath, @@ -43,13 +43,16 @@ import { PAK_DTYPE, KEY_STYLES, decodePng, + decodeWav, encodeImageEntry, + encodeSoundEntry, encodeSpriteEntry, keyFont, keyImage, keySprite, pack, placeholderImage, + resampleMono, type PakBlob, } from "../compiler/pak.ts"; @@ -295,6 +298,48 @@ for (const name of imageNames) { } } +// Optional per-app sound manifest: /sounds.json bakes WAV files into +// SND pak entries (AUDIO.md "Assets — sounds.json -> SND pak entries"). +// Zero-cost when absent — no manifest, no wav decoding, no extra bytes. +interface SoundMeta { + name: string; + /** Bake as audio:bgm. instead of the default audio:sfx.. */ + bgm?: boolean; + /** BGM loops from loopStart (spec SND_FLAG_LOOP). */ + loop?: boolean; + loopStart?: number; + /** Output sample rate (default SND_RATE_DEFAULT = 22050; 11025 halves size). */ + rate?: number; +} +const soundManifestPath = appDir + "sounds.json"; +if (existsSync(soundManifestPath)) { + const soundMeta = JSON.parse(await Bun.file(soundManifestPath).text()) as Record; + for (const [file, meta] of Object.entries(soundMeta)) { + const candidates = [appDir + file, ROOT + "assets/sounds/" + file, ROOT + "assets/" + file]; + const found = candidates.find((c) => existsSync(c)); + if (!found) { + console.error(` sounds.json: ${file} not found (tried ${candidates.join(", ")})`); + process.exit(1); + } + const wav = decodeWav(new Uint8Array(await Bun.file(found).arrayBuffer())); + const rate = meta.rate ?? SND_RATE_DEFAULT; + const pcm = resampleMono(wav.samples, wav.channels, wav.rate, rate); + const entry = encodeSoundEntry(pcm, rate, { loop: meta.loop, loopStart: meta.loopStart }); + const key = meta.bgm ? keyBgm(meta.name) : keySfx(meta.name); + blobs.push({ key, dtype: PAK_DTYPE.u8, data: entry }); + const seconds = (pcm.length / rate).toFixed(2); + const kb = (entry.length / 1024).toFixed(1); + console.log( + ` sound: ${meta.name} <- ${found} (${seconds}s, ${rate}Hz${meta.bgm ? " bgm" : ""}${meta.loop ? " loop" : ""}, ${kb} KB)`, + ); + if (meta.bgm && entry.length > 1024 * 1024) { + console.warn( + ` sounds.json: bgm "${meta.name}" is ${kb} KB (> 1 MB) — lower "rate" to 11025 or trim the clip (see AUDIO.md budget)`, + ); + } + } +} + // Optional per-app raw-blob manifest: /pak.json lists PREBAKED binary // entries (e.g. demos/zoomlab's committed TILESET pyramids from gen-assets.ts) // appended verbatim as u8 blobs. This keeps expensive offline bakes out of the diff --git a/test/sound-bake.test.ts b/test/sound-bake.test.ts new file mode 100644 index 0000000..e715921 --- /dev/null +++ b/test/sound-bake.test.ts @@ -0,0 +1,271 @@ +// test/sound-bake.test.ts — the audio ASSET pipeline (AUDIO.md "Assets — +// sounds.json -> SND pak entries"): compiler/pak.ts's decodeWav, resampleMono +// and encodeSoundEntry. Pure deterministic TS; no wall clock, no RNG, no host. +// +// Run: bun test test/sound-bake.test.ts (no --conditions=browser needed — +// this file never touches src/ or solid-js). + +import { describe, expect, test } from "bun:test"; +import { + SND_FLAG_LOOP, + SND_HEADER_SIZE, + SND_MAGIC, + SND_VERSION, +} from "../spec/spec.ts"; +import { decodeWav, encodeSoundEntry, resampleMono } from "../compiler/pak.ts"; + +// --------------------------------------------------------------------------- +// helper: hand-build a minimal RIFF/WAVE PCM file (mirrors buildTileset in +// test/tiles.test.ts — construct the exact bytes the real encoder produces, +// then round-trip through our own decoder). +// --------------------------------------------------------------------------- + +interface WavOpts { + channels: number; + rate: number; + bitsPerSample: 8 | 16; + /** Interleaved samples: signed for 16-bit, 0..255 unsigned for 8-bit. */ + samples: number[]; + /** Override the fmt chunk's format tag (1 = PCM). Used to test rejection. */ + format?: number; +} + +function makeWav(opts: WavOpts): Uint8Array { + const { channels, rate, bitsPerSample, samples, format = 1 } = opts; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = channels * bytesPerSample; + const byteRate = rate * blockAlign; + const dataSize = samples.length * bytesPerSample; + const buf = new Uint8Array(44 + dataSize); + const dv = new DataView(buf.buffer); + const writeStr = (o: number, s: string) => { + for (let i = 0; i < s.length; i++) buf[o + i] = s.charCodeAt(i); + }; + writeStr(0, "RIFF"); + dv.setUint32(4, 36 + dataSize, true); + writeStr(8, "WAVE"); + writeStr(12, "fmt "); + dv.setUint32(16, 16, true); // fmt chunk size (no extension) + dv.setUint16(20, format, true); + dv.setUint16(22, channels, true); + dv.setUint32(24, rate, true); + dv.setUint32(28, byteRate, true); + dv.setUint16(32, blockAlign, true); + dv.setUint16(34, bitsPerSample, true); + writeStr(36, "data"); + dv.setUint32(40, dataSize, true); + for (let i = 0; i < samples.length; i++) { + if (bitsPerSample === 16) dv.setInt16(44 + i * 2, samples[i], true); + else buf[44 + i] = samples[i] & 0xff; + } + return buf; +} + +// --------------------------------------------------------------------------- +// decodeWav +// --------------------------------------------------------------------------- + +describe("decodeWav", () => { + test("extracts rate/channels/samples from a 16-bit mono file", () => { + const wav = makeWav({ channels: 1, rate: 22050, bitsPerSample: 16, samples: [0, 100, -100, 32767, -32768] }); + const dec = decodeWav(wav); + expect(dec.rate).toBe(22050); + expect(dec.channels).toBe(1); + expect(Array.from(dec.samples)).toEqual([0, 100, -100, 32767, -32768]); + }); + + test("extracts interleaved samples from a 16-bit stereo file", () => { + const wav = makeWav({ channels: 2, rate: 44100, bitsPerSample: 16, samples: [100, 300, -50, 50] }); + const dec = decodeWav(wav); + expect(dec.rate).toBe(44100); + expect(dec.channels).toBe(2); + expect(Array.from(dec.samples)).toEqual([100, 300, -50, 50]); + }); + + test("expands 8-bit unsigned PCM to the s16 range, centered at 128", () => { + const wav = makeWav({ channels: 1, rate: 8000, bitsPerSample: 8, samples: [0, 128, 255] }); + const dec = decodeWav(wav); + expect(dec.channels).toBe(1); + expect(dec.rate).toBe(8000); + expect(Array.from(dec.samples)).toEqual([-32768, 0, 32512]); + }); + + test("throws a descriptive error on a non-PCM format tag", () => { + const wav = makeWav({ channels: 1, rate: 22050, bitsPerSample: 16, samples: [0, 1], format: 3 }); + expect(() => decodeWav(wav)).toThrow(/format/); + }); + + test("throws on a bad RIFF/WAVE magic", () => { + const wav = makeWav({ channels: 1, rate: 22050, bitsPerSample: 16, samples: [0] }); + const corrupt = wav.slice(); + corrupt[0] = 0; // clobber "R" of RIFF + expect(() => decodeWav(corrupt)).toThrow(/RIFF/); + }); + + test("throws on an unsupported bit depth", () => { + const wav = makeWav({ channels: 1, rate: 22050, bitsPerSample: 16, samples: [0, 1] }); + // Hand-clobber the bitsPerSample field (offset 34) to an unsupported value. + new DataView(wav.buffer).setUint16(34, 24, true); + expect(() => decodeWav(wav)).toThrow(/bit depth/); + }); + + test("throws on a truncated / malformed file", () => { + expect(() => decodeWav(new Uint8Array([1, 2, 3]))).toThrow(); + const wav = makeWav({ channels: 1, rate: 22050, bitsPerSample: 16, samples: [0, 1, 2] }); + expect(() => decodeWav(wav.slice(0, wav.length - 20))).toThrow(); // data chunk overruns + }); +}); + +// --------------------------------------------------------------------------- +// resampleMono +// --------------------------------------------------------------------------- + +describe("resampleMono", () => { + test("identity fast-path: mono at the same rate returns the samples unchanged", () => { + const samples = new Int16Array([0, 100, -100, 32767, -32768]); + const out = resampleMono(samples, 1, 22050, 22050); + expect(Array.from(out)).toEqual(Array.from(samples)); + }); + + test("downmixes stereo by averaging channels (no resample)", () => { + const samples = new Int16Array([100, 300, -50, 50]); + const out = resampleMono(samples, 2, 44100, 44100); + expect(Array.from(out)).toEqual([200, 0]); + }); + + test("44100 -> 22050 halves frameCount within +-1", () => { + const n = 4410; // 0.1s at 44100 + const samples = new Int16Array(n); + for (let i = 0; i < n; i++) samples[i] = Math.round(Math.sin(i / 10) * 10000); + const out = resampleMono(samples, 1, 44100, 22050); + expect(Math.abs(out.length - n / 2)).toBeLessThanOrEqual(1); + }); + + test("22050 -> 44100 doubles frameCount within +-1 (upsample)", () => { + const n = 2205; + const samples = new Int16Array(n); + for (let i = 0; i < n; i++) samples[i] = (i * 37) % 1000; + const out = resampleMono(samples, 1, 22050, 44100); + expect(Math.abs(out.length - n * 2)).toBeLessThanOrEqual(1); + }); + + test("linear interpolation of a straight ramp stays evenly spaced", () => { + // A perfectly linear source (0, 1000, 2000) resampled to double the rate + // (3 frames @ rate 2 -> 6 frames @ rate 4) must land on an even ramp — + // the exact values a correct linear interpolant produces for straight- + // line input, endpoints included. + const samples = new Int16Array([0, 1000, 2000]); + const out = resampleMono(samples, 1, 2, 4); + expect(out.length).toBe(6); + expect(Array.from(out)).toEqual([0, 400, 800, 1200, 1600, 2000]); + }); + + test("output samples stay within the s16 range", () => { + const samples = new Int16Array([32767, -32768, 32767, -32768]); + const out = resampleMono(samples, 1, 8000, 11025); + for (const v of out) { + expect(v).toBeGreaterThanOrEqual(-32768); + expect(v).toBeLessThanOrEqual(32767); + } + }); +}); + +// --------------------------------------------------------------------------- +// encodeSoundEntry +// --------------------------------------------------------------------------- + +describe("encodeSoundEntry", () => { + test("header round-trips the spec constants with loop off", () => { + const pcm = new Int16Array([0, 1, -1, 32767, -32768]); + const entry = encodeSoundEntry(pcm, 22050); + const dv = new DataView(entry.buffer, entry.byteOffset, entry.byteLength); + expect(dv.getUint32(0, true)).toBe(SND_MAGIC); + expect(dv.getUint16(4, true)).toBe(SND_VERSION); + expect(dv.getUint16(6, true)).toBe(0); // no loop flag + expect(dv.getUint32(8, true)).toBe(22050); + expect(dv.getUint32(12, true)).toBe(pcm.length); + expect(dv.getUint32(16, true)).toBe(0); // loopStart default + expect(dv.getUint32(20, true)).toBe(0); // reserved + expect(entry.length).toBe(SND_HEADER_SIZE + pcm.length * 2); + }); + + test("header carries the loop flag + loopStart when opted in", () => { + const pcm = new Int16Array(10); + const entry = encodeSoundEntry(pcm, 11025, { loop: true, loopStart: 4 }); + const dv = new DataView(entry.buffer, entry.byteOffset, entry.byteLength); + expect(dv.getUint16(6, true)).toBe(SND_FLAG_LOOP); + expect(dv.getUint32(8, true)).toBe(11025); + expect(dv.getUint32(16, true)).toBe(4); + }); + + test("sample bytes are preserved exactly, s16 LE", () => { + const pcm = new Int16Array([1234, -1234, 0, 32767, -32768]); + const entry = encodeSoundEntry(pcm, 22050); + const dv = new DataView(entry.buffer, entry.byteOffset, entry.byteLength); + for (let i = 0; i < pcm.length; i++) { + expect(dv.getInt16(SND_HEADER_SIZE + i * 2, true)).toBe(pcm[i]); + } + }); + + test("rejects an out-of-range loopStart", () => { + const pcm = new Int16Array(4); + expect(() => encodeSoundEntry(pcm, 22050, { loopStart: 5 })).toThrow(); + expect(() => encodeSoundEntry(pcm, 22050, { loopStart: -1 })).toThrow(); + }); + + test("rejects a bad sampleRate", () => { + const pcm = new Int16Array(4); + expect(() => encodeSoundEntry(pcm, 0)).toThrow(); + expect(() => encodeSoundEntry(pcm, -1)).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// end-to-end: wav -> decode -> resample -> encode +// --------------------------------------------------------------------------- + +describe("wav -> SND pipeline", () => { + test("a 440Hz sine at 44100Hz mono bakes to a valid 22050Hz SND entry", () => { + const rate = 44100; + const durationS = 0.05; + const n = Math.round(rate * durationS); + const samples: number[] = new Array(n); + for (let i = 0; i < n; i++) { + samples[i] = Math.round(Math.sin((2 * Math.PI * 440 * i) / rate) * 20000); + } + const wav = makeWav({ channels: 1, rate, bitsPerSample: 16, samples }); + + const decoded = decodeWav(wav); + expect(decoded.rate).toBe(rate); + expect(decoded.channels).toBe(1); + + const target = 22050; + const pcm = resampleMono(decoded.samples, decoded.channels, decoded.rate, target); + expect(Math.abs(pcm.length - n / 2)).toBeLessThanOrEqual(1); + + const entry = encodeSoundEntry(pcm, target, { loop: true, loopStart: 0 }); + const dv = new DataView(entry.buffer, entry.byteOffset, entry.byteLength); + expect(dv.getUint32(0, true)).toBe(SND_MAGIC); + expect(dv.getUint16(4, true)).toBe(SND_VERSION); + expect(dv.getUint16(6, true)).toBe(SND_FLAG_LOOP); + expect(dv.getUint32(8, true)).toBe(target); + expect(dv.getUint32(12, true)).toBe(pcm.length); + expect(entry.length).toBe(SND_HEADER_SIZE + pcm.length * 2); + }); + + test("a stereo 8-bit wav downmixes and resamples end to end", () => { + const rate = 16000; + const n = 100; + const samples: number[] = []; + for (let i = 0; i < n; i++) { + samples.push(128 + Math.round(Math.sin(i / 5) * 50), 128 + Math.round(Math.cos(i / 5) * 50)); + } + const wav = makeWav({ channels: 2, rate, bitsPerSample: 8, samples }); + const decoded = decodeWav(wav); + expect(decoded.channels).toBe(2); + const pcm = resampleMono(decoded.samples, decoded.channels, decoded.rate, 8000); + expect(Math.abs(pcm.length - n / 2)).toBeLessThanOrEqual(1); + const entry = encodeSoundEntry(pcm, 8000); + expect(entry.length).toBe(SND_HEADER_SIZE + pcm.length * 2); + }); +}); From b948be35a23957c3dc05a7b812e96afea5f7cfcf Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:56:36 +0800 Subject: [PATCH 4/9] feat(audio): WebAudio host with gesture unlock and synth pre-render --- host-web/audio.js | 416 +++++++++++++++++++++++++++++++++++++++++++++ host-web/engine.js | 52 +++++- 2 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 host-web/audio.js diff --git a/host-web/audio.js b/host-web/audio.js new file mode 100644 index 0000000..e88e99c --- /dev/null +++ b/host-web/audio.js @@ -0,0 +1,416 @@ +// host-web/audio.js — WebAudio implementation of globalThis.audio (AUDIO.md, +// src/sound.ts AudioOps). Plain ES module, zero deps, in the wasm-ops.js +// style. Mounted by engine.js as `globalThis.audio = createWebAudio(findEntry)` +// BEFORE the demo bundle evals (mirrors the globalThis.ui contract). Audio has +// NO core implementation, no wasm export, no DrawList op (DETERMINISM.md) — +// this file must stay out of test/golden.ts and host-sim/ entirely. +// +// Node graph: +// sfxGain \ +// -> masterGain -> destination +// bgmGain / +// One AudioBufferSourceNode per playSfx/playSynth call (through a per-play +// GainNode + StereoPannerNode); a single BGM "slot" (one GainNode + one live +// source at a time) for playBgm/pauseBgm/stopBgm cross-fades. +// +// SND pak entries (spec/spec.ts, AUDIO.md) are decoded once per key into an +// AudioBuffer and cached; playSynth descriptors are pre-rendered ONCE per +// distinct arg-tuple into a cached AudioBuffer using the exact waveform math +// documented in AUDIO.md, so a blip sounds the same as the PSP mixer. +// +// Autoplay policy (AUDIO.md Hosts table): the AudioContext starts locked — +// browser policy, not a bug. Until __unlock() runs (wired by engine.js to the +// first keydown/pointerdown), playSfx/playSynth are silently dropped and the +// LAST playBgm call is remembered and started from the top at unlock. + +// spec/spec.ts SND_* / ENUMS.Waveform, hand-rolled: host-web has no build +// step for these files (same convention as the BTN map in engine.js). +const SND_MAGIC = 0x44534b50; // 'PKSD' LE +const SND_HEADER_SIZE = 24; +const SND_FLAG_LOOP = 1 << 0; +const WAVE = { SQUARE: 0, PULSE25: 1, PULSE12: 2, TRIANGLE: 3, SAW: 4, SINE: 5, NOISE: 6 }; + +const CHANNEL_RAMP_S = 0.01; // ~10ms click-free channel-volume ramps (AUDIO.md) + +function clamp01(v) { + return v < 0 ? 0 : v > 1 ? 1 : v; +} +function clampPan(v) { + return v < -1 ? -1 : v > 1 ? 1 : v; +} + +/** Parse one SND pak entry (AUDIO.md layout) into header fields + a DataView + * over the s16 LE PCM data. Returns null for a missing/too-short/bad-magic + * entry (silent no-op territory — callers warn once per key). */ +function parseSnd(bytes) { + if (!bytes || bytes.length < SND_HEADER_SIZE) return null; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (dv.getUint32(0, true) !== SND_MAGIC) return null; + const flags = dv.getUint16(6, true); + const sampleRate = dv.getUint32(8, true); + const frameCount = dv.getUint32(12, true); + const loopStart = dv.getUint32(16, true); + if (SND_HEADER_SIZE + frameCount * 2 > bytes.length) return null; + return { flags, sampleRate, frameCount, loopStart, dv, dataOff: SND_HEADER_SIZE }; +} + +/** Render one cycle of a SynthDesc (AUDIO.md waveform math) as Float32 + * samples at `sampleRate`, envelope included. Linear freq sweep freq -> + * freqEnd across durMs; linear attack over attackMs; linear release over the + * final releaseMs. */ +function renderSynth(wave, freq, freqEnd, durMs, attackMs, releaseMs, sampleRate) { + const n = Math.max(1, Math.round((durMs / 1000) * sampleRate)); + const out = new Float32Array(n); + let phase = 0; // 0..1 phase accumulator + let lfsr = 0x1; // 15-bit LFSR, seeded non-zero + const dt = 1 / sampleRate; + for (let i = 0; i < n; i++) { + const t = n > 1 ? i / (n - 1) : 0; // 0..1 progress through the sweep + const f = freq + (freqEnd - freq) * t; + let s; + switch (wave) { + case WAVE.SQUARE: + s = phase < 0.5 ? 1 : -1; + break; + case WAVE.PULSE25: + s = phase < 0.25 ? 1 : -1; + break; + case WAVE.PULSE12: + s = phase < 0.125 ? 1 : -1; + break; + case WAVE.TRIANGLE: + s = 4 * Math.abs(phase - 0.5) - 1; // folded ramp, -1..1 + break; + case WAVE.SAW: + s = 2 * phase - 1; + break; + case WAVE.SINE: + s = Math.sin(2 * Math.PI * phase); + break; + case WAVE.NOISE: + default: + // Classic 15-bit LFSR: taps bits 0 and 1. + lfsr = (lfsr >> 1) | (((lfsr ^ (lfsr >> 1)) & 1) << 14); + s = lfsr & 1 ? 1 : -1; + break; + } + const ms = (i / sampleRate) * 1000; + const remainMs = durMs - ms; + let env = 1; + if (attackMs > 0 && ms < attackMs) env = ms / attackMs; + if (releaseMs > 0 && remainMs < releaseMs) env = Math.min(env, Math.max(0, remainMs / releaseMs)); + out[i] = s * env; + phase += f * dt; + phase -= Math.floor(phase); // wrap 0..1 + } + return out; +} + +/** + * @param {(key: string) => Uint8Array | null} findEntry raw bytes of a pak + * entry (e.g. "audio:sfx.click" / "audio:bgm.theme1"), or null when the + * pak is absent or the key doesn't exist. Built by engine.js over the + * already-fetched pak bytes. + * @returns AudioOps-shaped object (src/sound.ts) plus a host-only dispose(). + */ +export function createWebAudio(findEntry) { + let ctx = null; + let masterGain = null; + let sfxGain = null; + let bgmGain = null; + let unlockedFlag = false; + const channelVolume = [1, 1, 1]; // 0 master, 1 sfx, 2 bgm — applied once ctx exists + const warned = new Set(); // one console.warn per malformed/unknown key + + const sndCache = new Map(); // full pak key -> { buffer, loop, loopStart } | null + const synthCache = new Map(); // packed arg-tuple -> AudioBuffer + + // The single BGM slot: at most one of these is "live" at a time. + let bgm = null; // { key, source, gain, buffer, loop, loopStart, startedAt, offset, paused } + let pendingBgm = null; // { key, loop, fadeMs, volume } remembered while locked + + function ensureCtx() { + if (ctx) return ctx; + const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext; + if (!Ctor) return null; + ctx = new Ctor(); + masterGain = ctx.createGain(); + sfxGain = ctx.createGain(); + bgmGain = ctx.createGain(); + sfxGain.connect(masterGain); + bgmGain.connect(masterGain); + masterGain.connect(ctx.destination); + masterGain.gain.value = channelVolume[0]; + sfxGain.gain.value = channelVolume[1]; + bgmGain.gain.value = channelVolume[2]; + return ctx; + } + + function warnOnce(key, msg) { + if (warned.has(key)) return; + warned.add(key); + console.warn("[pocketjs/audio] " + msg); + } + + function buildSndBuffer(parsed) { + const buf = ctx.createBuffer(1, parsed.frameCount, parsed.sampleRate); + const ch = buf.getChannelData(0); + for (let i = 0; i < parsed.frameCount; i++) { + ch[i] = parsed.dv.getInt16(parsed.dataOff + i * 2, true) / 32768; + } + return buf; + } + + /** Decode-and-cache one SND pak entry (ctx must already exist). */ + function getSndEntry(fullKey) { + if (sndCache.has(fullKey)) return sndCache.get(fullKey); + const bytes = findEntry(fullKey); + let result = null; + if (bytes) { + const parsed = parseSnd(bytes); + if (!parsed) { + warnOnce(fullKey, "malformed SND entry: " + fullKey); + } else { + result = { + buffer: buildSndBuffer(parsed), + loop: !!(parsed.flags & SND_FLAG_LOOP), + loopStart: parsed.loopStart / parsed.sampleRate, + }; + } + } + sndCache.set(fullKey, result); + return result; + } + + /** Play a pre-built AudioBuffer through the sfx bus (used by both playSfx + * and playSynth — a synth voice is just a pre-rendered SFX). */ + function playBufferThroughSfx(buffer, volume, pan) { + const gain = ctx.createGain(); + gain.gain.value = clamp01(volume); + let tail = gain; + if (ctx.createStereoPanner) { + const panner = ctx.createStereoPanner(); + panner.pan.value = clampPan(pan); + gain.connect(panner); + tail = panner; + } + tail.connect(sfxGain); + const src = ctx.createBufferSource(); + src.buffer = buffer; + src.connect(gain); + src.start(); + } + + function playSfxImpl(key, volume, pan) { + if (!unlockedFlag) return; // dropped while locked + const entry = getSndEntry("audio:sfx." + key); + if (!entry) return; // unknown/malformed key: silent no-op + playBufferThroughSfx(entry.buffer, volume, pan); + } + + function playSynthImpl(wave, freq, freqEnd, durMs, attackMs, releaseMs, volume) { + if (!unlockedFlag) return; // dropped while locked + const cacheKey = wave + "|" + freq + "|" + freqEnd + "|" + durMs + "|" + attackMs + "|" + releaseMs + "|" + ctx.sampleRate; + let buffer = synthCache.get(cacheKey); + if (!buffer) { + const data = renderSynth(wave, freq, freqEnd, durMs, attackMs, releaseMs, ctx.sampleRate); + buffer = ctx.createBuffer(1, data.length, ctx.sampleRate); + buffer.getChannelData(0).set(data); + synthCache.set(cacheKey, buffer); + } + playBufferThroughSfx(buffer, volume, 0); + } + + /** Stop+disconnect a bgm slot's live source/gain, optionally cross-fading + * the gain to 0 first (fire-and-forget timer — the slot itself may + * already be gone or replaced by the time it fires). */ + function releaseBgmSlot(slot, fadeMs) { + if (!slot) return; + const now = ctx.currentTime; + if (fadeMs > 0) { + slot.gain.gain.cancelScheduledValues(now); + slot.gain.gain.setValueAtTime(slot.gain.gain.value, now); + slot.gain.gain.linearRampToValueAtTime(0, now + fadeMs / 1000); + setTimeout(() => { + try { + slot.source && slot.source.stop(); + } catch { + /* already stopped */ + } + slot.source && slot.source.disconnect(); + slot.gain.disconnect(); + }, fadeMs); + } else { + try { + slot.source && slot.source.stop(); + } catch { + /* already stopped */ + } + slot.source && slot.source.disconnect(); + slot.gain.disconnect(); + } + } + + /** Start a fresh BGM track from the top, cross-fading out whatever was + * playing before. ctx must already exist. */ + function startBgmTrack(fullKey, loop, fadeMs, volume) { + const entry = getSndEntry(fullKey); + if (!entry) return; // unknown/malformed bgm key: silent no-op + + const gain = ctx.createGain(); + const target = clamp01(volume); + gain.gain.value = fadeMs > 0 ? 0 : target; + gain.connect(bgmGain); + + const src = ctx.createBufferSource(); + src.buffer = entry.buffer; + src.loop = !!loop; + if (src.loop) { + src.loopStart = entry.loopStart; + src.loopEnd = entry.buffer.duration; + } + src.connect(gain); + src.start(0, 0); + + if (fadeMs > 0) { + const now = ctx.currentTime; + gain.gain.linearRampToValueAtTime(target, now + fadeMs / 1000); + } + + releaseBgmSlot(bgm, fadeMs); + + bgm = { + key: fullKey, + source: src, + gain, + buffer: entry.buffer, + loop: !!loop, + loopStart: entry.loopStart, + startedAt: ctx.currentTime, + offset: 0, + paused: false, + }; + } + + function playBgmImpl(key, loop, fadeMs, volume) { + const fullKey = "audio:bgm." + key; + if (bgm && bgm.key === fullKey) return; // already the playing track: no-op (phase kept) + if (!unlockedFlag) { + pendingBgm = { key, loop, fadeMs, volume }; // last call wins; started at unlock + return; + } + startBgmTrack(fullKey, loop, fadeMs, volume); + } + + function stopBgmImpl(fadeMs) { + pendingBgm = null; + if (!bgm) return; + const slot = bgm; + bgm = null; // slot is gone now — resume after stop must be impossible + if (ctx) releaseBgmSlot(slot, fadeMs); + } + + function pauseBgmImpl(paused) { + if (!bgm) return; + if (paused) { + if (bgm.paused) return; // idempotent + const now = ctx.currentTime; + let pos = bgm.offset + (now - bgm.startedAt); + pos = bgm.loop && bgm.buffer.duration > 0 ? pos % bgm.buffer.duration : Math.min(pos, bgm.buffer.duration); + try { + bgm.source.stop(); + } catch { + /* already stopped */ + } + bgm.source.disconnect(); + bgm.source = null; + bgm.offset = pos; + bgm.paused = true; + } else { + if (!bgm.paused) return; // idempotent + const src = ctx.createBufferSource(); + src.buffer = bgm.buffer; + src.loop = bgm.loop; + if (bgm.loop) { + src.loopStart = bgm.loopStart; + src.loopEnd = bgm.buffer.duration; + } + src.connect(bgm.gain); + src.start(0, bgm.offset); + bgm.source = src; + bgm.startedAt = ctx.currentTime; + bgm.paused = false; + } + } + + function setChannelVolumeImpl(channel, volume) { + const v = clamp01(volume); + if (channel < 0 || channel > 2) return; + channelVolume[channel] = v; + if (!ctx) return; // stored; applied to the gain node once ctx exists + const node = channel === 0 ? masterGain : channel === 1 ? sfxGain : bgmGain; + const now = ctx.currentTime; + node.gain.cancelScheduledValues(now); + node.gain.setValueAtTime(node.gain.value, now); + node.gain.linearRampToValueAtTime(v, now + CHANNEL_RAMP_S); + } + + /** Resume the AudioContext after the first user gesture, then start any + * deferred BGM from the top. Idempotent. */ + function unlockImpl() { + if (unlockedFlag) { + if (ctx && ctx.state === "suspended") ctx.resume(); + return; + } + const c = ensureCtx(); + if (!c) return; + unlockedFlag = true; + if (c.state === "suspended") c.resume(); + if (pendingBgm) { + const p = pendingBgm; + pendingBgm = null; + startBgmTrack("audio:bgm." + p.key, p.loop, 0, p.volume); + } + } + + /** Host-only teardown for reload (not part of AudioOps): stop BGM and + * close the AudioContext so a reloaded demo starts from a clean graph. */ + function dispose() { + pendingBgm = null; + if (bgm) { + const slot = bgm; + bgm = null; + try { + slot.source && slot.source.stop(); + } catch { + /* already stopped */ + } + } + if (ctx) { + try { + ctx.close(); + } catch { + /* already closed */ + } + } + ctx = null; + masterGain = null; + sfxGain = null; + bgmGain = null; + unlockedFlag = false; + sndCache.clear(); + synthCache.clear(); + warned.clear(); + } + + return { + playSfx: playSfxImpl, + playSynth: playSynthImpl, + playBgm: playBgmImpl, + stopBgm: stopBgmImpl, + pauseBgm: pauseBgmImpl, + setChannelVolume: setChannelVolumeImpl, + __unlock: unlockImpl, + dispose, + }; +} diff --git a/host-web/engine.js b/host-web/engine.js index 7dd794b..5aac8dd 100644 --- a/host-web/engine.js +++ b/host-web/engine.js @@ -16,6 +16,7 @@ import { createWasmUi, FB_W, FB_H } from "./wasm-ops.js"; import { drawHud, wasmMemoryBytes } from "./hud.js"; +import { createWebAudio } from "./audio.js"; // spec/spec.ts BTN (plain module — keep the literal in sync with the spec). export const BTN = { @@ -66,6 +67,43 @@ const NUBMAP = { }; const nubHeld = new Set(); +// spec/spec.ts PAK_MAGIC/PAK_HEADER_SIZE/PAK_ENTRY_SIZE — hand-rolled (like +// BTN above): host-web has no build step for these files. Mirrors the +// directory format src/pak.ts parses inside the guest bundle; audio.js needs +// its OWN read of the raw pak bytes because it runs host-side, outside the +// bundle's fresh-per-reload module scope. +const PAK_MAGIC = 0x4b504344; // 'DCPK' LE +const PAK_HEADER_SIZE = 32; +const PAK_ENTRY_SIZE = 24; + +/** Parse a fetched .pak ArrayBuffer into a key -> raw-bytes lookup function. + * Safe no-op (returns null for every key) when the pak is absent or + * malformed — used to feed host-side SND lookups (audio.js). */ +function buildPakFindEntry(ab) { + if (!ab || ab.byteLength < PAK_HEADER_SIZE) return () => null; + const dv = new DataView(ab); + if (dv.getUint32(0, true) !== PAK_MAGIC) return () => null; + const entryCount = dv.getUint32(8, true); + const dirOff = dv.getUint32(12, true); + const namesOff = dv.getUint32(16, true); + const u8 = new Uint8Array(ab); + const map = new Map(); + for (let i = 0; i < entryCount; i++) { + const e = dirOff + i * PAK_ENTRY_SIZE; + const blobOff = dv.getUint32(e + 4, true); + const byteLen = dv.getUint32(e + 8, true); + const nameOff = dv.getUint32(e + 12, true); + const nameLen = dv.getUint16(e + 16, true); + let key = ""; + for (let k = 0; k < nameLen; k++) key += String.fromCharCode(u8[namesOff + nameOff + k]); + map.set(key, { off: blobOff, len: byteLen }); + } + return (key) => { + const ent = map.get(key); + return ent ? u8.slice(ent.off, ent.off + ent.len) : null; + }; +} + function packedAnalog() { let x = 0; let y = 0; @@ -80,6 +118,7 @@ function packedAnalog() { } let wasm = null; // createWasmUi result +let audio = null; // createWebAudio result (globalThis.audio); null pre-mount let canvas = null; let ctx = null; let imageData = null; @@ -258,6 +297,7 @@ function stop() { function onKey(down) { return (e) => { + if (down) audio?.__unlock?.(); // first keydown unlocks the AudioContext (AUDIO.md) if (NUBMAP[e.code]) { e.preventDefault(); if (down) nubHeld.add(e.code); @@ -274,8 +314,10 @@ function onKey(down) { /** Virtual on-screen buttons ([data-btn] elements). */ export function pressVirtual(bit, down) { - if (down) held |= bit; - else held &= ~bit; + if (down) { + audio?.__unlock?.(); // touch users unlock via the virtual d-pad too + held |= bit; + } else held &= ~bit; } // ---- lifecycle ------------------------------------------------------------------ @@ -330,6 +372,12 @@ export async function load(name, opts = {}) { try { const pak = await fetch("dist/" + name + ".pak"); globalThis.__pak = pak.ok ? await pak.arrayBuffer() : undefined; + // Audio (AUDIO.md): fresh globalThis.audio BEFORE eval, same contract as + // globalThis.ui. Tear down the previous reload's context/BGM first — a + // new AudioContext per load would otherwise leak on every reload. + audio?.dispose?.(); + audio = createWebAudio(buildPakFindEntry(globalThis.__pak)); + globalThis.audio = audio; const srcRes = await fetch("dist/" + name + ".js"); if (!srcRes.ok) throw new Error("dist/" + name + ".js not found — run: bun scripts/build.ts " + name); const src = await srcRes.text(); From c21a10cc781950d80cf179cd984299888858a74c Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:56:36 +0800 Subject: [PATCH 5/9] feat(audio): record the audio command stream in the sim host trace --- host-sim/sim.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++--- test/sim.test.ts | 11 ++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/host-sim/sim.ts b/host-sim/sim.ts index bc744ba..d1e30d9 100644 --- a/host-sim/sim.ts +++ b/host-sim/sim.ts @@ -26,6 +26,7 @@ import { existsSync } from "node:fs"; import { createWasmUi } from "../host-web/wasm-ops.js"; import { normalizeHz, TICKS_PER_SECOND } from "../src/clock.ts"; +import type { AudioOps } from "../src/sound.ts"; const ROOT = new URL("..", import.meta.url).pathname; // PocketJS/ const DIST = ROOT + "dist/"; @@ -70,6 +71,17 @@ export interface EffectEvent { kind: string; } +/** One `globalThis.audio.*` call recorded by the sim's audio surface (AUDIO.md + * "sim" host row / Determinism contract point 2): the command STREAM is + * deterministic even though real playback is not, so journeys can assert + * *when* sounds fire. */ +export interface AudioEvent { + t: "audio"; + frame: number; + op: string; + args: (string | number)[]; +} + export interface Trace { app: string; hz: number; @@ -77,6 +89,10 @@ export interface Trace { /** FNV-1a of the RGBA framebuffer after every virtual frame. */ hashes: string[]; effects: EffectEvent[]; + /** Every `globalThis.audio.*` call, in call order (AUDIO.md Determinism + * contract point 2). Empty when the app never calls audio.* — sim always + * mounts the recorder, unlike goldens/tapes which mount nothing. */ + audio: AudioEvent[]; /** Raw RGBA of the final frame (for cross-hz byte comparison / PNGs). */ finalFrame: Uint8Array; /** Component tree JSON after the final frame (DevTools getTree). */ @@ -144,9 +160,34 @@ export interface SimWorld { ticksPerFrame: number; hz: number; effects: EffectEvent[]; + audio: AudioEvent[]; getTree: () => unknown; } +/** + * The sim's `globalThis.audio` mount (AUDIO.md "sim" host row): a pure + * recorder, not a player. Every op appends one `AudioEvent` to `sink` and + * returns undefined — no wall clock, no RNG, no playback — so two runs of the + * same tape (clean or chaos, any hz) produce identical `Trace.audio` + * (Determinism contract point 2). `getFrame` supplies the current virtual + * frame index; bootWorld() wires it to the same counter it advances the + * bundle's `frame()` entry point with. + */ +function createAudioRecorder(sink: AudioEvent[], getFrame: () => number): AudioOps { + const rec = (op: string, ...args: (string | number)[]) => { + sink.push({ t: "audio", frame: getFrame(), op, args }); + }; + return { + playSfx: (key, volume, pan) => rec("playSfx", key, volume, pan), + playSynth: (wave, freq, freqEnd, durMs, attackMs, releaseMs, volume) => + rec("playSynth", wave, freq, freqEnd, durMs, attackMs, releaseMs, volume), + playBgm: (key, loop, fadeMs, volume) => rec("playBgm", key, loop, fadeMs, volume), + stopBgm: (fadeMs) => rec("stopBgm", fadeMs), + pauseBgm: (paused) => rec("pauseBgm", paused), + setChannelVolume: (channel, volume) => rec("setChannelVolume", channel, volume), + }; +} + /** * Boot a fresh world: fresh wasm core, fresh bundle eval, host globals * (ui/__pak/__simHz/effect trace/DevTools transport) installed before eval — @@ -165,8 +206,14 @@ export async function bootWorld( const wasm = await createWasmUi(wasmBytes); const g = globalThis as Record; const effects: EffectEvent[] = []; + const audio: AudioEvent[] = []; const inbox: string[] = []; const outbox: string[] = []; + // Mirrors src/clock.ts's private `frame` counter: -1 before the first + // pump, advanced to 0 on it, +1 per pump thereafter — so an audio.* call + // made by app code during pump n is stamped with the same frame index + // virtualFrame() would report inside that same pump. + let currentFrame = -1; g.ui = wasm.ops; g.__pak = existsSync(DIST + app + ".pak") ? await Bun.file(DIST + app + ".pak").arrayBuffer() @@ -180,13 +227,20 @@ export async function bootWorld( send: (line: string) => outbox.push(line), recv: () => (inbox.length ? inbox.shift() : null), }; + // Fresh recorder per boot (fresh `audio` array + closure) — traces never + // leak across worlds/runs (AUDIO.md "sim" host row). + g.audio = createAudioRecorder(audio, () => (currentFrame < 0 ? 0 : currentFrame)); if (extraGlobals) Object.assign(g, extraGlobals); const src = await Bun.file(DIST + app + ".js").text(); (0, eval)(src); - const frame = g.frame as ((buttons: number, analog?: number) => void) | undefined; - if (typeof frame !== "function") { + const rawFrame = g.frame as ((buttons: number, analog?: number) => void) | undefined; + if (typeof rawFrame !== "function") { throw new Error("sim: bundle did not install globalThis.frame (entry must call render()/mount())"); } + const frame = (buttons: number, analog?: number) => { + currentFrame = currentFrame < 0 ? 0 : currentFrame + 1; + rawFrame(buttons, analog); + }; return { frame, tick: wasm.tick, @@ -194,6 +248,7 @@ export async function bootWorld( ticksPerFrame: TICKS_PER_SECOND / hz, hz, effects, + audio, // Tree probe: ask the DevTools shim, flush with one extra frame (the // shim polls its transport at frame start). The probe frame advances the // world — call it only when the run is over. @@ -237,7 +292,16 @@ export async function runScenario(scenario: Scenario, chaos?: ChaosOptions): Pro } const finalFrame = world.render().slice(); const tree = world.getTree(); - return { app: scenario.app, hz, frames, hashes, effects: world.effects.slice(), finalFrame, tree }; + return { + app: scenario.app, + hz, + frames, + hashes, + effects: world.effects.slice(), + audio: world.audio.slice(), + finalFrame, + tree, + }; } /** Depth-first search of a DevTools tree (TreeNodeJson: text = `x`, children diff --git a/test/sim.test.ts b/test/sim.test.ts index 8fdd28b..961c8f4 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -55,6 +55,17 @@ describe("determinism", () => { expect(chaos.effects).toEqual(t60.effects); }); + test("the audio command stream is deterministic too (AUDIO.md, chaos-equal)", async () => { + // cafe-main doesn't wire up audio yet (demo audio wiring lands later), so + // both arrays are empty today — but this is the harness that will catch + // any drift once a demo starts calling audio.*: same tape, same ops, same + // frames, chaos or clean. JSON.stringify keeps the comparison trivial to + // extend with real expected events later. + expect(Array.isArray(t60.audio)).toBe(true); + const chaos = await runScenario(scenario(60), { maxSleepMs: 8, gcEvery: 60 }); + expect(JSON.stringify(chaos.audio)).toBe(JSON.stringify(t60.audio)); + }); + test("the low-rate worlds are deterministic too", async () => { expect((await runScenario(scenario(4))).hashes).toEqual(t4.hashes); expect((await runScenario(scenario(2))).hashes).toEqual(t2.hashes); From ed39c4b6d21d3158beeb8e0fff521b9b54cf8be9 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 10:56:36 +0800 Subject: [PATCH 6/9] feat(audio): PSP software mixer thread behind globalThis.audio --- native/src/audio.rs | 1136 +++++++++++++++++++++++++++++++++++++++++++ native/src/ffi.rs | 128 +++++ native/src/lib.rs | 1 + native/src/main.rs | 20 +- native/src/pak.rs | 54 +- 5 files changed, 1337 insertions(+), 2 deletions(-) create mode 100644 native/src/audio.rs diff --git a/native/src/audio.rs b/native/src/audio.rs new file mode 100644 index 0000000..b10dbc3 --- /dev/null +++ b/native/src/audio.rs @@ -0,0 +1,1136 @@ +//! PSP native audio mixer — the host side of `globalThis.audio` (AUDIO.md; +//! spec ops 26-31, `spec/spec.ts` OP.playSfx..setChannelVolume). Audio has NO +//! core (`pocketjs-core::Ui`) implementation and never will (AUDIO.md +//! "Determinism contract") — this module is entirely separate from ffi.rs's +//! `ui` surface, wired up by its own `ffi::register_audio`. +//! +//! ## Threading model +//! +//! One hardware channel (44100 Hz stereo, reserved once in `init`) is driven +//! by a dedicated mixer thread, separate from the QuickJS worker thread that +//! owns the arena allocator (`arena.rs`: "Single-threaded (the QuickJS +//! worker), so `static mut` matches the existing style" — that contract is +//! for the ARENA specifically, and this module never calls into it). The two +//! threads talk through a lock-free SPSC command ring (`RING`): the JS thread +//! (inside the `play_*`/`stop_bgm`/`pause_bgm`/`set_bus_volume` ops below) +//! pushes; the mixer thread drains once per 1024-frame block. Everything the +//! mixer thread touches after boot (`VOICES`, `RING`, `BUS_*`, `OUT_BUF`) is +//! either exclusively mixer-thread-owned or synchronized purely through the +//! ring's Acquire/Release atomics — no locks, no shared mutable state that +//! crosses threads any other way. +//! +//! `SOUNDS` (the sound registry) is the one exception: it is filled from +//! `pak::feed` on the BOOT thread before `init()` ever creates the mixer +//! thread (see main.rs's ordering), and is treated as read-only by both +//! threads from that point on — plain `static mut` reads are sound because +//! there is no concurrent writer once the mixer thread exists. +//! +//! ## Allocation / float discipline +//! +//! The mixer thread (`mixer_thread_entry`, `mix_block` and everything it +//! calls) is **allocation-free and integer-only**: no heap, no `format!`, no +//! `Vec`/`Box`, no f32/f64. All state lives in fixed-size `static` arrays; +//! gains are Q15 (`i32`, unity = `Q15_ONE` = 1<<15); PCM/synth phase uses +//! 16.16 or full-`u32` fixed-point accumulators. The handful of f32/f64 +//! conversions in this file (`q15_from_unit`, `pan_gains`, `ms_to_samples`, +//! `hz_to_step`) run ONLY on the JS thread, converting JS's f64 args to +//! integers before they ever reach the ring — flagged individually below. +//! +//! ## Voice pool + cross-fade scheme +//! +//! `VOICES` is a fixed `[Voice; 8]`: slots 0..4 are PCM one-shot SFX, slot 4 +//! is the single BGM slot, slots 5..8 are procedural synth voices (AUDIO.md's +//! "4 SFX + 1 BGM + 3 synth"). SFX and synth pools do "oldest-of-kind" +//! stealing (`alloc_voice`) via a monotonic per-voice `trigger` counter. +//! +//! BGM only has ONE slot, so a true overlapping cross-fade needs somewhere to +//! put the outgoing track while the new one ramps in. Scheme: when +//! `playBgm(..., fadeMs>0)` arrives and a track is already playing, the OLD +//! voice's playback state is copied into a borrowed SYNTH slot (stolen +//! oldest-of-kind exactly like a real synth trigger would be), retagged +//! `VKind::BgmDying`, and given a linear fade-to-0 that frees the slot when +//! it completes (`die_at_fade_end`). The BGM slot itself is immediately +//! handed to the new track, ramping 0 -> volume over the same window. Net +//! effect: a real overlapping cross-fade using the SAME fixed 8-voice pool — +//! no 9th slot, no second "BGM-capable" slot carved out of the budget. The +//! trade-off (documented, accepted): a `BgmDying` voice can itself be stolen +//! by a genuine synth trigger (or a second rapid `playBgm`) if all 3 synth +//! slots are busy — the old track just cuts a little early in that case, +//! which is a fine failure mode for a fire-and-forget fade. +//! +//! `fadeMs == 0` never allocates a dying voice: the BGM slot is simply +//! overwritten (silent cut), matching the web host's "else cuts" behavior. +//! +//! Pausing (`pauseBgm`) freezes the ENTIRE BGM voice — cursor, fade ramp, the +//! lot — and produces silence while paused (AUDIO.md Hosts table: "BGM pause +//! freezes the cursor (voice stays, produces silence)"); resuming continues +//! exactly where it left off, fade included. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use psp::sys::{self, AudioFormat, ThreadAttributes, AUDIO_NEXT_CHANNEL, AUDIO_VOLUME_MAX}; + +// --------------------------------------------------------------------------- +// constants +// --------------------------------------------------------------------------- + +/// Frames per `sceAudioOutputPannedBlocking` call (~23.2 ms @ 44100 Hz) — +/// AUDIO.md "1024-sample blocks ~= 23 ms". +const BLOCK: usize = 1024; +/// Interleaved stereo i16 samples per block. +const MIX_LEN: usize = BLOCK * 2; +/// The one hardware channel always runs at 44100 Hz stereo (rust-psp's plain +/// channel API); SFX/BGM source rates (22050/11025 Hz, SND header) resample +/// UP to this via the 16.16 cursor step. +const OUTPUT_RATE: u32 = 44100; + +const NUM_VOICES: usize = 8; +const SFX_START: usize = 0; +const SFX_COUNT: usize = 4; +const BGM_SLOT: usize = 4; +const SYNTH_START: usize = 5; +const SYNTH_COUNT: usize = 3; + +/// SPSC command ring capacity (power of two not required — modulo, not mask). +const RING_CAP: usize = 32; + +const MAX_SOUNDS: usize = 64; +const MAX_NAME_LEN: usize = 32; + +/// Q15 unity gain (gains are 0..=Q15_ONE, never negative — these are volume +/// multipliers, not signed audio samples). +const Q15_ONE: i32 = 1 << 15; + +/// setChannelVolume ramp window: "Hosts ramp ~10 ms to avoid clicks" +/// (spec/spec.ts OP.setChannelVolume, AUDIO.md). 44100 * 0.010 ~= 441. +const BUS_RAMP_SAMPLES: u32 = 441; + +// SND pak entry format (AUDIO.md / spec/spec.ts SND_*). Not re-exported from +// pocketjs_core::spec (spec/gen-rust.ts hasn't grown SND constants there), +// so these are pinned locally, byte-for-byte identical to spec.ts's values — +// same approach pak.rs already takes for IMG/SPRITE entry sub-fields that +// don't have their own core::spec constants either. +pub(crate) const SND_MAGIC: u32 = 0x4453_4b50; // 'PKSD' LE +pub(crate) const SND_VERSION: u16 = 1; +pub(crate) const SND_HEADER_SIZE: usize = 24; +pub(crate) const SND_FLAG_LOOP: u16 = 1 << 0; + +// --------------------------------------------------------------------------- +// waveform (ENUMS.Waveform, spec/spec.ts:419 — ordinals are the wire values) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum Waveform { + Square = 0, + Pulse25 = 1, + Pulse12 = 2, + Triangle = 3, + Saw = 4, + Sine = 5, + Noise = 6, +} + +impl Waveform { + /// Any out-of-range ordinal (a future enum value an old host doesn't know + /// about yet) falls back to Noise — never panics, never indexes OOB. + fn from_u8(v: u8) -> Self { + match v { + 0 => Waveform::Square, + 1 => Waveform::Pulse25, + 2 => Waveform::Pulse12, + 3 => Waveform::Triangle, + 4 => Waveform::Saw, + 5 => Waveform::Sine, + _ => Waveform::Noise, + } + } +} + +/// 256-entry full-cycle sine table, `round(sin(2*pi*i/256) * 32767)` — +/// precomputed (no libm on the audio thread). Indexed by the top 8 bits of +/// the 32-bit phase accumulator (`phase >> 24`). +static SINE_LUT: [i16; 256] = [ + 0, 804, 1608, 2410, 3212, 4011, 4808, 5602, 6393, 7179, 7962, 8739, 9512, 10278, 11039, 11793, + 12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530, 18204, 18868, 19519, 20159, 20787, 21403, 22005, 22594, + 23170, 23731, 24279, 24811, 25329, 25832, 26319, 26790, 27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956, + 30273, 30571, 30852, 31113, 31356, 31580, 31785, 31971, 32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757, + 32767, 32757, 32728, 32678, 32609, 32521, 32412, 32285, 32137, 31971, 31785, 31580, 31356, 31113, 30852, 30571, + 30273, 29956, 29621, 29268, 28898, 28510, 28105, 27683, 27245, 26790, 26319, 25832, 25329, 24811, 24279, 23731, + 23170, 22594, 22005, 21403, 20787, 20159, 19519, 18868, 18204, 17530, 16846, 16151, 15446, 14732, 14010, 13279, + 12539, 11793, 11039, 10278, 9512, 8739, 7962, 7179, 6393, 5602, 4808, 4011, 3212, 2410, 1608, 804, + 0, -804, -1608, -2410, -3212, -4011, -4808, -5602, -6393, -7179, -7962, -8739, -9512, -10278, -11039, -11793, + -12539, -13279, -14010, -14732, -15446, -16151, -16846, -17530, -18204, -18868, -19519, -20159, -20787, -21403, -22005, -22594, + -23170, -23731, -24279, -24811, -25329, -25832, -26319, -26790, -27245, -27683, -28105, -28510, -28898, -29268, -29621, -29956, + -30273, -30571, -30852, -31113, -31356, -31580, -31785, -31971, -32137, -32285, -32412, -32521, -32609, -32678, -32728, -32757, + -32767, -32757, -32728, -32678, -32609, -32521, -32412, -32285, -32137, -31971, -31785, -31580, -31356, -31113, -30852, -30571, + -30273, -29956, -29621, -29268, -28898, -28510, -28105, -27683, -27245, -26790, -26319, -25832, -25329, -24811, -24279, -23731, + -23170, -22594, -22005, -21403, -20787, -20159, -19519, -18868, -18204, -17530, -16846, -16151, -15446, -14732, -14010, -13279, + -12539, -11793, -11039, -10278, -9512, -8739, -7962, -7179, -6393, -5602, -4808, -4011, -3212, -2410, -1608, -804, +]; + +// --------------------------------------------------------------------------- +// sound registry (filled at boot from pak.rs, read-only once the mixer runs) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum SoundKind { + Sfx, + Bgm, +} + +#[derive(Clone, Copy)] +struct SoundReg { + in_use: bool, + kind: SoundKind, + name: [u8; MAX_NAME_LEN], + name_len: u8, + // Pointer into APP_PAK's .rodata (include_bytes!, program-lifetime + // 'static) — see register_sound's safety doc. Never null while in_use. + ptr: *const i16, + frames: u32, + rate: u32, + // NOTE: the SND header's OWN loop flag is deliberately not stored here — + // whether an instance loops is entirely the CALLER's decision each time + // (AUDIO.md playBgm's `loop` argument); `loop_start` is the only baked + // metadata playback needs, and pak.rs already clamps it to 0 when the + // header itself wasn't baked with looping in mind. + loop_start: u32, +} + +impl SoundReg { + const EMPTY: SoundReg = SoundReg { + in_use: false, + kind: SoundKind::Sfx, + name: [0; MAX_NAME_LEN], + name_len: 0, + ptr: core::ptr::null(), + frames: 0, + rate: 0, + loop_start: 0, + }; +} + +/// Filled once at boot (pak::feed, boot thread) BEFORE `init()` creates the +/// mixer thread — see main.rs's ordering. Plain `static mut` writes here are +/// sound: nothing else touches this table concurrently (module docs). +static mut SOUNDS: [SoundReg; MAX_SOUNDS] = [SoundReg::EMPTY; MAX_SOUNDS]; +static mut SOUND_COUNT: usize = 0; + +/// Register one SND pak entry. Called from `pak::feed` while walking the +/// pak, on the boot thread, strictly before `init()` — see main.rs. +/// +/// # Safety +/// Single-threaded boot contract, same shape as `pak::install`: caller must +/// not call this concurrently with itself or with any mixer-thread read (the +/// mixer thread does not exist yet at the point main.rs calls this). `ptr` +/// must point at `frames` contiguous `i16` LE samples that remain valid for +/// the rest of the program's lifetime (APP_PAK's .rodata satisfies this). +pub unsafe fn register_sound( + kind: SoundKind, + name: &str, + ptr: *const i16, + frames: u32, + rate: u32, + loop_start: u32, +) { + let bytes = name.as_bytes(); + if bytes.len() > MAX_NAME_LEN || SOUND_COUNT >= MAX_SOUNDS { + return; // too long / registry full: skip silently (pak.rs convention) + } + let slot = &mut SOUNDS[SOUND_COUNT]; + slot.in_use = true; + slot.kind = kind; + slot.name = [0; MAX_NAME_LEN]; + slot.name[..bytes.len()].copy_from_slice(bytes); + slot.name_len = bytes.len() as u8; + slot.ptr = ptr; + slot.frames = frames; + slot.rate = if rate == 0 { OUTPUT_RATE } else { rate }; + slot.loop_start = loop_start; + SOUND_COUNT += 1; +} + +/// Linear scan (registry is <= 64 entries, looked up only on `play*` calls, +/// never per-sample) — mirrors pak::find's "callers cache the handle, not +/// the lookup" comment, except here the JS runtime just calls by name every +/// time (AUDIO.md ops are fire-and-forget), so this stays O(entries) by +/// design. +unsafe fn find_sound(kind: SoundKind, name: &str) -> Option { + let bytes = name.as_bytes(); + for i in 0..SOUND_COUNT { + let s = SOUNDS[i]; + if s.in_use && s.kind == kind && s.name_len as usize == bytes.len() && &s.name[..bytes.len()] == bytes { + return Some(s); + } + } + None +} + +// --------------------------------------------------------------------------- +// SPSC command ring (JS thread pushes, mixer thread drains) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum Cmd { + None, + PlaySfx { + ptr: *const i16, + frames: u32, + rate: u32, + vol_q15: i32, + pan_l_q15: i32, + pan_r_q15: i32, + }, + PlaySynth { + wave: Waveform, + step0: u32, + step1: u32, + total_samples: u32, + attack_samples: u32, + release_samples: u32, + vol_q15: i32, + }, + PlayBgm { + ptr: *const i16, + frames: u32, + rate: u32, + loop_flag: bool, + loop_start: u32, + fade_samples: u32, + vol_q15: i32, + }, + StopBgm { + fade_samples: u32, + }, + PauseBgm { + paused: bool, + }, + SetBusVolume { + bus: usize, + vol_q15: i32, + }, +} + +static mut RING: [Cmd; RING_CAP] = [Cmd::None; RING_CAP]; +/// Written only by the producer (JS thread); read by the consumer via +/// Acquire to observe the paired Release store's slot write. +static RING_HEAD: AtomicUsize = AtomicUsize::new(0); +/// Written only by the consumer (mixer thread); read by the producer via +/// Acquire to know how much room is free. +static RING_TAIL: AtomicUsize = AtomicUsize::new(0); + +/// Push one command. Never blocks: a full ring silently drops the command +/// (AUDIO.md "synchronous fire-and-forget" — dropping under extreme command +/// pressure is strictly better than stalling the JS thread). +/// +/// # Safety +/// Must only be called from the single JS-thread producer (never +/// concurrently with itself). +unsafe fn ring_push(cmd: Cmd) { + let head = RING_HEAD.load(Ordering::Relaxed); // sole writer of HEAD + let tail = RING_TAIL.load(Ordering::Acquire); // syncs with the consumer's Release store below + let next = (head + 1) % RING_CAP; + if next == tail { + return; // full: drop + } + RING[head] = cmd; + RING_HEAD.store(next, Ordering::Release); // publishes the slot write above to the consumer +} + +/// Drain every pending command. Called once per block from the mixer +/// thread. +/// +/// # Safety +/// Must only be called from the single mixer-thread consumer. +unsafe fn ring_drain_all() { + loop { + let tail = RING_TAIL.load(Ordering::Relaxed); // sole writer of TAIL + let head = RING_HEAD.load(Ordering::Acquire); // syncs with the producer's Release store + if tail == head { + break; + } + let cmd = RING[tail]; + apply_cmd(cmd); + RING_TAIL.store((tail + 1) % RING_CAP, Ordering::Release); // publishes the free slot to the producer + } +} + +// --------------------------------------------------------------------------- +// voice pool +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum VKind { + Free, + Sfx, + Bgm, + /// An outgoing BGM track fading out in a BORROWED synth slot — see the + /// module doc's cross-fade scheme. + BgmDying, + Synth, +} + +#[derive(Clone, Copy)] +struct Voice { + kind: VKind, + /// Monotonic trigger counter for oldest-of-kind stealing (`alloc_voice`). + trigger: u32, + + // -- PCM playback (Sfx, Bgm, BgmDying) -- + ptr: *const i16, + frames: u32, + /// 16.16 fixed-point index into the source PCM (integer part = sample). + cursor: u32, + /// 16.16 per-output-sample cursor increment = `source_rate * 65536 / 44100`. + step: u32, + loop_flag: bool, + loop_start: u32, + /// BGM (and its BgmDying copy) only: freezes cursor + fade + output. + paused: bool, + + // -- synth -- + wave: Waveform, + /// Phase accumulator: the full `u32` range is one waveform cycle + /// (`phase >> 24` indexes the 256-entry sine LUT; `phase >> 16` gives a + /// 16-bit 0..65536 fraction-of-cycle for the duty/ramp waveforms). + phase: u32, + freq_step0: u32, + freq_step1: u32, + total_samples: u32, + attack_samples: u32, + release_samples: u32, + elapsed: u32, + /// 15-bit Galois LFSR state for the noise waveform (never 0). + lfsr: u16, + /// Static per-voice volume (multiplies the envelope for synth voices). + vol_q15: i32, + + // -- generic linear gain ramp (Sfx: constant, no ramp; Bgm/BgmDying: the + // cross-fade / stop-fade ramps) -- + gain_cur: i32, + gain_target: i32, + gain_step: i32, + fade_remaining: u32, + die_at_fade_end: bool, + + // -- pan (Sfx only; Bgm/Synth/BgmDying stay centered, both Q15_ONE) -- + pan_l_q15: i32, + pan_r_q15: i32, +} + +impl Voice { + const EMPTY: Voice = Voice { + kind: VKind::Free, + trigger: 0, + ptr: core::ptr::null(), + frames: 0, + cursor: 0, + step: 0, + loop_flag: false, + loop_start: 0, + paused: false, + wave: Waveform::Square, + phase: 0, + freq_step0: 0, + freq_step1: 0, + total_samples: 0, + attack_samples: 0, + release_samples: 0, + elapsed: 0, + lfsr: 0xACE1, + vol_q15: 0, + gain_cur: 0, + gain_target: 0, + gain_step: 0, + fade_remaining: 0, + die_at_fade_end: false, + pan_l_q15: Q15_ONE, + pan_r_q15: Q15_ONE, + }; +} + +/// Mixer-thread-owned after boot (see module docs); the JS thread never +/// touches this array directly, only through `RING` commands. +static mut VOICES: [Voice; NUM_VOICES] = [Voice::EMPTY; NUM_VOICES]; +static mut TRIGGER: u32 = 0; + +/// Live bus gains: [Master, Sfx, Bgm] (ENUMS.AudioChannel ordinals). Ramped +/// over BUS_RAMP_SAMPLES on every `setChannelVolume` (click guard). +static mut BUS_CUR: [i32; 3] = [Q15_ONE, Q15_ONE, Q15_ONE]; +static mut BUS_TARGET: [i32; 3] = [Q15_ONE, Q15_ONE, Q15_ONE]; +static mut BUS_STEP: [i32; 3] = [0, 0, 0]; +static mut BUS_REMAINING: [u32; 3] = [0, 0, 0]; + +/// False until `init()` fully succeeds (channel reserved + mixer thread +/// started). Every `play_*`/`stop_bgm`/`pause_bgm`/`set_bus_volume` op checks +/// this FIRST — a boot failure must not change guest-visible shape, so ops +/// just silently no-op forever instead (AUDIO.md capability-as-surface +/// contract: the ops still exist as functions, they just do nothing). +static READY: AtomicBool = AtomicBool::new(false); + +static mut OUT_BUF: [i16; MIX_LEN] = [0; MIX_LEN]; +static mut CHANNEL: i32 = -1; + +// --------------------------------------------------------------------------- +// integer-only helpers (audio thread AND JS thread both use these — no +// float, safe to share) +// --------------------------------------------------------------------------- + +#[inline] +fn q15_mul(a: i32, b: i32) -> i32 { + (((a as i64) * (b as i64)) >> 15) as i32 +} + +#[inline] +fn clamp_i16(v: i32) -> i16 { + if v > i16::MAX as i32 { + i16::MAX + } else if v < i16::MIN as i32 { + i16::MIN + } else { + v as i16 + } +} + +/// Per-sample ramp step from `from` to `to` over `samples` samples. Floors +/// to +/-1 when integer division truncates a nonzero difference to 0 (a huge +/// `samples` count with a tiny gain delta) so the ramp is still monotonic and +/// visibly moving rather than appearing frozen; `advance_fade`/ +/// `advance_bus_ramps` clamp against overshoot, and both always snap exactly +/// to `to` when their remaining-count reaches 0, so this floor never causes +/// the final value to be wrong — only (rarely) to arrive a few samples early. +#[inline] +fn fade_step(from: i32, to: i32, samples: u32) -> i32 { + if samples == 0 { + return 0; + } + let diff = to as i64 - from as i64; + let mut step = diff / samples as i64; + if step == 0 && diff != 0 { + step = if diff > 0 { 1 } else { -1 }; + } + step as i32 +} + +/// 16.16 fixed-point per-output-sample cursor step for resampling `rate` Hz +/// mono PCM up to the fixed 44100 Hz hardware channel (nearest-neighbor — +/// no interpolation; simplest correct choice for an allocation-free, +/// integer-only mixer, and plenty for chiptune-grade source material). +fn rate_step(rate: u32) -> u32 { + let rate = if rate == 0 { OUTPUT_RATE } else { rate }; + (((rate as u64) << 16) / OUTPUT_RATE as u64) as u32 +} + +/// Linear-interpolate a synth voice's phase step between its start/end sweep +/// values across its whole lifetime. i64 math only (b-a can be negative for +/// downward sweeps). +#[inline] +fn lerp_u32(a: u32, b: u32, t: u32, total: u32) -> u32 { + if total == 0 { + return b; + } + let a = a as i64; + let b = b as i64; + let t = t as i64; + let total = total as i64; + (a + (b - a) * t / total) as u32 +} + +/// Linear attack -> full sustain -> linear release envelope, in Q15. `attack` +/// and `release` are clamped into `[0, total]` by the caller (`play_synth`) +/// before this ever runs, so `release_start` never goes negative. +#[inline] +fn envelope_q15(elapsed: u32, total: u32, attack: u32, release: u32) -> i32 { + if attack > 0 && elapsed < attack { + return ((elapsed as i64 * Q15_ONE as i64) / attack as i64) as i32; + } + let release_start = total.saturating_sub(release); + if release > 0 && elapsed >= release_start { + let into = elapsed - release_start; + let remain = release.saturating_sub(into); + return ((remain as i64 * Q15_ONE as i64) / release as i64) as i32; + } + Q15_ONE +} + +// --------------------------------------------------------------------------- +// JS-thread helpers (f32/f64 math — NEVER called from the mixer thread) +// --------------------------------------------------------------------------- + +/// Clamp 0..1 and scale to Q15. `f32::clamp`/comparisons are plain +/// hardware/softfloat ops, not transcendental — no libm needed, and this +/// only ever runs on the JS thread (which has FPU/VFPU access; see host.rs). +fn q15_from_unit(v: f32) -> i32 { + let c = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + (c * Q15_ONE as f32) as i32 +} + +/// Linear (not constant-power) L/R split from a -1..1 pan value — AUDIO.md's +/// mixer entry explicitly allows this ("Linear L/R split is fine"). At +/// pan=0 both channels are full (Q15_ONE); at the extremes one channel goes +/// to 0 while the other stays full. +fn pan_gains(p: f32) -> (i32, i32) { + let p = if p.is_finite() { p.clamp(-1.0, 1.0) } else { 0.0 }; + let l = (1.0 - p).clamp(0.0, 1.0); + let r = (1.0 + p).clamp(0.0, 1.0); + (q15_from_unit(l), q15_from_unit(r)) +} + +fn ms_to_samples(ms: f32) -> u32 { + if !ms.is_finite() || ms <= 0.0 { + return 0; + } + (ms * (OUTPUT_RATE as f32) / 1000.0) as u32 +} + +/// Per-sample phase increment for a `hz` Hz waveform against the full `u32` +/// phase wheel (one cycle = 2^32). f64 (not f32) avoids precision loss at +/// this magnitude; still plain arithmetic, no libm. +fn hz_to_step(hz: f32) -> u32 { + let hz = if hz.is_finite() && hz > 0.0 { hz as f64 } else { 0.0 }; + let v = hz * 4294967296.0 / (OUTPUT_RATE as f64); + if v <= 0.0 { + 0 + } else if v >= 4294967295.0 { + u32::MAX + } else { + v as u64 as u32 + } +} + +// --------------------------------------------------------------------------- +// mixer thread: init + entry +// --------------------------------------------------------------------------- + +/// Reserve the hardware channel and start the mixer thread. Called once from +/// main.rs, after `pak::install` (so `register_sound` has already filled the +/// registry) and before `JS_Eval`. Returns whether audio is actually live; +/// `ffi::register_audio` is called unconditionally regardless of the result +/// (see `READY`'s doc comment) — a boot failure here must not change the +/// guest-visible shape of `globalThis.audio`. +/// +/// # Safety +/// Must be called exactly once, from the boot thread, before any `play_*`/ +/// `register_sound` call and before the mixer thread could possibly exist. +pub unsafe fn init() -> bool { + let ch = sys::sceAudioChReserve(AUDIO_NEXT_CHANNEL, BLOCK as i32, AudioFormat::Stereo); + if ch < 0 { + return false; + } + CHANNEL = ch; + let id = sys::sceKernelCreateThread( + b"pocketjs_audio\0".as_ptr(), + mixer_thread_entry, + 16, // priority: numerically below (PSP: lower = scheduled first) + // the main worker's 32 (host.rs run_on_worker) + 32 * 1024, // 32 KB stack: allocation-free integer mixing needs little + ThreadAttributes::USER, // NO VFPU: the mixer thread never touches f32/f64 + core::ptr::null_mut(), + ); + if id.0 < 0 { + sys::sceAudioChRelease(CHANNEL); + CHANNEL = -1; + return false; + } + sys::sceKernelStartThread(id, 0, core::ptr::null_mut()); + READY.store(true, Ordering::Release); + true +} + +unsafe extern "C" fn mixer_thread_entry(_argc: usize, _argv: *mut c_void) -> i32 { + loop { + mix_block(); + } +} + +/// Drain commands, mix one 1024-frame stereo block, output it. The ENTIRE +/// call tree from here down is allocation-free and integer-only (module +/// docs) — this is the only function the mixer thread ever calls in steady +/// state. +unsafe fn mix_block() { + ring_drain_all(); + for i in 0..BLOCK { + advance_bus_ramps(); + let master = BUS_CUR[0]; + let sfx_bus = BUS_CUR[1]; + let bgm_bus = BUS_CUR[2]; + let mut acc_l: i32 = 0; + let mut acc_r: i32 = 0; + for vi in 0..NUM_VOICES { + let kind = VOICES[vi].kind; + if kind == VKind::Free { + continue; + } + let bus = match kind { + VKind::Sfx | VKind::Synth => sfx_bus, + VKind::Bgm | VKind::BgmDying => bgm_bus, + VKind::Free => unreachable!(), + }; + if let Some((sample, pan_l, pan_r)) = sample_voice(vi, kind) { + let g_l = q15_mul(q15_mul(bus, master), pan_l); + let g_r = q15_mul(q15_mul(bus, master), pan_r); + acc_l += q15_mul(sample, g_l); + acc_r += q15_mul(sample, g_r); + } + } + OUT_BUF[i * 2] = clamp_i16(acc_l); + OUT_BUF[i * 2 + 1] = clamp_i16(acc_r); + } + if CHANNEL >= 0 { + // Hardware volume pinned to max — ALL gain (voice/envelope/fade/bus/ + // pan/master) is already baked into OUT_BUF by the software mix + // above (AUDIO.md mixer entry pseudocode). + sys::sceAudioOutputPannedBlocking( + CHANNEL, + AUDIO_VOLUME_MAX as i32, + AUDIO_VOLUME_MAX as i32, + OUT_BUF.as_mut_ptr() as *mut c_void, + ); + } +} + +unsafe fn advance_bus_ramps() { + for b in 0..3 { + if BUS_REMAINING[b] > 0 { + BUS_CUR[b] = BUS_CUR[b].saturating_add(BUS_STEP[b]); + if BUS_STEP[b] >= 0 { + if BUS_CUR[b] > BUS_TARGET[b] { + BUS_CUR[b] = BUS_TARGET[b]; + } + } else if BUS_CUR[b] < BUS_TARGET[b] { + BUS_CUR[b] = BUS_TARGET[b]; + } + BUS_REMAINING[b] -= 1; + if BUS_REMAINING[b] == 0 { + BUS_CUR[b] = BUS_TARGET[b]; + } + } + } +} + +/// Advance one active voice by one output sample; returns +/// `Some((gain-scaled sample, pan_l_q15, pan_r_q15))`, or `None` if the voice +/// just finished (and was deactivated). +unsafe fn sample_voice(vi: usize, kind: VKind) -> Option<(i32, i32, i32)> { + match kind { + VKind::Free => None, + VKind::Sfx => sample_sfx(vi), + VKind::Bgm | VKind::BgmDying => sample_bgm(vi), + VKind::Synth => sample_synth(vi), + } +} + +unsafe fn sample_sfx(vi: usize) -> Option<(i32, i32, i32)> { + let v = &mut VOICES[vi]; + let idx = v.cursor >> 16; + if idx >= v.frames { + v.kind = VKind::Free; + return None; + } + // SAFETY: `v.ptr` points into an SND entry's PCM region inside APP_PAK's + // .rodata (register_sound's contract); `idx < v.frames` was just + // checked, and `frames` is exactly the SND header's frameCount used to + // bound-check the entry at registration time (pak.rs's register_snd_entry). + let raw = *v.ptr.add(idx as usize) as i32; + v.cursor = v.cursor.wrapping_add(v.step); + let sample = q15_mul(raw, v.gain_cur); // constant for Sfx: no ramp + Some((sample, v.pan_l_q15, v.pan_r_q15)) +} + +unsafe fn sample_bgm(vi: usize) -> Option<(i32, i32, i32)> { + let v = &mut VOICES[vi]; + if v.paused { + // Freeze cursor AND fade: AUDIO.md "voice stays, produces silence". + return Some((0, Q15_ONE, Q15_ONE)); + } + let mut idx = v.cursor >> 16; + if idx >= v.frames { + if !(v.loop_flag && v.frames > v.loop_start) { + v.kind = VKind::Free; + return None; + } + let loop_len = v.frames - v.loop_start; + v.cursor = v.cursor.wrapping_sub(loop_len << 16); + idx = v.cursor >> 16; + if idx >= v.frames { + // Malformed loop metadata (shouldn't happen — pak.rs validates + // loop_start < frames at registration): bail instead of spinning. + v.kind = VKind::Free; + return None; + } + } + // SAFETY: same .rodata contract as sample_sfx; idx bound-checked above. + let raw = *v.ptr.add(idx as usize) as i32; + v.cursor = v.cursor.wrapping_add(v.step); + advance_fade(v); + Some((q15_mul(raw, v.gain_cur), Q15_ONE, Q15_ONE)) // BGM never pans +} + +unsafe fn sample_synth(vi: usize) -> Option<(i32, i32, i32)> { + let v = &mut VOICES[vi]; + if v.elapsed >= v.total_samples { + v.kind = VKind::Free; + return None; + } + let env = envelope_q15(v.elapsed, v.total_samples, v.attack_samples, v.release_samples); + let step = lerp_u32(v.freq_step0, v.freq_step1, v.elapsed, v.total_samples); + let raw = waveform_sample(v); + v.phase = v.phase.wrapping_add(step); + v.elapsed += 1; + let gain = q15_mul(env, v.vol_q15); + Some((q15_mul(raw, gain), Q15_ONE, Q15_ONE)) // playSynth has no pan arg (spec) +} + +/// Generate one raw (pre-gain) sample for the voice's waveform at its +/// current phase; advances the LFSR for Noise (the only waveform with +/// state beyond the shared phase accumulator). +unsafe fn waveform_sample(v: &mut Voice) -> i32 { + const AMP: i32 = i16::MAX as i32; + let frac16 = (v.phase >> 16) as i32; // 0..65535: position within the cycle + match v.wave { + Waveform::Square => { + if frac16 < 32768 { + AMP + } else { + -AMP + } + } + Waveform::Pulse25 => { + if frac16 < 16384 { + AMP + } else { + -AMP + } + } + Waveform::Pulse12 => { + if frac16 < 8192 { + AMP + } else { + -AMP + } + } + Waveform::Triangle => { + if frac16 < 32768 { + -AMP + (frac16 * 2 * AMP) / 32768 + } else { + AMP - ((frac16 - 32768) * 2 * AMP) / 32768 + } + } + Waveform::Saw => -AMP + (frac16 * 2 * AMP) / 65536, + Waveform::Sine => SINE_LUT[(v.phase >> 24) as usize] as i32, + Waveform::Noise => { + // 15-bit Galois LFSR (AUDIO.md / spec task): x = (x>>1) | + // (((x^(x>>1))&1)<<14). Output = current bit 0, THEN advance — + // order doesn't matter perceptually, just needs to be consistent. + let bit = v.lfsr & 1; + let x = v.lfsr; + v.lfsr = (x >> 1) | (((x ^ (x >> 1)) & 1) << 14); + if v.lfsr == 0 { + v.lfsr = 0xACE1; // never let it lock into the all-zero state + } + if bit != 0 { + AMP + } else { + -AMP + } + } + } +} + +/// Advance a BGM/BgmDying voice's linear gain ramp by one sample. Clamps +/// against overshoot (see `fade_step`'s floor-to-+/-1 comment) and, on +/// completion, snaps exactly to `gain_target` and frees the voice if +/// `die_at_fade_end`. +unsafe fn advance_fade(v: &mut Voice) { + if v.fade_remaining > 0 { + v.gain_cur = v.gain_cur.saturating_add(v.gain_step); + if v.gain_step >= 0 { + if v.gain_cur > v.gain_target { + v.gain_cur = v.gain_target; + } + } else if v.gain_cur < v.gain_target { + v.gain_cur = v.gain_target; + } + v.fade_remaining -= 1; + if v.fade_remaining == 0 { + v.gain_cur = v.gain_target; + if v.die_at_fade_end { + v.kind = VKind::Free; + } + } + } +} + +fn next_trigger() -> u32 { + unsafe { + TRIGGER = TRIGGER.wrapping_add(1); + TRIGGER + } +} + +/// Find a free slot in `[start, start+count)`, else steal the oldest (lowest +/// `trigger`) one — AUDIO.md "oldest-of-kind stealing when a kind's slots +/// are full". +unsafe fn alloc_voice(start: usize, count: usize) -> usize { + for i in start..start + count { + if VOICES[i].kind == VKind::Free { + return i; + } + } + let mut oldest = start; + let mut oldest_trigger = VOICES[start].trigger; + for i in start + 1..start + count { + if VOICES[i].trigger < oldest_trigger { + oldest = i; + oldest_trigger = VOICES[i].trigger; + } + } + oldest +} + +// --------------------------------------------------------------------------- +// command application (mixer thread only — called from ring_drain_all) +// --------------------------------------------------------------------------- + +unsafe fn apply_cmd(cmd: Cmd) { + match cmd { + Cmd::None => {} + Cmd::PlaySfx { ptr, frames, rate, vol_q15, pan_l_q15, pan_r_q15 } => { + let slot = alloc_voice(SFX_START, SFX_COUNT); + let v = &mut VOICES[slot]; + *v = Voice::EMPTY; + v.kind = VKind::Sfx; + v.trigger = next_trigger(); + v.ptr = ptr; + v.frames = frames; + v.step = rate_step(rate); + v.gain_cur = vol_q15; // constant, no ramp for one-shot SFX + v.gain_target = vol_q15; + v.pan_l_q15 = pan_l_q15; + v.pan_r_q15 = pan_r_q15; + } + Cmd::PlaySynth { wave, step0, step1, total_samples, attack_samples, release_samples, vol_q15 } => { + let slot = alloc_voice(SYNTH_START, SYNTH_COUNT); + let v = &mut VOICES[slot]; + *v = Voice::EMPTY; + v.kind = VKind::Synth; + v.trigger = next_trigger(); + v.wave = wave; + v.freq_step0 = step0; + v.freq_step1 = step1; + v.total_samples = total_samples; + v.attack_samples = attack_samples; + v.release_samples = release_samples; + v.vol_q15 = vol_q15; + } + Cmd::PlayBgm { ptr, frames, rate, loop_flag, loop_start, fade_samples, vol_q15 } => { + handle_play_bgm(ptr, frames, rate, loop_flag, loop_start, fade_samples, vol_q15); + } + Cmd::StopBgm { fade_samples } => handle_stop_bgm(fade_samples), + Cmd::PauseBgm { paused } => { + if VOICES[BGM_SLOT].kind == VKind::Bgm { + VOICES[BGM_SLOT].paused = paused; + } + // No track playing: idempotent no-op (src/sound.ts already guards + // this JS-side too, but the native op stays safe either way). + } + Cmd::SetBusVolume { bus, vol_q15 } => { + BUS_TARGET[bus] = vol_q15; + BUS_STEP[bus] = fade_step(BUS_CUR[bus], vol_q15, BUS_RAMP_SAMPLES); + BUS_REMAINING[bus] = BUS_RAMP_SAMPLES; + } + } +} + +unsafe fn handle_play_bgm( + ptr: *const i16, + frames: u32, + rate: u32, + loop_flag: bool, + loop_start: u32, + fade_samples: u32, + vol_q15: i32, +) { + let step = rate_step(rate); + let loop_start = if loop_start < frames { loop_start } else { 0 }; + + if VOICES[BGM_SLOT].kind == VKind::Bgm && fade_samples > 0 { + // Cross-fade scheme (module docs): park the outgoing track in a + // borrowed synth slot, fading it to 0; the BGM slot goes straight to + // the new track, fading it in. Both ramps share `fade_samples`. + let dying_slot = alloc_voice(SYNTH_START, SYNTH_COUNT); + let old = VOICES[BGM_SLOT]; + let d = &mut VOICES[dying_slot]; + *d = old; + d.kind = VKind::BgmDying; + d.trigger = next_trigger(); + // Force unpaused: the fade-out must always run to completion and + // free the slot, even if the track being replaced was paused when + // this playBgm arrived (otherwise a paused track could pin a synth + // slot forever — see sample_bgm's full-freeze-while-paused). + d.paused = false; + d.gain_target = 0; + d.fade_remaining = fade_samples; + d.gain_step = fade_step(old.gain_cur, 0, fade_samples); + d.die_at_fade_end = true; + } + + let v = &mut VOICES[BGM_SLOT]; + *v = Voice::EMPTY; + v.kind = VKind::Bgm; + v.trigger = next_trigger(); + v.ptr = ptr; + v.frames = frames; + v.step = step; + v.loop_flag = loop_flag; + v.loop_start = loop_start; + if fade_samples > 0 { + v.gain_cur = 0; + v.gain_target = vol_q15; + v.fade_remaining = fade_samples; + v.gain_step = fade_step(0, vol_q15, fade_samples); + } else { + // fadeMs == 0: instant cut to the new track (web host's "else cuts"). + v.gain_cur = vol_q15; + v.gain_target = vol_q15; + } +} + +unsafe fn handle_stop_bgm(fade_samples: u32) { + let v = &mut VOICES[BGM_SLOT]; + if v.kind != VKind::Bgm { + return; // nothing playing: no-op (src/sound.ts guards this too) + } + if fade_samples == 0 { + v.kind = VKind::Free; // instant cut + release + return; + } + v.gain_target = 0; + v.fade_remaining = fade_samples; + v.gain_step = fade_step(v.gain_cur, 0, fade_samples); + v.die_at_fade_end = true; +} + +// --------------------------------------------------------------------------- +// public ops (JS thread) — called from ffi.rs's js_play_sfx etc. +// --------------------------------------------------------------------------- + +/// spec op 26: one-shot SFX from the pak. Unknown key: silent no-op. +/// +/// # Safety +/// JS-thread-only (matches the rest of this module's single-producer +/// contract for `RING`/`SOUNDS`). +pub unsafe fn play_sfx(key: &str, volume: f32, pan: f32) { + if !READY.load(Ordering::Relaxed) { + return; + } + let Some(reg) = find_sound(SoundKind::Sfx, key) else { + return; + }; + let (pan_l_q15, pan_r_q15) = pan_gains(pan); + ring_push(Cmd::PlaySfx { + ptr: reg.ptr, + frames: reg.frames, + rate: reg.rate, + vol_q15: q15_from_unit(volume), + pan_l_q15, + pan_r_q15, + }); +} + +/// spec op 27: one-shot procedural voice, routed through the sfx bus. +/// +/// # Safety +/// JS-thread-only, see `play_sfx`. +#[allow(clippy::too_many_arguments)] +pub unsafe fn play_synth(wave: i32, freq: f32, freq_end: f32, dur_ms: f32, attack_ms: f32, release_ms: f32, volume: f32) { + if !READY.load(Ordering::Relaxed) { + return; + } + let total = ms_to_samples(dur_ms).max(1); + let mut attack = ms_to_samples(attack_ms); + let mut release = ms_to_samples(release_ms); + // Clamp attack/release into the voice's own lifetime so envelope_q15's + // `total - release` never straddles a caller-supplied value longer than + // the voice itself. + if attack > total { + attack = total; + } + if release > total { + release = total; + } + let wave = Waveform::from_u8(wave.clamp(0, 255) as u8); + ring_push(Cmd::PlaySynth { + wave, + step0: hz_to_step(freq), + step1: hz_to_step(freq_end), + total_samples: total, + attack_samples: attack, + release_samples: release, + vol_q15: q15_from_unit(volume), + }); +} + +/// spec op 28: start/switch the single BGM track. Same-key-while-playing +/// no-op is already handled JS-side (src/sound.ts); this always (re)starts +/// whatever key it's given. +/// +/// # Safety +/// JS-thread-only, see `play_sfx`. +pub unsafe fn play_bgm(key: &str, loop_flag: bool, fade_ms: f32, volume: f32) { + if !READY.load(Ordering::Relaxed) { + return; + } + let Some(reg) = find_sound(SoundKind::Bgm, key) else { + return; + }; + ring_push(Cmd::PlayBgm { + ptr: reg.ptr, + frames: reg.frames, + rate: reg.rate, + loop_flag, + loop_start: reg.loop_start, + fade_samples: ms_to_samples(fade_ms), + vol_q15: q15_from_unit(volume), + }); +} + +/// spec op 29: fade the BGM track to silence and release it. +/// +/// # Safety +/// JS-thread-only, see `play_sfx`. +pub unsafe fn stop_bgm(fade_ms: f32) { + if !READY.load(Ordering::Relaxed) { + return; + } + ring_push(Cmd::StopBgm { fade_samples: ms_to_samples(fade_ms) }); +} + +/// spec op 30: freeze/resume the BGM cursor. Idempotent. +/// +/// # Safety +/// JS-thread-only, see `play_sfx`. +pub unsafe fn pause_bgm(paused: bool) { + if !READY.load(Ordering::Relaxed) { + return; + } + ring_push(Cmd::PauseBgm { paused }); +} + +/// spec op 31: live bus gain. `channel` = ENUMS.AudioChannel ordinal (0 +/// Master, 1 Sfx, 2 Bgm); unknown ordinal: silent no-op. +/// +/// # Safety +/// JS-thread-only, see `play_sfx`. +pub unsafe fn set_bus_volume(channel: i32, volume: f32) { + if !READY.load(Ordering::Relaxed) { + return; + } + let bus = match channel { + 0 | 1 | 2 => channel as usize, + _ => return, + }; + ring_push(Cmd::SetBusVolume { bus, vol_q15: q15_from_unit(volume) }); +} diff --git a/native/src/ffi.rs b/native/src/ffi.rs index ab00ec7..b182e0e 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -529,6 +529,134 @@ unsafe extern "C" fn js_dbg_send( JS_UNDEFINED } +// --------------------------------------------------------------------------- +// Audio surface (globalThis.audio — spec ops 26..31, AUDIO.md; NOT ui.*). +// Marshaling follows the same pattern as the ui.* shims above (JS_ToCStringLen2 +// + JS_FreeCString for strings, arg_i32/arg_f64 for numbers); the actual +// mixer logic lives entirely in audio.rs — these shims only convert JS +// values and forward. +// --------------------------------------------------------------------------- + +/// playSfx(key, volume, pan) — spec op 26. +unsafe extern "C" fn js_play_sfx( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + if argc < 1 { + return JS_UNDEFINED; + } + let mut len: size_t = 0; + let s = JS_ToCStringLen2(ctx, &mut len, *argv.offset(0), 0); + if s.is_null() { + return JS_UNDEFINED; + } + if let Ok(key) = core::str::from_utf8(core::slice::from_raw_parts(s as *const u8, len)) { + let volume = arg_f64(ctx, argc, argv, 1) as f32; + let pan = arg_f64(ctx, argc, argv, 2) as f32; + crate::audio::play_sfx(key, volume, pan); + } + JS_FreeCString(ctx, s); + JS_UNDEFINED +} + +/// playSynth(wave, freq, freqEnd, durMs, attackMs, releaseMs, volume) — spec op 27. +unsafe extern "C" fn js_play_synth( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let wave = arg_i32(ctx, argc, argv, 0); + let freq = arg_f64(ctx, argc, argv, 1) as f32; + let freq_end = arg_f64(ctx, argc, argv, 2) as f32; + let dur_ms = arg_f64(ctx, argc, argv, 3) as f32; + let attack_ms = arg_f64(ctx, argc, argv, 4) as f32; + let release_ms = arg_f64(ctx, argc, argv, 5) as f32; + let volume = arg_f64(ctx, argc, argv, 6) as f32; + crate::audio::play_synth(wave, freq, freq_end, dur_ms, attack_ms, release_ms, volume); + JS_UNDEFINED +} + +/// playBgm(key, loop, fadeMs, volume) — spec op 28. +unsafe extern "C" fn js_play_bgm( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + if argc < 1 { + return JS_UNDEFINED; + } + let mut len: size_t = 0; + let s = JS_ToCStringLen2(ctx, &mut len, *argv.offset(0), 0); + if s.is_null() { + return JS_UNDEFINED; + } + if let Ok(key) = core::str::from_utf8(core::slice::from_raw_parts(s as *const u8, len)) { + let loop_flag = arg_i32(ctx, argc, argv, 1) != 0; + let fade_ms = arg_f64(ctx, argc, argv, 2) as f32; + let volume = arg_f64(ctx, argc, argv, 3) as f32; + crate::audio::play_bgm(key, loop_flag, fade_ms, volume); + } + JS_FreeCString(ctx, s); + JS_UNDEFINED +} + +/// stopBgm(fadeMs) — spec op 29. +unsafe extern "C" fn js_stop_bgm( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let fade_ms = arg_f64(ctx, argc, argv, 0) as f32; + crate::audio::stop_bgm(fade_ms); + JS_UNDEFINED +} + +/// pauseBgm(paused) — spec op 30. +unsafe extern "C" fn js_pause_bgm( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let paused = arg_i32(ctx, argc, argv, 0) != 0; + crate::audio::pause_bgm(paused); + JS_UNDEFINED +} + +/// setChannelVolume(channel, volume) — spec op 31. +unsafe extern "C" fn js_set_channel_volume( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let channel = arg_i32(ctx, argc, argv, 0); + let volume = arg_f64(ctx, argc, argv, 1) as f32; + crate::audio::set_bus_volume(channel, volume); + JS_UNDEFINED +} + +/// Install `globalThis.audio` (AUDIO.md). Always called, even when +/// `audio::init()` failed — every op checks audio.rs's own `READY` flag and +/// silently no-ops, so the guest-visible shape of `globalThis.audio` never +/// changes with hardware availability (AUDIO.md's capability-as-surface +/// contract, same as `ui` never varying its shape). +pub unsafe fn register_audio(ctx: *mut JSContext, global: JSValue) { + let audio_obj = JS_NewObject(ctx); + add_fn(ctx, audio_obj, b"playSfx\0", js_play_sfx, 3); + add_fn(ctx, audio_obj, b"playSynth\0", js_play_synth, 7); + add_fn(ctx, audio_obj, b"playBgm\0", js_play_bgm, 4); + add_fn(ctx, audio_obj, b"stopBgm\0", js_stop_bgm, 1); + add_fn(ctx, audio_obj, b"pauseBgm\0", js_pause_bgm, 1); + add_fn(ctx, audio_obj, b"setChannelVolume\0", js_set_channel_volume, 2); + JS_SetPropertyStr(ctx, global, b"audio\0".as_ptr() as *const _, audio_obj); +} + // --------------------------------------------------------------------------- // registration // --------------------------------------------------------------------------- diff --git a/native/src/lib.rs b/native/src/lib.rs index af651a6..9f316c6 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -22,6 +22,7 @@ extern crate alloc; #[path = "alloc.rs"] mod allocator; pub mod arena; +pub mod audio; pub mod c_heap; pub mod dbg; pub mod ffi; diff --git a/native/src/main.rs b/native/src/main.rs index c4b6e9a..55d6fa7 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -26,7 +26,7 @@ use psp::sys::DisplaySetBufSync; use psp::sys::DisplayPixelFormat; use psp::sys::{self, CtrlMode, GuContextType, GuSyncBehavior, GuSyncMode, IoOpenFlags, SceCtrlData}; -use pocketjs_psp::{dbg, ffi, ge, host, pak}; +use pocketjs_psp::{audio, dbg, ffi, ge, host, pak}; #[cfg(feature = "bench")] use pocketjs_psp::arena; @@ -383,6 +383,19 @@ unsafe fn run() { // below (loadTileTexture pulls tile blobs straight from .rodata). pak::install(APP_PAK); + // ---- Audio mixer (AUDIO.md) ---- + // After pak::feed (above) so the SND sound registry is already filled; + // before JS_Eval so globalThis.audio is ready the instant app code runs. + // A failure here (no hardware channel, thread create failed) is not + // fatal: ffi::register_audio still installs the full op surface below, + // it just silently no-ops forever (audio.rs's READY gate). + trace("run: audio init begin"); + if audio::init() { + trace("run: audio init ok"); + } else { + trace("run: audio init failed (no sound this run)"); + } + // ---- QuickJS ---- trace("run: JS_NewRuntime begin"); let rt = pocketjs_psp::qjs_alloc::new_runtime(); @@ -411,6 +424,11 @@ unsafe fn run() { ffi::register(ctx, global, &textures, &sprites); trace("run: register ui ok"); + // globalThis.audio — separate surface, never part of ui.* (AUDIO.md). + trace("run: register audio begin"); + ffi::register_audio(ctx, global); + trace("run: register audio ok"); + // Expose the asset pack read-only as globalThis.__pak (zero-copy over // .rodata; free_func = None). Web/test hosts feed core through loadStyles/ // loadFontAtlas ops instead — on PSP pak.rs already did it natively. diff --git a/native/src/pak.rs b/native/src/pak.rs index d4495fe..3278f49 100644 --- a/native/src/pak.rs +++ b/native/src/pak.rs @@ -78,7 +78,7 @@ pub struct SpriteReg { pub step: u16, } -pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec) { +pub fn feed(ui: &mut Ui, pak: &'static [u8]) -> (Vec<(String, i32)>, Vec) { let mut textures: Vec<(String, i32)> = Vec::new(); let mut sprites: Vec = Vec::new(); let Some(()) = (|| { @@ -162,12 +162,64 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec) { } else { psp::dprintln!("[PocketJS pak] bad sprite {} ({}x{} psm {})", key, w, h, psm); } + } else if let Some(name) = key.strip_prefix("audio:sfx.") { + // SND one-shot SFX entry (AUDIO.md; audio.rs owns the mixer — + // this crate never plays anything, it only registers the pak + // pointer). `name` is the bare sounds.json name JS calls + // audio.playSfx with, mirroring loadTileTexture's key contract. + register_snd_entry(name, blob, crate::audio::SoundKind::Sfx); + } else if let Some(name) = key.strip_prefix("audio:bgm.") { + register_snd_entry(name, blob, crate::audio::SoundKind::Bgm); } // unknown keys: ignored (forward compatible) } (textures, sprites) } +/// Parse one SND pak entry (AUDIO.md / spec/spec.ts SND_* — 24-byte header + +/// frameCount x s16 LE mono) and register it into audio.rs's sound registry. +/// Called only from `feed`, at boot, before `audio::init()` creates the +/// mixer thread (main.rs's ordering) — same "plain writes are fine" contract +/// as `register_sound`'s own safety doc. Malformed entries (bad magic/ +/// version, truncated PCM, bogus loopStart) are skipped silently, same as +/// every other pak entry kind in this file. +fn register_snd_entry(name: &str, blob: &'static [u8], kind: crate::audio::SoundKind) { + let (Some(magic), Some(version), Some(flags), Some(rate), Some(frames), Some(loop_start)) = ( + rd_u32(blob, 0), + rd_u16(blob, 4), + rd_u16(blob, 6), + rd_u32(blob, 8), + rd_u32(blob, 12), + rd_u32(blob, 16), + ) else { + return; + }; + if magic != crate::audio::SND_MAGIC || version != crate::audio::SND_VERSION { + return; + } + let Some(pcm) = blob.get(crate::audio::SND_HEADER_SIZE..) else { return }; + // frameCount x s16 LE mono must actually fit in what's left of the blob. + if pcm.len() < frames as usize * 2 { + return; + } + // The SND header's own loop flag only gates whether `loopStart` is + // meaningful metadata (an sfx-baked or non-looping entry may have a + // garbage/zero loopStart); whether a given PLAYBACK actually loops is + // entirely the caller's choice each time (AUDIO.md playBgm's `loop` arg), + // so that flag itself isn't stored — see SoundReg's doc comment. + let loop_flag = flags & crate::audio::SND_FLAG_LOOP != 0; + let loop_start = if loop_flag && loop_start < frames { loop_start } else { 0 }; + // SAFETY: `pcm.as_ptr()` points into APP_PAK's .rodata (include_bytes!, + // program-lifetime 'static — `feed`'s `pak: &'static [u8]` parameter + // carries that lifetime through to `blob`/`pcm`). Alignment: PAK blobs + // are 16-byte aligned (spec.ts PAK_ALIGN) and SND_HEADER_SIZE (24) is + // even, so `pcm.as_ptr()` lands on an even address — valid for the + // `*const i16` register_sound stores and the mixer thread later reads. + unsafe { + crate::audio::register_sound(kind, name, pcm.as_ptr() as *const i16, frames, rate, loop_start); + } +} + /// Look up one entry's blob by exact key (the runtime side of the streaming /// ops — e.g. loadTileTexture's `ui:tile.` keys, which `feed` skips as /// unknown). Same bounds-checked walk as `feed`: malformed packs and misses From 235b10fa18ab42ec6229c2ddf5749c1d74bd381e Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 11:01:53 +0800 Subject: [PATCH 7/9] fix(audio): widen PCM cursor to u64 and make fade ramps click-free --- native/src/audio.rs | 49 +++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/native/src/audio.rs b/native/src/audio.rs index b10dbc3..3871ad1 100644 --- a/native/src/audio.rs +++ b/native/src/audio.rs @@ -382,7 +382,10 @@ struct Voice { ptr: *const i16, frames: u32, /// 16.16 fixed-point index into the source PCM (integer part = sample). - cursor: u32, + /// u64, NOT u32: a u32's integer part caps at 65535 frames (~6 s of + /// 11025 Hz source), which real BGM clips exceed — the cursor would wrap + /// and the end/loop checks would never fire. + cursor: u64, /// 16.16 per-output-sample cursor increment = `source_rate * 65536 / 44100`. step: u32, loop_flag: bool, @@ -494,23 +497,25 @@ fn clamp_i16(v: i32) -> i16 { } } -/// Per-sample ramp step from `from` to `to` over `samples` samples. Floors -/// to +/-1 when integer division truncates a nonzero difference to 0 (a huge -/// `samples` count with a tiny gain delta) so the ramp is still monotonic and -/// visibly moving rather than appearing frozen; `advance_fade`/ -/// `advance_bus_ramps` clamp against overshoot, and both always snap exactly -/// to `to` when their remaining-count reaches 0, so this floor never causes -/// the final value to be wrong — only (rarely) to arrive a few samples early. +/// Per-sample ramp step from `from` to `to` over `samples` samples, rounded +/// AWAY from zero. Truncating toward zero would leave the residual as a snap +/// at the ramp's final sample (e.g. a full-scale 300 ms fade @ 44100 Hz +/// truncates 2.47 to 2, leaving a ~19% gain jump at the end — an audible +/// click); rounding away from zero makes the ramp land on the target +/// slightly EARLY instead, where `advance_fade`/`advance_bus_ramps` clamp +/// it — monotonic, click-free, and the fade is at most a fraction shorter. +/// Also subsumes the tiny-delta case (a nonzero diff never yields step 0). #[inline] fn fade_step(from: i32, to: i32, samples: u32) -> i32 { if samples == 0 { return 0; } let diff = to as i64 - from as i64; - let mut step = diff / samples as i64; - if step == 0 && diff != 0 { - step = if diff > 0 { 1 } else { -1 }; + if diff == 0 { + return 0; } + let s = samples as i64; + let step = if diff > 0 { (diff + s - 1) / s } else { (diff - s + 1) / s }; step as i32 } @@ -725,7 +730,7 @@ unsafe fn sample_voice(vi: usize, kind: VKind) -> Option<(i32, i32, i32)> { unsafe fn sample_sfx(vi: usize) -> Option<(i32, i32, i32)> { let v = &mut VOICES[vi]; let idx = v.cursor >> 16; - if idx >= v.frames { + if idx >= v.frames as u64 { v.kind = VKind::Free; return None; } @@ -734,7 +739,7 @@ unsafe fn sample_sfx(vi: usize) -> Option<(i32, i32, i32)> { // checked, and `frames` is exactly the SND header's frameCount used to // bound-check the entry at registration time (pak.rs's register_snd_entry). let raw = *v.ptr.add(idx as usize) as i32; - v.cursor = v.cursor.wrapping_add(v.step); + v.cursor += v.step as u64; let sample = q15_mul(raw, v.gain_cur); // constant for Sfx: no ramp Some((sample, v.pan_l_q15, v.pan_r_q15)) } @@ -746,15 +751,15 @@ unsafe fn sample_bgm(vi: usize) -> Option<(i32, i32, i32)> { return Some((0, Q15_ONE, Q15_ONE)); } let mut idx = v.cursor >> 16; - if idx >= v.frames { + if idx >= v.frames as u64 { if !(v.loop_flag && v.frames > v.loop_start) { v.kind = VKind::Free; return None; } - let loop_len = v.frames - v.loop_start; - v.cursor = v.cursor.wrapping_sub(loop_len << 16); + let loop_len = (v.frames - v.loop_start) as u64; + v.cursor -= loop_len << 16; // u64: no overflow even for long clips idx = v.cursor >> 16; - if idx >= v.frames { + if idx >= v.frames as u64 { // Malformed loop metadata (shouldn't happen — pak.rs validates // loop_start < frames at registration): bail instead of spinning. v.kind = VKind::Free; @@ -763,7 +768,7 @@ unsafe fn sample_bgm(vi: usize) -> Option<(i32, i32, i32)> { } // SAFETY: same .rodata contract as sample_sfx; idx bound-checked above. let raw = *v.ptr.add(idx as usize) as i32; - v.cursor = v.cursor.wrapping_add(v.step); + v.cursor += v.step as u64; advance_fade(v); Some((q15_mul(raw, v.gain_cur), Q15_ONE, Q15_ONE)) // BGM never pans } @@ -840,8 +845,8 @@ unsafe fn waveform_sample(v: &mut Voice) -> i32 { } /// Advance a BGM/BgmDying voice's linear gain ramp by one sample. Clamps -/// against overshoot (see `fade_step`'s floor-to-+/-1 comment) and, on -/// completion, snaps exactly to `gain_target` and frees the voice if +/// against overshoot (see `fade_step`'s round-away-from-zero comment) and, +/// on completion, snaps exactly to `gain_target` and frees the voice if /// `die_at_fade_end`. unsafe fn advance_fade(v: &mut Voice) { if v.fade_remaining > 0 { @@ -1007,6 +1012,10 @@ unsafe fn handle_stop_bgm(fade_samples: u32) { v.kind = VKind::Free; // instant cut + release return; } + // Force unpaused: a paused voice freezes its fade ramp (sample_bgm), so + // stopping a paused track with a fade would otherwise leave a zombie + // voice holding the BGM slot forever instead of fading out and freeing. + v.paused = false; v.gain_target = 0; v.fade_remaining = fade_samples; v.gain_step = fade_step(v.gain_cur, 0, fade_samples); From 6ce821cddef78e9ece30106c17454f3cb39541dc Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 11:02:27 +0800 Subject: [PATCH 8/9] docs(audio): pin loopStart units to the baked sample rate --- AUDIO.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AUDIO.md b/AUDIO.md index 086f4d3..ce6b74f 100644 --- a/AUDIO.md +++ b/AUDIO.md @@ -113,8 +113,10 @@ optional `/sounds.json` (same pattern as `sprites.json`): Files resolve against ``, `assets/sounds/`, then `assets/`. Each entry is decoded (RIFF/WAVE, PCM 8/16-bit), downmixed to mono, resampled to `rate` (default 22050 Hz), and packed as an SND pak entry under -`audio:sfx.` or (with `"bgm": true`) `audio:bgm.`. Old hosts skip -unknown `audio:` keys — forward compatible. +`audio:sfx.` or (with `"bgm": true`) `audio:bgm.`. `loopStart` +is a sample index **in the baked entry's units** — i.e. at the manifest's +`rate`, after resampling, not in the source file's. Old hosts skip unknown +`audio:` keys — forward compatible. SND entry layout (constants in `spec/spec.ts`): From be30c5316a76ec351a79593501841f2c2d1cb0d7 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Sun, 12 Jul 2026 11:14:43 +0800 Subject: [PATCH 9/9] feat(audio): wire the music and settings demos with baked chiptune BGM --- demos/music/app.tsx | 60 ++++++- demos/music/gen-sounds.ts | 224 +++++++++++++++++++++++++ demos/music/sounds.json | 6 + demos/music/sounds/click.wav | Bin 0 -> 816 bytes demos/music/sounds/glass-horizon.wav | Bin 0 -> 122156 bytes demos/music/sounds/midnight-replay.wav | Bin 0 -> 132332 bytes demos/music/sounds/static-bloom.wav | Bin 0 -> 99308 bytes demos/settings/app.tsx | 10 +- test/sim.test.ts | 41 ++++- 9 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 demos/music/gen-sounds.ts create mode 100644 demos/music/sounds.json create mode 100644 demos/music/sounds/click.wav create mode 100644 demos/music/sounds/glass-horizon.wav create mode 100644 demos/music/sounds/midnight-replay.wav create mode 100644 demos/music/sounds/static-bloom.wav diff --git a/demos/music/app.tsx b/demos/music/app.tsx index 7bb352e..0b2df11 100644 --- a/demos/music/app.tsx +++ b/demos/music/app.tsx @@ -15,12 +15,15 @@ import { createSignal } from "solid-js"; import { Text, View } from "@pocketjs/framework/components"; import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { BTN } from "@pocketjs/framework/input"; +import { defineSfx, pauseBgm, playBgm, playSfx, resumeBgm } from "@pocketjs/framework/audio"; interface Track { title: string; artist: string; /** cover/play-pause control: FULL literal (fixed size + per-track accent). */ coverCls: string; + /** audio:bgm. key baked by sounds.json (AUDIO.md) — playBgm target. */ + sound: string; } const TRACKS: Track[] = [ @@ -29,24 +32,32 @@ const TRACKS: Track[] = [ artist: "SYNC PULSE", coverCls: "w-16 h-16 rounded-xl shadow-md items-center justify-center bg-gradient-to-b from-blue-500 to-blue-700 border-blue-300 focus:border-slate-900 transition-colors duration-150", + sound: "midnight-replay", }, { title: "GLASS HORIZON", artist: "AMBER TIDE", coverCls: "w-16 h-16 rounded-xl shadow-md items-center justify-center bg-gradient-to-b from-amber-400 to-amber-700 border-amber-300 focus:border-slate-900 transition-colors duration-150", + sound: "glass-horizon", }, { title: "STATIC BLOOM", artist: "NEON DRIFTERS", coverCls: "w-16 h-16 rounded-xl shadow-md items-center justify-center bg-gradient-to-b from-cyan-500 to-cyan-700 border-cyan-300 focus:border-slate-900 transition-colors duration-150", + sound: "static-bloom", }, ]; const TRACK_FRAMES = 300; // 5s per track at 60 Hz (demo-length, not the real song) const PROGRESS_TRACK_W = 160; // progress track px — matches the w-[160] track class +// Code-defined retro blip (AUDIO.md) — zero pak cost, used for d-pad/button +// feedback below. A separate baked "click" SFX also ships via sounds.json for +// pak-bake coverage, but interactive feedback uses this synth voice instead. +defineSfx("blip", { wave: "square", freq: 880, durMs: 40, releaseMs: 20 }); + // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -57,24 +68,57 @@ export default function Music() { const [position, setPosition] = createSignal(0); // frames into the current track const [barsFrame, setBarsFrame] = createSignal(0); + // Tracks whether playBgm() has ever been called (a plain closure flag, not + // reactive state — it doesn't drive rendering, only which audio op the + // play/pause toggle issues below). + let bgmStarted = false; + const selectTrack = (i: number) => { setTrackIndex(i); setPosition(0); setPlaying(true); + playBgm(TRACKS[i].sound, { loop: true, fadeMs: 300 }); + bgmStarted = true; + playSfx("blip"); }; - const nextTrack = () => { - setTrackIndex((trackIndex() + 1) % TRACKS.length); + // Switch to track `i` WITHOUT changing the play/pause state: the mock has + // always kept "switching while paused stays paused", so the new track is + // loaded and immediately re-frozen when the UI shows paused. + const switchTrack = (i: number) => { + setTrackIndex(i); setPosition(0); + playBgm(TRACKS[i].sound, { loop: true, fadeMs: 300 }); + bgmStarted = true; + if (!playing()) pauseBgm(); }; - const prevTrack = () => { - setTrackIndex((trackIndex() - 1 + TRACKS.length) % TRACKS.length); - setPosition(0); + const nextTrack = () => switchTrack((trackIndex() + 1) % TRACKS.length); + const prevTrack = () => switchTrack((trackIndex() - 1 + TRACKS.length) % TRACKS.length); + + const togglePlay = () => { + const next = !playing(); + setPlaying(next); + if (next) { + if (bgmStarted) resumeBgm(); + else { + playBgm(TRACKS[trackIndex()].sound, { loop: true, fadeMs: 300 }); + bgmStarted = true; + } + } else { + pauseBgm(); + } + playSfx("blip"); }; - onButtonPress(BTN.LTRIGGER, prevTrack); - onButtonPress(BTN.RTRIGGER, nextTrack); + onButtonPress(BTN.LTRIGGER, () => { + prevTrack(); + playSfx("blip"); + }); + onButtonPress(BTN.RTRIGGER, () => { + nextTrack(); + playSfx("blip"); + }); onFrame(() => { if (!playing()) return; setBarsFrame(barsFrame() + 1); @@ -103,7 +147,7 @@ export default function Music() { - setPlaying(!playing())}> + {playing() ? ">" : "II"} diff --git a/demos/music/gen-sounds.ts b/demos/music/gen-sounds.ts new file mode 100644 index 0000000..2b18530 --- /dev/null +++ b/demos/music/gen-sounds.ts @@ -0,0 +1,224 @@ +// demos/music/gen-sounds.ts — synthesize the "Now Playing" demo's chiptune +// BGM loops and UI blip as committed WAV assets for sounds.json (AUDIO.md +// "Assets -- sounds.json -> SND pak entries"). +// +// bun demos/music/gen-sounds.ts +// +// Offline baker (run MANUALLY, like demos/zoomlab/gen-assets.ts) with NO +// external inputs, no Math.random and no wall clock: every track is a +// monophonic note sequence rendered straight into 16-bit PCM mono samples +// (square/triangle oscillators, a short linear attack/release envelope per +// note) so a re-run is byte-identical. Each track is built from an integer +// number of integer-length notes and its first sample (attack ratio 0/N = 0) +// and last sample (forced to 0) are both exact silence, so `loop: true` +// playback has no click at the seam — sample-accurate loop closure without +// needing to search for a zero crossing. +// +// Outputs are COMMITTED (the bake is deterministic; a re-run is byte-identical): +// demos/music/sounds/midnight-replay.wav BGM for "MIDNIGHT REPLAY" (A minor arpeggio, square, ~120 BPM) +// demos/music/sounds/glass-horizon.wav BGM for "GLASS HORIZON" (C major arpeggio, triangle, ~130 BPM) +// demos/music/sounds/static-bloom.wav BGM for "STATIC BLOOM" (pentatonic run, square+triangle alternating, ~160 BPM) +// demos/music/sounds/click.wav tiny UI blip SFX (baked for sounds.json/pak +// coverage — the demo's interactive feedback +// instead uses a defineSfx-registered synth +// blip, zero pak cost; see app.tsx) +// demos/music/sounds.json maps all four into the pak as audio:bgm. / +// audio:sfx.click (scripts/build.ts bakes them at build time; nothing here is +// read at runtime). + +import { mkdirSync } from "node:fs"; + +const HERE = new URL(".", import.meta.url).pathname; // demos/music/ +const SOUNDS_DIR = HERE + "sounds/"; +const RATE = 11025; // AUDIO.md budget: 11025 Hz halves the 22050 default + +// --------------------------------------------------------------------------- +// Oscillators — phase in [0, 1), output in [-1, 1]. Pure functions of phase, +// no state, no randomness. +// --------------------------------------------------------------------------- + +type Wave = "square" | "triangle"; + +function oscillator(wave: Wave, phase: number): number { + if (wave === "square") return phase < 0.5 ? 1 : -1; + // Continuous triangle: -1 at phase 0, +1 at phase 0.5, -1 at phase 1. + return phase < 0.5 ? -1 + 4 * phase : 3 - 4 * phase; +} + +function toInt16(samples: Float32Array): Int16Array { + const out = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const v = Math.max(-1, Math.min(1, samples[i])); + out[i] = Math.round(v * 32767); + } + return out; +} + +// --------------------------------------------------------------------------- +// WAV encoder (RIFF/WAVE, PCM=1, mono, 16-bit) — matches compiler/pak.ts's +// decodeWav exactly: "RIFF" + size, "WAVE", a 16-byte "fmt " chunk, then a +// "data" chunk immediately after (word-aligned; data length here is always +// even so no pad byte is needed). +// --------------------------------------------------------------------------- + +function encodeWav(samples: Int16Array, rate: number): Uint8Array { + const dataLen = samples.length * 2; + const buf = new Uint8Array(44 + dataLen); + const dv = new DataView(buf.buffer); + const writeStr = (o: number, s: string) => { + for (let i = 0; i < s.length; i++) buf[o + i] = s.charCodeAt(i); + }; + writeStr(0, "RIFF"); + dv.setUint32(4, 36 + dataLen, true); + writeStr(8, "WAVE"); + writeStr(12, "fmt "); + dv.setUint32(16, 16, true); // fmt chunk size + dv.setUint16(20, 1, true); // format = PCM + dv.setUint16(22, 1, true); // channels = mono + dv.setUint32(24, rate, true); + dv.setUint32(28, rate * 2, true); // byte rate = rate * blockAlign + dv.setUint16(32, 2, true); // block align (2 bytes/frame, mono 16-bit) + dv.setUint16(34, 16, true); // bits per sample + writeStr(36, "data"); + dv.setUint32(40, dataLen, true); + for (let i = 0; i < samples.length; i++) dv.setInt16(44 + i * 2, samples[i], true); + return buf; +} + +// --------------------------------------------------------------------------- +// Note-sequence renderer — every note gets its own short linear +// attack/release envelope (click-free note-to-note transitions); the whole +// buffer's first and last samples are forced to exact 0 for a clean loop seam. +// --------------------------------------------------------------------------- + +interface TrackSpec { + file: string; + title: string; + /** Scale degrees in Hz, indexed by `pattern`. */ + scale: number[]; + /** Sequence of indices into `scale`, one per note; tiled across the buffer once. */ + pattern: number[]; + /** Seconds per note (drives both tempo and note length). */ + noteSec: number; + /** Waveform per note, cycled if shorter than `pattern`. */ + waves: Wave[]; + /** Peak amplitude, 0..1. */ + amp: number; +} + +function renderTrack(spec: TrackSpec): Int16Array { + const noteSamples = Math.round(RATE * spec.noteSec); + const envSamples = Math.min(Math.round(RATE * 0.008), Math.floor(noteSamples / 4)); // ~8ms attack+release + const total = noteSamples * spec.pattern.length; + const buf = new Float32Array(total); + for (let n = 0; n < spec.pattern.length; n++) { + const freq = spec.scale[spec.pattern[n]]; + const wave = spec.waves[n % spec.waves.length]; + const offset = n * noteSamples; + for (let i = 0; i < noteSamples; i++) { + const phase = ((freq * i) / RATE) % 1; + let env = 1; + if (i < envSamples) env = i / envSamples; + else if (i >= noteSamples - envSamples) env = (noteSamples - i) / envSamples; + buf[offset + i] = oscillator(wave, phase) * env * spec.amp; + } + } + buf[0] = 0; // exact silence at the loop start (attack ratio 0/N is already ~0; make it exact) + buf[total - 1] = 0; // exact silence at the loop end -> click-free seam + return toInt16(buf); +} + +function renderClick(): Int16Array { + const samples = Math.round(RATE * 0.035); // ~35ms blip + const attackSamples = Math.round(RATE * 0.003); + const releaseSamples = Math.round(RATE * 0.02); + const freq = 1400; + const buf = new Float32Array(samples); + for (let i = 0; i < samples; i++) { + const phase = ((freq * i) / RATE) % 1; + let env = 1; + if (i < attackSamples) env = i / attackSamples; + else if (i >= samples - releaseSamples) env = Math.max(0, (samples - i) / releaseSamples); + buf[i] = oscillator("square", phase) * env * 0.5; + } + buf[0] = 0; + buf[samples - 1] = 0; + return toInt16(buf); +} + +// --------------------------------------------------------------------------- +// Track table — matches the 3 covers in demos/music/app.tsx (title/artist +// order and accent colors: blue "MIDNIGHT REPLAY", amber "GLASS HORIZON", +// cyan "STATIC BLOOM"); tempo/scale/waveform deliberately differ per track so +// they read as distinct loops. +// --------------------------------------------------------------------------- + +// A natural minor, one octave (A3..A4). +const A_MINOR = [220.0, 246.94, 261.63, 293.66, 329.63, 349.23, 392.0, 440.0]; +// C major, one octave (C4..C5). +const C_MAJOR = [261.63, 293.66, 329.63, 349.23, 392.0, 440.0, 493.88, 523.25]; +// C major pentatonic, one octave + a high C (C4..C5..C6). +const C_PENTATONIC = [261.63, 293.66, 329.63, 392.0, 440.0, 523.25]; + +const TRACKS: TrackSpec[] = [ + { + file: "midnight-replay.wav", + title: "MIDNIGHT REPLAY — A minor arpeggio, square, ~120 BPM eighths", + scale: A_MINOR, + // 24 eighth-notes @ 120 BPM (noteSec 0.25s) = 6.0s + pattern: [0, 2, 4, 7, 6, 4, 2, 0, 1, 3, 5, 7, 6, 5, 3, 1, 0, 2, 4, 6, 7, 6, 4, 2], + noteSec: 0.25, + waves: ["square"], + amp: 0.35, + }, + { + file: "glass-horizon.wav", + title: "GLASS HORIZON — C major arpeggio, triangle, ~130 BPM eighths", + scale: C_MAJOR, + // 24 eighth-notes @ 130 BPM (noteSec ~0.2308s) = ~5.54s + pattern: [0, 2, 4, 7, 6, 4, 2, 0, 1, 3, 5, 7, 6, 5, 3, 1, 2, 4, 6, 7, 6, 4, 2, 0], + noteSec: 60 / 130 / 2, + waves: ["triangle"], + amp: 0.4, + }, + { + file: "static-bloom.wav", + title: "STATIC BLOOM — C major pentatonic run, square+triangle alternating, ~160 BPM sixteenths", + scale: C_PENTATONIC, + // 48 sixteenth-notes @ 160 BPM (noteSec ~0.09375s) = 4.5s + pattern: [ + 0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 0, 2, 4, 5, 4, 2, + 0, 1, 3, 5, 4, 3, 1, 0, 2, 4, + ], + noteSec: 60 / 160 / 4, + waves: ["square", "triangle"], + amp: 0.3, + }, +]; + +// --------------------------------------------------------------------------- +// Bake +// --------------------------------------------------------------------------- + +mkdirSync(SOUNDS_DIR, { recursive: true }); + +let totalBytes = 0; +for (const spec of TRACKS) { + const pcm = renderTrack(spec); + const wav = encodeWav(pcm, RATE); + await Bun.write(SOUNDS_DIR + spec.file, wav); + totalBytes += wav.length; + console.log( + ` bgm: ${spec.file} <- ${spec.title} (${(pcm.length / RATE).toFixed(2)}s, ${(wav.length / 1024).toFixed(1)} KB)`, + ); +} + +const clickPcm = renderClick(); +const clickWav = encodeWav(clickPcm, RATE); +await Bun.write(SOUNDS_DIR + "click.wav", clickWav); +totalBytes += clickWav.length; +console.log(` sfx: click.wav (${((clickPcm.length / RATE) * 1000).toFixed(0)}ms, ${(clickWav.length / 1024).toFixed(1)} KB)`); + +console.log( + `gen-sounds: wrote ${TRACKS.length + 1} wav file(s), ${(totalBytes / 1024).toFixed(1)} KB total -> ${SOUNDS_DIR}`, +); diff --git a/demos/music/sounds.json b/demos/music/sounds.json new file mode 100644 index 0000000..d977e12 --- /dev/null +++ b/demos/music/sounds.json @@ -0,0 +1,6 @@ +{ + "sounds/midnight-replay.wav": { "name": "midnight-replay", "bgm": true, "loop": true, "loopStart": 0, "rate": 11025 }, + "sounds/glass-horizon.wav": { "name": "glass-horizon", "bgm": true, "loop": true, "loopStart": 0, "rate": 11025 }, + "sounds/static-bloom.wav": { "name": "static-bloom", "bgm": true, "loop": true, "loopStart": 0, "rate": 11025 }, + "sounds/click.wav": { "name": "click" } +} diff --git a/demos/music/sounds/click.wav b/demos/music/sounds/click.wav new file mode 100644 index 0000000000000000000000000000000000000000..e32b4bd3b1a66572acfe3f282a3e1d88e3af11a9 GIT binary patch literal 816 zcmWIYbaT^SW?%?+40BD(Em06)U|?VbLP2c?2Bk0t1||jphLpsTL>8bL28Iue51B8r z+WqkR7W*alQwx8IV5)Ga=&l#1p51)%@)3vp4}}*>H&x7Ux?K;untHiguf`z9DAr{2 z$wS949esG1-;Tin1Q`!NAcRLOpET8s2dJx_x~4H6*lM3`uV{bWuK3{IgY1Wb4yD*i z*PmnOcb+i$AvN7?0&4iwX-p3z-vXCpMn=ePXv+p_#hb zUDMoC+fOl__BowpqF{2(xW{OR()sk7L@)@oUZ7r`1hXs z{fYN)-@h$CQQl4dkDTAb84n*l)Opk;YcKm%X0^=x$1finKdF6UDg91riBy!-ho@H0 zTArPI_FQ7FM6d*>gyW0u7gt}%znmuKBgQ1QLv+%sJFnDU_lvlS{1)CQJoC-tH@a`j z-`WX%5nLshAo%K?$@{wZC*Qy2U(6rL&(ClDvGwEmk5ZrJ@CNa6@b2U3`F!oO;+O0% z-rS7b+qrVN?tWGOR`hMpw_h9^IMO&|IP`v0{y6-D_vcEscs5bC)2#KsPW=-39rrte ig^%S3a~1Q&Khl3w{{}L#Gwo$8X1xAS>3`0D1_l5e2}9!m literal 0 HcmV?d00001 diff --git a/demos/music/sounds/glass-horizon.wav b/demos/music/sounds/glass-horizon.wav new file mode 100644 index 0000000000000000000000000000000000000000..c41fe0cdefda045c5510c744cdf1c33117842d30 GIT binary patch literal 122156 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 z

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

j+haWkcu2 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 F{{wY5SL*-( literal 0 HcmV?d00001 diff --git a/demos/music/sounds/static-bloom.wav b/demos/music/sounds/static-bloom.wav new file mode 100644 index 0000000000000000000000000000000000000000..b774bf2176d5d0572248cf05b519b90caad18d3c GIT binary patch literal 99308 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"indigo"); const currentTheme = () => themeByName(theme()); + const toggleSfx = () => { + const on = !sfx(); + setSfx(on); + setVolume("sfx", on ? 1 : 0); + playSynth({ wave: "square", freq: on ? 880 : 440, durMs: 40 }); + }; + return ( @@ -288,7 +296,7 @@ export default function Settings() { - setSfx(!sfx())} /> + setVibration(!vibration())} /> diff --git a/test/sim.test.ts b/test/sim.test.ts index 961c8f4..c4f0c4f 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -19,7 +19,7 @@ // and the settled final screen is byte-equal across rates. import { describe, expect, test } from "bun:test"; -import { runScenario, treeHasText, type Trace } from "../host-sim/sim.ts"; +import { runScenario, treeHasText, type AudioEvent, type Trace } from "../host-sim/sim.ts"; import { BTN } from "../spec/spec.ts"; // The user journey, in virtual seconds (one script drives every rate; the @@ -56,16 +56,45 @@ describe("determinism", () => { }); test("the audio command stream is deterministic too (AUDIO.md, chaos-equal)", async () => { - // cafe-main doesn't wire up audio yet (demo audio wiring lands later), so - // both arrays are empty today — but this is the harness that will catch - // any drift once a demo starts calling audio.*: same tape, same ops, same - // frames, chaos or clean. JSON.stringify keeps the comparison trivial to - // extend with real expected events later. + // cafe-main never calls audio.* — its recorded stream is (and must stay) + // empty; the real-events case is the music-main journey below. expect(Array.isArray(t60.audio)).toBe(true); const chaos = await runScenario(scenario(60), { maxSleepMs: 8, gcEvery: 60 }); expect(JSON.stringify(chaos.audio)).toBe(JSON.stringify(t60.audio)); }); + test("music journey: audio ops land on exact frames, chaos or clean (AUDIO.md)", async () => { + // R/L trigger track switches in demos/music — global button handlers, no + // focus dependence. Every op below is presentation-only (Trace.audio); + // the pixel trace itself must not know audio exists. + const music = { + app: "music-main", + hz: 60, + seconds: 3, + script: [ + { at: 1.0, press: BTN.RTRIGGER }, // next track + { at: 2.0, press: BTN.LTRIGGER }, // back again + ], + }; + const clean = await runScenario(music); + const want: AudioEvent[] = [ + // render() boot: initAudio pushes all three bus gains (src/sound.ts). + { t: "audio", frame: 0, op: "setChannelVolume", args: [0, 1] }, + { t: "audio", frame: 0, op: "setChannelVolume", args: [1, 1] }, + { t: "audio", frame: 0, op: "setChannelVolume", args: [2, 1] }, + // RTRIGGER: switchTrack -> playBgm + the defineSfx("blip") synth blip. + { t: "audio", frame: 60, op: "playBgm", args: ["glass-horizon", 1, 300, 1] }, + { t: "audio", frame: 60, op: "playSynth", args: [0, 880, 880, 40, 0, 20, 1] }, + // LTRIGGER: back to track 0. + { t: "audio", frame: 120, op: "playBgm", args: ["midnight-replay", 1, 300, 1] }, + { t: "audio", frame: 120, op: "playSynth", args: [0, 880, 880, 40, 0, 20, 1] }, + ]; + expect(clean.audio).toEqual(want); + const chaos = await runScenario(music, { maxSleepMs: 8, gcEvery: 60 }); + expect(chaos.audio).toEqual(want); + expect(chaos.hashes).toEqual(clean.hashes); + }); + test("the low-rate worlds are deterministic too", async () => { expect((await runScenario(scenario(4))).hashes).toEqual(t4.hashes); expect((await runScenario(scenario(2))).hashes).toEqual(t2.hashes);