diff --git a/AUDIO.md b/AUDIO.md new file mode 100644 index 0000000..ce6b74f --- /dev/null +++ b/AUDIO.md @@ -0,0 +1,170 @@ +# 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.`. `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`): + +| 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 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/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/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 0000000..e32b4bd Binary files /dev/null and b/demos/music/sounds/click.wav differ diff --git a/demos/music/sounds/glass-horizon.wav b/demos/music/sounds/glass-horizon.wav new file mode 100644 index 0000000..c41fe0c Binary files /dev/null and b/demos/music/sounds/glass-horizon.wav differ diff --git a/demos/music/sounds/midnight-replay.wav b/demos/music/sounds/midnight-replay.wav new file mode 100644 index 0000000..37a8d8f Binary files /dev/null and b/demos/music/sounds/midnight-replay.wav differ diff --git a/demos/music/sounds/static-bloom.wav b/demos/music/sounds/static-bloom.wav new file mode 100644 index 0000000..b774bf2 Binary files /dev/null and b/demos/music/sounds/static-bloom.wav differ diff --git a/demos/settings/app.tsx b/demos/settings/app.tsx index a8f0769..ebd0cb6 100644 --- a/demos/settings/app.tsx +++ b/demos/settings/app.tsx @@ -13,6 +13,7 @@ import { createEffect, createSignal, Show } from "solid-js"; import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { animate } from "@pocketjs/framework/animation"; +import { playSynth, setVolume } from "@pocketjs/framework/audio"; type ThemeName = "indigo" | "emerald" | "amber" | "rose"; @@ -277,6 +278,13 @@ export default function Settings() { const [theme, setTheme] = createSignal("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/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/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(); diff --git a/native/src/audio.rs b/native/src/audio.rs new file mode 100644 index 0000000..3871ad1 --- /dev/null +++ b/native/src/audio.rs @@ -0,0 +1,1145 @@ +//! 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). + /// 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, + 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, 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; + 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 +} + +/// 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 as u64 { + 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.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)) +} + +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 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) as u64; + v.cursor -= loop_len << 16; // u64: no overflow even for long clips + idx = v.cursor >> 16; + 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; + 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.step as u64; + 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 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 { + 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; + } + // 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); + 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 diff --git a/package.json b/package.json index f1f9cb7..ee06355 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 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/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 + }); +}); diff --git a/test/sim.test.ts b/test/sim.test.ts index 8fdd28b..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 @@ -55,6 +55,46 @@ describe("determinism", () => { expect(chaos.effects).toEqual(t60.effects); }); + test("the audio command stream is deterministic too (AUDIO.md, chaos-equal)", async () => { + // 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); 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); + }); +});