diff --git a/.github/workflows/web-playwright.yml b/.github/workflows/web-playwright.yml new file mode 100644 index 00000000..2e30cb85 --- /dev/null +++ b/.github/workflows/web-playwright.yml @@ -0,0 +1,96 @@ +name: Web Playwright E2E + +# Runs the browser end-to-end suite (including the ToneGraph smoke test that +# proves at least one file-backed recipe is discoverable in the web bundle). +# +# The web server is started by Playwright itself (`node dist-server/index.js`); +# file-backed recipes are inlined into the client bundle at build time by the +# Vite `yamlRecipePlugin` + `import.meta.glob` in +# `web/src/browser-recipe-registry.ts`, so no separate static-asset copy step +# is required. +# +# Work item: TF-0MOKJJN42008FARS. + +on: + push: + branches: + - dev + - main + paths: + - "web/**" + - "presets/**" + - "src/**" + - "package.json" + - "package-lock.json" + - ".github/workflows/web-playwright.yml" + pull_request: + paths: + - "web/**" + - "presets/**" + - "src/**" + - "package.json" + - "package-lock.json" + - ".github/workflows/web-playwright.yml" + workflow_dispatch: + +# Cancel superseded runs for the same ref to keep CI minutes down. +concurrency: + group: web-playwright-${{ github.ref }} + cancel-in-progress: true + +jobs: + web-e2e: + name: Web Playwright E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install web dependencies + run: npm ci --prefix web + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium firefox + working-directory: web + + - name: Build web demo + run: npm run build + working-directory: web + + - name: Run Playwright e2e + run: npx playwright test + working-directory: web + env: + CI: "true" + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report + retention-days: 14 + if-no-files-found: ignore + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: web/test-results + retention-days: 14 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 29e5f0dd..45838d24 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,14 @@ bin/ # Node / TypeScript ################################## node_modules/ +# Also ignore a symlink named exactly `node_modules` (no trailing slash): +# `phase_start` links the main checkout's dependency tree into each worktree, +# and a trailing-slash pattern does NOT match a symlink. Without this rule +# `git add -A` stages the symlink and pushes a self-referential link to dev, +# breaking every fresh checkout (TF-0MUUEZE48002CLBX). +node_modules +.implement_state.json +.port-skips dist/ dist-server/ *.tsbuildinfo diff --git a/.toneforge/mixer/rules.json b/.toneforge/mixer/rules.json new file mode 100644 index 00000000..c1f7d427 --- /dev/null +++ b/.toneforge/mixer/rules.json @@ -0,0 +1,34 @@ +{ + "version": "1.0", + "defaults": { + "maxVoices": 4, + "duckDepthDb": 6, + "uiDuckDurationMs": 200 + }, + "groups": { + "dialogue": { "priority": 100 }, + "alerts": { "priority": 90 }, + "ui": { "priority": 80 }, + "combat": { "priority": 60 }, + "footsteps": { "priority": 40 }, + "ambience": { "priority": 20 } + }, + "rules": [ + { + "id": "combat-focus", + "when": { "state": "combat" }, + "then": { + "duck": ["ambience"], + "boost": ["combat"], + "limit": ["ui"] + } + }, + { + "id": "ui-clarity", + "when": { "state": "ui" }, + "then": { + "duck": ["ambience", "footsteps"] + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 412d0f16..03d09fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v0.1.2 (2026-10-06) +> **Release focus:** This release focuses on making the sound design system more flexible and reliable, letting you shape sounds with interactive controls, save and reuse custom recipes without rebuilding the game, and add recipes from any file location. It also adds smooth pitch sweeps for effects, network playtesting, and preset browsing, alongside fixes for audio glitches, session saving, and automated tests. +### Features +- Shape sounds with sliders, dials, and touchpads, then hear changes instantly. (TF-0MUTZ4TOY003DJ45) +- Sound effects can now sweep smoothly up or down in pitch, like laser zaps and explosions. (TF-0MUV8YKFR002BIA2) +- You can now save custom recipes that persist across sessions and use them in any future run. (TF-0MUVC3DZ10089SAU) +- You can now add custom sound recipes from any file location without editing the game's code. (TF-0MUUC9RTU008UJ18) +- Recipes can now be added or tweaked without rebuilding the game. (TF-0MMZIW9NQ09MV98G) +- Improved session saving reliability so your progress is preserved more consistently. (TF-0MN1QCTMC07ATYMS) +- Easily browse available sequence and stack presets right from the command line. (TF-0MMSXILP20HEKXT2) +- Playtest the game from other devices on your network. (TF-0MUVUWN0A006EEBT) +- You can now reuse layered sound stacks inside sequences without rebuilding them. (TF-0MMSYF0G20PY7GMB) +### Bug Fixes +- Fixed browser audio rendering and terminal command issues so tutorial tests pass reliably. (TF-0MUVBX24I000ST03) +- Fixed an occasional audio glitch that could cause sound effects to play incorrectly. (TF-0MUURP3KD00724BM) +- Fixed a setup issue that was preventing the game's automated tests from running. (TF-0MUUELBUD002OEQ8) +- Sound controls now show labels and respond to click-and-drag. (TF-0MUVMITO30059E7U) +- All sound parameters now actually change the audio when adjusted. (TF-0MUV9U9QU001SORH) +### Other +- ToneForge audio is now properly licensed, ensuring reliable sound in the game. (TF-0MUTVQSI6003VPAD) +- Recipes now produce different results each time you play. (TF-0MN1TS9661DEIXH0) +- Removed an unused audio library, making the game download smaller and faster to install. (TF-0MUUYLVHZ002FRYE) + ## v0.1.1 (2026-10-04) ### Features - Generate a wider variety of game sounds, like lasers, footsteps, chimes, and wind. (TF-0MLXF74KE17SQKAD) diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f010a1a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ToneForge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d14ce7b8..373cf6e0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ToneForge lets developers generate placeholder sounds from recipes and seeds dur ## What It Does -- **Generate** -- Procedural and sample-hybrid sound synthesis via Tone.js +- **Generate** -- Procedural and sample-hybrid sound synthesis via the Web Audio API (browser-native, with optional offline rendering in Node.js) - **Compose** -- Layer multiple sounds into stacks with per-layer gain, pan, and sample-accurate timing - **Sequence** -- Schedule sounds into temporal patterns with state-driven transitions and probabilistic variation - **Analyze** -- Extract audio features (envelope, spectral, loudness, transient) in batch @@ -59,6 +59,22 @@ Starts the backend server and Vite dev server: npm run dev:web ``` +### Browser support + +ToneForge is Runtime-aware: the Runtime, renderer, and recipe registry run in +the browser using the native Web Audio API. `node-web-audio-api` is an optional +dependency used only for offline rendering in Node.js and is never bundled into +browser builds. See [Using ToneForge in the Browser](docs/browser-usage.md) for +installation, the Runtime API, and recipe-rendering examples. + +### Web demo — CI-safe CLI in the PTY + +The web demo spawns a terminal PTY for the frontend to execute CLI commands. +To make `toneforge` and `tf` resolvable inside that PTY without a global +`npm link`, the server prepends a shim directory (`bin/tf-shim/`) to the +PTY `PATH` at startup. The shim scripts delegate to `bin/dev-cli.js`, so the +commands work in CI and on any machine. + ### Troubleshooting In CI, the postinstall step skips linking automatically. If linking is unavailable locally (permissions/restricted environments), you'll see a non-failing message and can use the loader script directly: @@ -72,8 +88,8 @@ The `docs/prd/` directory contains detailed product requirements documents for p ## Planned Tech Stack - JavaScript / TypeScript -- [Tone.js](https://tonejs.github.io/) (Web Audio API) -- Offline rendering via `Tone.Offline` +- Native [Web Audio API](https://developer.mozilla.org/docs/Web/API/Web_Audio_API) in the browser +- Offline rendering via `OfflineAudioContext` (browser-native, or `node-web-audio-api` in Node.js) - WAV export - JSON configuration for presets, stacks, sequences, and library entries - npm distribution @@ -111,9 +127,12 @@ toneforge library add --input sound.wav --tags "weapon,laser" - [System Architecture PRD](docs/prd/PRD.md) - [Core Module PRD](docs/prd/CORE_PRD.md) +- [ToneGraph v0.1 Specification](docs/tonegraph.md) +- [Browser Runtime Usage](docs/browser-usage.md) +- [Mixer Rules](docs/mixer-rules.md) - [All Module PRDs](docs/prd/) - [Research Questions](docs/prd/BRAINSTORM_QUESTIONS.md) ## License -TBD +ToneForge is released under the [MIT License](LICENSE). diff --git a/bin/tf-shim/tf b/bin/tf-shim/tf new file mode 100755 index 00000000..b8e0d7fa --- /dev/null +++ b/bin/tf-shim/tf @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# CI-safe tf CLI shim. +# Locates the project root relative to this script and delegates to +# bin/dev-cli.js, preserving all arguments. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +exec node "$PROJECT_ROOT/bin/dev-cli.js" "$@" diff --git a/bin/tf-shim/toneforge b/bin/tf-shim/toneforge new file mode 100755 index 00000000..47ee9032 --- /dev/null +++ b/bin/tf-shim/toneforge @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# CI-safe toneforge CLI shim. +# Locates the project root relative to this script and delegates to +# bin/dev-cli.js, preserving all arguments. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +exec node "$PROJECT_ROOT/bin/dev-cli.js" "$@" diff --git a/docs/browser-usage.md b/docs/browser-usage.md new file mode 100644 index 00000000..735bb065 --- /dev/null +++ b/docs/browser-usage.md @@ -0,0 +1,230 @@ +# Browser Runtime Usage + +ToneForge is **Runtime-aware**: the Runtime engine, the recipe registry, and +the offline renderer run unchanged in a browser using the **native Web Audio +API**. Web Audio is provided by the browser itself — no polyfill is required, +and the Node-only `node-web-audio-api` package is never loaded in the browser. + +`node-web-audio-api` is an **optional dependency** (see `package.json`). It is +only needed when you render audio offline in Node.js (the CLI and the Node test +suite). Browser builds omit it automatically: the cross-platform abstraction in +`src/audio/web-audio.ts` loads it lazily, only when `isNodeRuntime()` is true. + +## Prerequisites + +- A modern browser with the Web Audio API (Chromium, Firefox, Safari, Edge). +- For the Node.js path only: Node.js >= 22.3 (the abstraction resolves + `node:module` via `process.getBuiltinModule`). +- No Web Audio polyfill. Do not bundle `node-web-audio-api` for the browser. + +## Installation + +```bash +npm install toneforge +``` + +In a bundler (Vite, webpack, Next.js, Rollup) the abstraction is +tree-shakeable and browser-safe — the Node-only dependency is resolved lazily +inside a Node-guarded code path, so it never appears in the browser bundle. + +## Cross-runtime audio abstraction + +`src/audio/web-audio.ts` exposes the only audio entry points you need. Import +from it instead of importing `node-web-audio-api` directly: + +| Export | Kind | Purpose | +|---|---|---| +| `OfflineAudioContext` | constructor | Offline rendering — native Web Audio in the browser, `node-web-audio-api` in Node.js | +| `AudioContext` | constructor | Real-time playback (browser-native; `node-web-audio-api` in Node.js) | +| `getAudioContext()` | function | Convenience factory returning a real-time `AudioContext` | +| `getOfflineAudioContextCtor()` | function | Resolve the active `OfflineAudioContext` constructor | +| `isNodeRuntime()` | function | `true` under Node.js, `false` in the browser | + +```ts +import { + OfflineAudioContext, + getAudioContext, + isNodeRuntime, +} from "@toneforge/audio/web-audio.js"; + +if (!isNodeRuntime()) { + const ctx = getAudioContext(); + await ctx.resume(); // browsers require a user gesture before playback +} +``` + +> In the in-repo web demo the `@toneforge/*` alias maps to `src/*` (see +> `web/vite.config.ts`). Published packages resolve the same modules from the +> compiled `dist/` output. + +## Runtime API + +The Runtime is the deterministic playback engine that ties together State, +Context, and Sequencer. See +[docs/prd/RUNTIME_PRD.md](prd/RUNTIME_PRD.md) for the full specification. + +### `createRuntime(options?): Runtime` + +| Option | Type | Default | Purpose | +|---|---|---|---| +| `seed` | `number` | `42` | Base seed for deterministic event generation | +| `stateMachine` | `StateMachine` | — | Attach a state machine for state-driven sequences | +| `context` | `Context` | — | Attach a context for environment-driven recipe resolution | +| `sequences` | `Record` | `{}` | State/sequencer-name to sequence definition | +| `clock` | `() => number` | `Date.now` | Clock used for event timestamps | +| `recipeResolver` | `(event, context) => string` | identity | Map an event + context to a recipe name | +| `maxLogEntries` | `number` | `1000` | In-memory event-log retention | + +### `Runtime` interface + +| Method | Returns | Description | +|---|---|---| +| `start()` | `string` | Start a session; returns the session id | +| `stop()` | `void` | Stop the current session | +| `isRunning()` | `boolean` | Whether a session is active | +| `setState(name)` | `TransitionRecord` | Transition the attached state machine | +| `setContext(updates)` | `ContextChangeRecord[]` | Update context dimensions | +| `inspect()` | `RuntimeInspection` | Snapshot of state, context, active sequences, event count | +| `log(limit?)` | `readonly RuntimeLogEntry[]` | Inspect the event log | +| `onEvent(listener)` | `() => void` | Subscribe to events; returns an unsubscribe | +| `sessionId()` | `string \| null` | Current session id | +| `simulateActive()` | `SimulationResult \| null` | Simulate the active sequence | +| `reset()` | `void` | Clear state, context, and logs | + +### Example: a state-driven runtime in the browser + +```ts +import { createRuntime } from "@toneforge/runtime/index.js"; +import { createStateMachine } from "@toneforge/state/state.js"; +import { createContext } from "@toneforge/context/context.js"; +import { parseSequencePreset } from "@toneforge/sequence/schema.js"; + +const stateMachine = createStateMachine({ + name: "movement", + initial: "idle", + states: [ + { name: "idle" }, + { name: "walk", sequencer: "footsteps_walk" }, + ], + transitions: [ + { from: "idle", to: "walk" }, + { from: "walk", to: "idle" }, + ], +}); + +const context = createContext({ + dimensions: { surface: ["stone", "gravel"] }, + initial: { surface: "stone" }, +}); + +const footstepsWalk = parseSequencePreset( + { + version: "1.0", + name: "footsteps_walk", + events: [ + { time: 0, event: "footstep", seedOffset: 0, gain: 0.7 }, + { time: 0.6, event: "footstep", seedOffset: 1, gain: 0.65 }, + ], + }, + "footsteps_walk", +); + +const runtime = createRuntime({ + seed: 42, + stateMachine, + context, + sequences: { footsteps_walk: footstepsWalk }, + // Context-driven recipe switching: "footstep" + surface:"gravel" + // resolves to "footstep-gravel". + recipeResolver: (event, ctx) => + ctx.surface ? `${event}-${ctx.surface}` : event, +}); + +runtime.onEvent((entry) => { + // entry.event.type, entry.event.detail, entry.event.timestamp +}); + +runtime.start(); +runtime.setState("walk"); +runtime.setContext({ surface: "gravel" }); +runtime.stop(); +``` + +## Recipe rendering in the browser + +`renderRecipe(recipeName, seed, duration?)` from `src/core/renderer.ts` uses the +cross-platform `OfflineAudioContext`, so the same call works in Node.js and the +browser: + +```ts +import { renderRecipe } from "@toneforge/core/renderer.js"; + +const result = await renderRecipe("footstep-stone", 42); +// result.samples: Float32Array +// result.sampleRate: 44100 +// result.duration: seconds +// result.numberOfChannels: 1 +``` + +To play the rendered buffer in the browser: + +```ts +import { getAudioContext } from "@toneforge/audio/web-audio.js"; + +const result = await renderRecipe("footstep-stone", 42); +const ctx = getAudioContext(); +await ctx.resume(); + +const buffer = ctx.createBuffer(1, result.samples.length, result.sampleRate); +buffer.copyToChannel(result.samples, 0); +const source = ctx.createBufferSource(); +source.buffer = buffer; +source.connect(ctx.destination); +source.start(0); +``` + +> Recipes are deterministic: the same recipe + seed produces identical samples +> on every platform. File-backed ToneGraph recipes are discovered on disk and +> are therefore only registered in Node.js; synchronously-registered built-in +> recipes and recipe modules you import yourself are available in the browser. + +## Web demo + +The `web/` directory contains a browser demo that renders and plays recipes +using the browser-native Web Audio API: + +```bash +npm run dev:web +``` + +The demo runs the Terminal UI and wizard; when a `generate --recipe +--seed ` command completes, the wizard renders the recipe offline in the +browser and plays the result. Browser playback is implemented in +`web/src/audio.ts`. + +## Tests + +The browser behaviour is covered by Playwright end-to-end tests in +`web/e2e/`: + +```bash +npm run test:e2e:ci --prefix web # builds the web demo and runs Playwright +``` + +`web/e2e/runtime-recipes.spec.ts` runs against Chromium and Firefox and verifies +Runtime start/stop, state transitions, context changes, event-log determinism, +and non-silent deterministic recipe rendering. + +`web/e2e/tonegraph-smoke.spec.ts` proves that at least one file-backed +ToneGraph recipe (`ui-scifi-confirm`) is discoverable in the browser bundle, +renders a non-zero buffer, and produces no Node-only console errors. + +The Playwright web server binds the first free port at or after 3000 so the +suite still runs when the default port is occupied (for example by another +dev server). Set `PORT=` to pin a specific port. + +The `Web Playwright E2E` workflow (`.github/workflows/web-playwright.yml`) runs +the suite on pushes and pull requests that touch `web/`, `presets/`, or `src/`. +On CI the config retries failures twice and writes an HTML report plus failure +traces, which the workflow uploads as `playwright-report` and +`playwright-test-results` artifacts. diff --git a/docs/guides/enemy-gym-sound-editor.md b/docs/guides/enemy-gym-sound-editor.md new file mode 100644 index 00000000..a5bb7367 --- /dev/null +++ b/docs/guides/enemy-gym-sound-editor.md @@ -0,0 +1,127 @@ +--- +title: "Embedding the Sound Editor in AI_Hell's Enemy Gym" +id: enemy-gym-sound-editor +order: 20 +description: > + How to embed the framework-agnostic ToneForge SoundEditor into a host view + (AI_Hell Enemy Gym) via the plain-DOM adapter, hand a SoundPreset back to the + host, theme it, and meet the accessibility contract. The AI_Hell migration is + tracked separately (AH-0MUTUOB7X007PR9J); this guide documents the pattern. +--- + +## Overview + +The ToneForge SoundEditor is a single-sound, framework-agnostic editor. It +mounts into any DOM container, renders one control per declared recipe +parameter, auditions the sound, and exports WAV — without adopting ToneForge's +build stack and without host-side framework changes. + +The Enemy Gym panel in AI_Hell is the reference host. **This guide documents the +embedding pattern only**; migrating AI_Hell's procedural SFX to ToneForge is +tracked in `AH-0MUTUOB7X007PR9J`. + +## Quick start (plain-DOM adapter) + +```ts +import { mountEnemyGymEditor } from "@toneforge-web/components/SoundEditor/adapters/enemy-gym.js"; + +const handle = mountEnemyGymEditor(panelElement, { + preset: { version: 1, recipe: "weapon-laser-zap", seed: 1234, overrides: {} }, + label: `${enemy.name} sound`, + onChange: (preset) => { + // Persist the sound alongside the enemy definition. + enemy.sound = preset; + }, +}); + +// when the enemy panel is removed: +handle.setPreset(otherPreset); // switch enemy +handle.dispose(); // release DOM + audio +``` + +The adapter wraps `createSoundEditor()` and returns `{ dispose, getPreset, +setPreset, exportWav }`. See +[`web/src/components/SoundEditor/README.md`](../../web/src/components/SoundEditor/README.md) +for the full API. + +## Mount / dispose API + +| Member | Contract | +| --- | --- | +| `mount(container)` | Appends exactly one scoped root element (shadow DOM). Never constructs audio. | +| `dispose()` | Removes DOM, disconnects observers, disposes the audio engine; idempotent. | +| `onChange(listener)` | Fires once per committed edit with the current `SoundPreset`; returns an unsubscribe. | +| `getPreset()` / `setPreset(p)` | Read / load the preset. | +| `exportWav(preset?)` | Renders offline and returns WAV bytes; `createWavDownload` builds a Blob + download. | + +## Handing `SoundPreset` to the host + +```ts +interface SoundPreset { + version: number; // schema version (currently 1) + recipe: string; // registered recipe name + seed: number; // deterministic seed + overrides: Record; // edited parameter overrides +} +``` + +Persist the JSON as-is (see the `SoundPreset` model docs). Reloading a preset +reproduces the same sound: the seed determines the baseline and the overrides +are re-applied deterministically. + +## AI_Hell parameter mapping (recipe ↔ perceived traits) + +Use this table to pick a starting recipe per perceived sound need, then tune the +seed/overrides in the editor: + +| AI_Hell sound need | Starting recipe | Perceived traits | +| --- | --- | --- | +| Enemy laser / ranged attack | `weapon-laser-zap` | bright, zappy, aggressive, short | +| Enemy impact / hit | `impact-crack` | sharp transient, brittle, percussive | +| Enemy defeat sting | `card-defeat-sting` | descending, final, tonal | +| Arena ambience | `ambient-wind-gust` | airy, evolving, filtered noise | +| UI / notification confirm | `ui-scifi-confirm` | tonal, positive, short | +| Footstep on stone | `footstep-stone` | percussive, filtered noise, tactile | + +The mapping is intentionally a starting point: any registered recipe can be +loaded and its declared parameters are exposed as controls automatically. + +## Theming + +Theme via CSS custom properties on (or above) the host container: + +```css +.enemy-gym-panel { + --tfe-background: #101418; + --tfe-foreground: #f5f5f5; + --tfe-accent: #ff9f1c; + --tfe-font: system-ui, sans-serif; +} +``` + +## Accessibility contract + +- **Labelled controls** — every control is named from its recipe parameter; the + editor region is a labelled `role="group"`. +- **Keyboard operation** — sliders/dials: Arrow keys, PageUp/PageDown, Home/End; + XY pad: per-axis arrows/Home/End; toggle/select: arrows, Home/End, + Enter/Space; audition buttons are native buttons. +- **Ranges & units** — `aria-valuemin`/`aria-valuemax`/`aria-valuenow` plus a + unit-bearing `aria-valuetext`. +- **Roles** — `role="slider"`, `role="listbox"`/`role="option"`, and a + `role="group"` XY pad; the audition status uses `aria-live="polite"`. +- **Focus order** — DOM order: recipe controls (in descriptor order) then + audition controls. + +## Autoplay & graceful degradation + +Browsers block audio until a user gesture. The editor shows an **Enable audio** +button until the first gesture and creates no `AudioContext` before it. If Web +Audio is unavailable, the audition control disables itself with an accessible +message; editing and WAV export (offline render) continue to work. + +## Scope boundary + +The editor is **single-sound only**. Stacking, sequencing and mixing UIs are out +of scope; requests for those belong to separate work items so the boundary is +maintained. diff --git a/docs/guides/external-recipes.md b/docs/guides/external-recipes.md new file mode 100644 index 00000000..3c1a1b9e --- /dev/null +++ b/docs/guides/external-recipes.md @@ -0,0 +1,105 @@ +--- +title: "External Recipes: Registering ToneGraph Recipes from Outside the Repo" +id: external-recipes +order: 30 +description: > + Register ToneGraph recipe files that live outside the ToneForge repository so + a separate `tf generate` process discovers them. Covers `tf library add`, + `--destination`, the `TONEFORGE_RECIPE_DIR` environment variable, path + resolution, idempotency, validation and listing. +--- + +## Overview + +By default, `tf generate --recipe ` resolves recipes from two sources: + +1. the built-in TypeScript recipes in `src/recipes/`, and +2. file-backed ToneGraph recipes in the repository's `presets/recipes/` + directory. + +`tf library add` extends this with an **external recipe directory** so tools and +build pipelines can register recipes without editing the ToneForge source tree. +The recipe is copied into the external directory, and every later `tf` process +discovers it alongside the baked-in recipes. + +## Registering a recipe + +```bash +# Persist to the default external directory (~/.toneforge/recipes/) +tf library add --file ./game-weapon.yaml + +# Render it in a separate process +tf generate --recipe game-weapon --seed 42 --output ./game-weapon.wav +``` + +The recipe name is derived from the file name without its extension +(`game-weapon.yaml` → `game-weapon`). Use `--name ` to override it; the +persisted file is then written as `.` so the separate process finds +it under the same name. + +Inline and stdin inputs are persisted as YAML: + +```bash +tf library add --inline "$(cat game-weapon.yaml)" --name game-weapon +tf library add --stdin --name game-weapon < game-weapon.yaml +``` + +## Choosing the destination directory + +The destination is resolved in this order: + +1. `--destination ` — single-operation override. +2. `TONEFORGE_RECIPE_DIR` — environment variable. +3. `~/.toneforge/recipes/` — platform-agnostic default (uses the OS home + directory). + +```bash +tf library add --file ./game-weapon.yaml --destination ./project-recipes + +TONEFORGE_RECIPE_DIR=./project-recipes tf library add --file ./game-weapon.yaml +``` + +### Path resolution + +Both relative and absolute paths are supported for `--file`, `--destination` +and `TONEFORGE_RECIPE_DIR`: + +- **Relative paths** are resolved against the current working directory. +- **Absolute paths** are used as-is. + +`~` is **not** expanded by ToneForge; the shell expands it before the CLI runs. +The default (`~/.toneforge/recipes/`) is expanded programmatically using the OS +home directory, so it works on every platform. + +## Idempotency and validation + +- Re-registering the same recipe overwrites the persisted file and re-registers + the recipe in-process — it never errors and never creates duplicates. The + write is atomic (temp file + rename), so a concurrent `tf generate` never + reads a partial file. +- Invalid recipe files (non-ToneGraph, schema-violating, or corrupt) are + rejected with a clear, non-zero-exit error message. Validation happens + **before** anything is written, so the external directory is left unchanged. +- Recipe names that contain path separators (e.g. `../escape`) are rejected so + persistence can never write outside the destination directory. + +## Listing externally registered recipes + +`tf library list` lists the recipe catalogue (built-in, baked-in and external) +with each recipe's source directory and kind: + +```bash +tf library list +tf library list --json +``` + +In JSON mode, the `recipes` array entries carry `source` (the directory) and +`external` (boolean). `externalRecipeCount` reports how many recipes came from +the external directory. + +## How discovery works + +`initializeRecipeRegistry()` scans the baked-in `presets/recipes/` directory +first and then the external directory. A recipe in the external directory with +the same name as a baked-in recipe **overrides** it. Discovery is a no-op in +browser runtimes; external directories are Node-only. diff --git a/docs/guides/gamedev-workflow-example.md b/docs/guides/gamedev-workflow-example.md index 7874e4bb..196a6016 100644 --- a/docs/guides/gamedev-workflow-example.md +++ b/docs/guides/gamedev-workflow-example.md @@ -140,6 +140,36 @@ toneforge show card-coin-collect --seed 42 ## Step 3 — Inspect Existing Presets +Before diving into a specific preset, discover what sequences and stacks ship +with ToneForge. The `list` command scans the `presets/sequences/` and +`presets/stacks/` directories and prints each preset's name and description: + +```bash +# List every sequence preset +toneforge list sequences + +# List every stack preset +toneforge list stacks +``` + +Narrow the list with a case-insensitive keyword search over names and +descriptions: + +```bash +# Find sequence presets related to card handling +toneforge list sequences --search card + +# Find stack presets for victory moments +toneforge list stacks --search victory +``` + +For scripting or piping into build tools, request JSON output: + +```bash +toneforge list sequences --json +toneforge list stacks --search victory --json +``` + Two relevant presets ship with ToneForge that you will use as worked examples. ### Sequence preset: `tableau_play_card` @@ -175,6 +205,124 @@ This stack layers: > small offsets) into one mixed buffer. Use sequences for events that unfold over > time; use stacks for layered single-moment impacts. +### Including a stack inside a sequence (recipe wrapper) + +Sequence events resolve **recipes only** — `renderSequence()` looks up a +registered recipe and calls `renderRecipe()`. Stack presets are rendered by a +separate `renderStack()` path, so you cannot reference a stack preset directly +from a sequence event. To reuse a stack inside a timeline, wrap it as a thin +**recipe wrapper**: + +1. **Implement the wrapper.** A factory builds a `RecipeRegistration` whose + `buildOfflineGraph()` derives a numeric seed from the recipe `Rng`, calls + `renderStack()`, and copies the resulting samples into the caller's + `OfflineAudioContext` as a buffer source. Its `getDuration()` mirrors the + stack's natural duration from the same seed derivative so `renderRecipe()` + does not truncate the wrapped audio. + + ```ts + // src/recipes/stack-wrapper.ts (excerpt) + export function createStackWrapperRecipe(options: StackWrapperOptions): RecipeRegistration { + const stack = options.stack; + const params = options.params; // e.g. [{ name: "gain", min: 0.2, max: 1.0, unit: "amplitude" }] + return { + getDuration(rng, overrides) { + const { seed } = deriveInputs(rng, params, overrides); + return computeStackDuration(stack, seed); + }, + async buildOfflineGraph(rng, ctx, duration, overrides) { + const { seed, params: derived } = deriveInputs(rng, params, overrides); + const gain = derived["gain"] ?? 1.0; + const result = await renderStack(stack, seed); + + const audioBuffer = ctx.createBuffer( + result.numberOfChannels, result.samples.length, result.sampleRate, + ); + for (let ch = 0; ch < result.numberOfChannels; ch++) { + const data = audioBuffer.getChannelData(ch); + for (let i = 0; i < result.samples.length; i++) { + data[i] = result.samples[i] * gain; + } + } + + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(ctx.destination); + source.start(0); + source.stop(duration); + }, + // ...description, category, tags, signalChain, params, getParams + }; + } + ``` + + `deriveInputs()` takes the stack seed from the **first** `rng()` call, so + `getDuration()` and `buildOfflineGraph()` agree for the same event seed. + +2. **Register it.** In `src/recipes/index.ts`, after the shared `registry` is + created, register the wrapper with an in-memory `StackDefinition` (so the + browser build never touches `node:fs`): + + ```ts + const cardPlayLandingStack: StackDefinition = { + name: "card_play_landing", + layers: [ + { recipe: "card-slide", startTime: 0, gain: 1.0 }, + { recipe: "card-place", startTime: 0.08, gain: 0.9 }, + { recipe: "card-glow", startTime: 0.18, gain: 0.55 }, + ], + }; + + registry.register("stack-card-play-landing", createStackWrapperRecipe({ + name: "stack-card-play-landing", + stack: cardPlayLandingStack, + description: "Wraps the card_play_landing stack preset as a recipe.", + category: "Card Game", + tags: ["card", "stack", "wrapper", "landing"], + signalChain: "Stack(card_play_landing): card-slide -> card-place -> card-glow", + params: [ + { name: "gain", min: 0.2, max: 1.0, unit: "amplitude" }, + ], + })); + ``` + +3. **Reference it from a sequence preset.** The `event` field names the wrapper + recipe exactly as registered. The shipped example + `presets/sequences/tableau_play_card_with_landing.json` mixes a plain + `card-slide` lead-in with the wrapped stack: + + ```json + { + "version": "1.0", + "name": "tableau_play_card_with_landing", + "events": [ + { "time": 0, "event": "card-slide", "seedOffset": 0, "gain": 0.7 }, + { "time": 0.15, "event": "stack-card-play-landing", "seedOffset": 1, "gain": 1.0 } + ] + } + ``` + +4. **Preview the composed sequence.** Omit `--output` to play it through your + speakers: + + ```bash + toneforge sequence generate --preset presets/sequences/tableau_play_card_with_landing.json --seed 42 + ``` + + Inspect and simulate the timeline first if you want to verify event timing: + + ```bash + toneforge sequence inspect --preset presets/sequences/tableau_play_card_with_landing.json + toneforge sequence simulate --preset presets/sequences/tableau_play_card_with_landing.json --seed 42 + ``` + +> **Why a wrapper instead of a native stack event?** The sequence schema +documents `event` as resolving "recipe, stack preset, or library entry", but +only recipes are resolved today. The wrapper keeps the stack reusable without +re-authoring its layers, and the pattern is covered by automated tests in +`src/recipes/stack-wrapper.test.ts`. Native stack events in the sequence schema +are a future enhancement. + --- ## Step 4 — Iterate and Choose Seeds @@ -462,6 +610,8 @@ headlessly and is safe to use in CI. |---|---|---| | Discover | `toneforge list recipes --category card-game` | List all 34 card-game recipes | | Search | `toneforge list recipes --search coin` | Keyword search across all fields | +| List sequences | `toneforge list sequences [--search ]` | Discover sequence presets by name/description | +| List stacks | `toneforge list stacks [--search ]` | Discover stack presets by name/description | | Preview | `toneforge generate --recipe --seed ` | Play through speakers instantly | | Inspect | `toneforge show --seed ` | View metadata and parameter values | | Inspect preset | `toneforge sequence inspect --preset ` | View sequence structure | diff --git a/docs/mixer-rules.md b/docs/mixer-rules.md new file mode 100644 index 00000000..e21fb64a --- /dev/null +++ b/docs/mixer-rules.md @@ -0,0 +1,195 @@ +# Mixer Rules + +ToneForge's Mixer applies **declarative, deterministic, auditable** mix rules +to avoid an unusable mix when many sounds play at once. This document defines +the rule schema, the built-in defaults, and the loader that reads rules from +`.toneforge/mixer/*.json`. + +See [`docs/prd/MIXER_PRD.md`](prd/MIXER_PRD.md) for the product-level design. + +## Mix groups + +Mix groups are intent-based semantic categories (not channel strips). The six +confirmed groups are: + +| Group | Default priority | +|---|---| +| `dialogue` | 100 | +| `alerts` | 90 | +| `ui` | 80 | +| `combat` | 60 | +| `footsteps` | 40 | +| `ambience` | 20 | + +Group names are case-insensitive and normalised to lowercase. + +## Defaults + +| Default | Value | Meaning | +|---|---|---| +| `maxVoices` | 4 | Concurrent voices allowed per group | +| `duckDepthDb` | 6 | Attenuation applied by a duck action (positive magnitude, e.g. `6` = −6 dB) | +| `uiDuckDurationMs` | 200 | Duck duration applied to UI-triggered rules | + +## Rule schema + +Each file under `.toneforge/mixer/` is a JSON rule set: + +```json +{ + "version": "1.0", + "defaults": { "maxVoices": 4, "duckDepthDb": 6, "uiDuckDurationMs": 200 }, + "groups": { + "combat": { "priority": 60, "maxVoices": 4 } + }, + "rules": [ + { + "id": "combat-focus", + "when": { "state": "combat" }, + "then": { + "duck": ["ambience"], + "boost": ["combat"], + "limit": ["ui"] + } + } + ] +} +``` + +### `when` — triggers + +At least one of `state` / `context` is required: + +- `state` — a state name (e.g. `"combat"`). +- `context` — a map of context dimensions that must all match (e.g. + `{ "surface": "metal" }`). + +### `then` — actions + +At least one of `duck` / `boost` / `limit` is required. Every target may be a +group-name string (using the built-in defaults) or an object with explicit +values: + +| Action | String form | Object form | Defaults applied | +|---|---|---|---| +| `duck` | `"ambience"` | `{ "group": "ambience", "depthDb": 9, "durationMs": 500 }` | `duckDepthDb` (6); UI-triggered rules also default `durationMs` to `uiDuckDurationMs` (200) | +| `boost` | `"combat"` | `{ "group": "combat", "gainDb": 3 }` | none | +| `limit` | `"ui"` | `{ "group": "ui", "maxVoices": 2 }` | `maxVoices` (4) | + +Rules are evaluated deterministically: the same state/context and rules always +produce the same decisions. Intelligence may *suggest* rules, but the Mixer +never auto-applies suggestions. + +## Loading rules + +```ts +import { loadMixRules } from "./src/mixer/index.js"; + +const rules = loadMixRules(); +// → { version, defaults, groups, rules } +``` + +`loadMixRules(options)` accepts: + +- `dir` — directory to read (`*.json`); defaults to `/.toneforge/mixer`. +- `fallback` — rule set used when no files are present; defaults to the + built-in rule set. +- `warn` — warning sink used on fallback; defaults to `console.warn`. + +Behaviour: + +- **No files present** (directory missing or containing no `.json`): a warning + is emitted and the built-in defaults are returned. +- **Files present**: every file is validated in filename order and merged + (later files override `defaults`/`groups`; rules concatenate). Invalid JSON + or schema violations fail fast with field-level, actionable errors — a + malformed rule file never silently falls back to defaults. +- **Group names are case-insensitive** (`"UI"` and `"ui"` are equivalent) and + normalise to lowercase. + +The committed seed file [`../.toneforge/mixer/rules.json`](../.toneforge/mixer/rules.json) +mirrors the built-in defaults and is covered by tests that guard against drift. + +## Programmatic validation + +`validateMixRuleSet(data, source)` returns an array of `ValidationError` +(empty = valid); `parseMixRuleSet(data, source)` validates and normalises, +throwing an actionable `Error` when invalid. + +## Mixer runtime + +The runtime arbitration engine lives in +[`src/mixer/runtime.ts`](../src/mixer/runtime.ts) and is reached through the +module index: + +```ts +import { Mixer, loadMixRules } from "./src/mixer/index.js"; + +const mixer = new Mixer({ ruleSet: loadMixRules() }); + +mixer.setContext({ surface: "metal" }); // context-driven rules +mixer.updateState("combat"); // evaluates rules → decisions + +const voice = mixer.startVoice("combat"); // null when at the voice cap +mixer.stopVoice("combat", voice!); + +mixer.inspect(); +// → { state, context, groups[], decisions[], voiceCount } +``` + +### Group registry + +`inspect().groups` returns one `MixGroupState` per confirmed group with its +`priority`, effective `maxVoices`, `currentGain`, `activeVoices` and (while a +duck is active) `duckDurationMs`. Gains start at unity (`1`) and only move in +response to an explicit rule action. + +### Voice ledger + +`startVoice(group)` admits a voice only while the group is below its effective +`maxVoices` cap and returns a deterministic voice ID (`-voice-`), or +`null` when the group is full. `stopVoice(group, id)` releases a voice. Active +voices are tracked per group; a group never admits more voices than its cap. + +### Rule evaluation + +`applyRules()` (called implicitly by `updateState`/`setContext`) evaluates the +rule set against the current state/context. A rule matches when its `state` +matches the current state **and** every declared `context` dimension matches; +a rule declaring only one of the two is matched on that trigger alone. Each +pass resets gains to unity and caps to their configured values, so evaluation +is deterministic and idempotent for identical inputs. + +Matching actions are applied in rule order: + +- `duck` multiplies the target gain by `10 ** (-depthDb / 20)` (6 dB → ≈ 0.5); +- `boost` multiplies the target gain by `10 ** (gainDb / 20)` when `gainDb` is +declared, otherwise it records a decision without changing the gain; +- `limit` lowers the target's effective voice cap. + +A cap lowered beneath the current active count does not retroactively stop +live voices; the effective cap is raised to the active count until they end, so +`activeVoices ≤ maxVoices` always holds. + +The full ordered decision list from the most recent pass is available as +`inspect().decisions`. Evaluation is constant-time per rule and adds no +allocation-heavy work to the hot path; there is no ML inference and no +suggestion is ever auto-applied. + +Reference: work item TF-0MMLC8B3T0Z1F7ZM. + +### Testing + +The Mixer is covered by three unit suites under +[`src/mixer/__tests__/`](../src/mixer/__tests__/): + +- `schema.test.ts` and `rules-loader.test.ts` — rule parsing/validation and + config loading; +- `runtime.test.ts` — the runtime API: group metadata, voice admission, + rule decisions, default duck/UI values, determinism and a latency benchmark; +- `voice-limiting.test.ts` — the safety-critical voice-limit invariant under a + seeded simulated load, fixture-verified duck/boost/limit outcomes for all + six groups, and byte-stable determinism snapshots. Deterministic fixtures + (seeded PRNG + fixed rule sets) live in `rules-fixtures.ts`. + +Reference: work items TF-0MMLC8B3T0Z1F7ZM and TF-0MMLC8SQ302DVYOZ. diff --git a/docs/tonegraph.md b/docs/tonegraph.md index 801902e6..ea066fa6 100644 --- a/docs/tonegraph.md +++ b/docs/tonegraph.md @@ -8,8 +8,35 @@ ToneGraph v0.1 targets standard Web Audio API graph construction. This spec is t - Target runtime: `BaseAudioContext` (`OfflineAudioContext` or `AudioContext`) - File formats: JSON and YAML -- In scope: static graph declaration, node parameters, deterministic randomness metadata, flat routing links, `chain()` shorthand -- Out of scope in v0.1: advanced routing patterns, complex automation DSLs, and sequence scheduling +- In scope: static graph declaration, node parameters, per-node `automation` (one-shot parameter ramps and stepped LFOs), deterministic randomness metadata, flat routing links, `chain()` shorthand, bus routing (`buses` + `{ bus }` entries), and deterministic sequence scheduling (`sequences`) +- Out of scope in v0.1: complex automation DSLs beyond the documented event kinds + +## Discovery and Loading + +ToneGraph recipes reach the shared registry (`src/recipes/index.ts`) in two stages: + +1. **Built-in recipes** are registered synchronously when the registry module is +evaluated. This is safe in every runtime, including browsers. +1. **File-backed recipes** (`.json`/`.yaml`/`.yml` files under `presets/recipes/`) +are discovered asynchronously from disk through `initializeRecipeRegistry()`. + +File-backed discovery is deliberately **not** performed with a top-level +`await`: browser build targets (Vite/esbuild) reject top-level await, and +filesystem access is Node-only. `initializeRecipeRegistry()` is therefore: + +- **Explicit** — Node call sites (the CLI, the offline renderer, and Node test + setup) `await` it before reading the registry. +- **Idempotent** — discovery runs once; later calls reuse the same result and do + not re-register recipes. +- **Portable** — in non-Node runtimes it resolves to an empty array, so a + browser sees only the synchronously-registered built-in recipes. + +```ts +import { registry, initializeRecipeRegistry } from "./recipes/index.js"; + +await initializeRecipeRegistry(); +const recipe = registry.getRegistration("ui-scifi-confirm"); +``` ## Version Compatibility @@ -30,9 +57,10 @@ ToneGraph v0.1 documents must be an object with the following fields. | `random` | no | object | RNG metadata and optional seed hint. | | `transport` | no | object | Timing metadata (for future scheduling). | | `nodes` | yes | object map | Node definitions keyed by node id. | -| `routing` | yes | array | Connection declarations (`link` or `chain`). | -| `sequences` | no | any | Reserved for v0.2. Loaders must reject when present in strict mode. | -| `namespaces` | no | any | Reserved for v0.2. Loaders must reject when present in strict mode. | +| `routing` | yes | array | Connection declarations (`link`, `chain`, or `bus`). | +| `buses` | no | object map | Named mixer buses materialised as GainNodes and referenced as `bus:`. | +| `sequences` | no | array | Timed AudioParam event schedules (see [Sequences](#sequences)). | +| `namespaces` | no | object map | Declares node groups for namespace-qualified routing references (`ns//`). See [Namespaces](#namespaces). | ### Top-Level Defaults @@ -91,7 +119,9 @@ Supported keys in v0.1: - `tempo` (number, BPM, default `120`) - `timeSignature` (array `[numerator, denominator]`, default `[4, 4]`) -Note: v0.1 does not define sequence scheduling behavior; transport is metadata only. +Note: v0.1 sequences schedule AudioParam events only (see +[Sequences](#sequences)); `transport` remains metadata and does not by itself +drive sequence timing. ## Nodes @@ -114,6 +144,8 @@ Each node object uses this structure: Rules: - `kind` is required. - `params` is optional; omitted params use kind-specific defaults. +- `automation` is optional; it declares timed AudioParam events on the node + (see [Node Automation](#node-automation)). - Node ids must be unique in `nodes`. - `destination` kind is a special terminal node and should normally be declared once. @@ -166,6 +198,14 @@ Implementations may internally expand helper kinds, but validators/loaders must - `sustain` (0..1, default `0`) - `release` (seconds, default `0`) +Envelope timing: the amplitude ramps `0 -> 1` over `attack`, `-> sustain` +over `decay`, holds `sustain`, then ramps `-> 0` over `release`. The release +ramp is scheduled **inside** the render window: `handle.stop(t)` ramps from +the sustain level to silence over `[t - release, t]` (clamped at `0`), and the +underlying sources stop at `t`. File-backed recipes that author +`duration = attack + decay + release` therefore use the full release ramp; +an explicit `release = 0` drops to silence at the stop boundary. + #### `lfo` - `type` (`sine`, `square`, `sawtooth`, `triangle`; default `sine`) - `rate` (Hz, default `1`) @@ -209,6 +249,57 @@ Validation rules: - If both `min` and `max` are present, `min <= max`. - `default` must match `type` and be inside declared bounds when bounds exist. +### File-Backed Override Mappings + +When a recipe is loaded from a file, a declared parameter is normally injected +into the graph by matching node-param keys or automation fields by exact name, +or by equality with the declared `default`. That heuristic cannot express +computed relationships (for example `modulator.frequency = carrier.frequency * +modRatio`). + +For those cases a parameter descriptor may carry an explicit `overrides` list: + +```yaml +meta: + parameters: + - name: modRatio + type: number + min: 1 + max: 4 + default: 2 + overrides: + - target: modulator.frequency + expression: "carrier.frequency * modRatio" + - name: modDepthEnd + type: number + min: 300 + max: 800 + overrides: + - target: modDepth.automation.gain.linearRamp.value +``` + +Each entry in `overrides` has: + +- `target` (string, required) — a dotted path to a node field: + - `.` writes a node `params` field. + - `.automation...` writes an automation + event field, selected by zero-based `index` or by event `kind`. +- `expression` (string, optional) — a small arithmetic expression evaluated + against resolved parameter values and node fields. Supported operators are + `*` and `/`; operands are numeric literals, declared parameter names, or + `.` references. When omitted, the parameter's resolved value is + written directly. + +Resolution order: seed-derived values, then base overrides, then the generic +name/default heuristic, then explicit mappings. Computed mappings therefore see +the post-override value of every parameter they reference, regardless of +declaration order, and explicit mappings win over a heuristic match for the +same field. An unknown parameter, node, field, automation param, event or +reference fails loudly when the recipe is registered. + +Recipes that declare no `overrides` are unaffected: the mapping mechanism is a +no-op for them. + ## Routing `routing` is a required array of connection entries. @@ -239,16 +330,286 @@ Validation rules: - `chain` length must be at least 2. - Every id in `chain` must exist in `nodes`. -## Reserved v0.2 Fields +## Buses + +`buses` is an optional object map of named mixer buses. Each bus is +materialised by the loader as a `GainNode`, addressed from routing by the +reserved reference `bus:` (for example bus `mix` becomes `bus:mix`). Buses +are the explicit construct for **fan-in** (many sources into one bus) and +**fan-out** (one bus to many destinations). + +### Bus Definition + +```json +"buses": { + "mix": { "gain": 0.5 } +} +``` + +Fields: +- `gain` (number, default `1.0`) — the bus mix level. + +Validation rules: +- Bus ids must be non-empty and unique. +- A bus id must not collide with a node id. +- A node id must not collide with a generated `bus:` reference. +- `gain`, when present, must be a finite number. + +### Bus Routing Entry + +A routing entry may instead fan signals through a declared bus: + +```json +{ "bus": "mix", "from": ["osc", "noise"], "to": ["filter"] } +``` + +Fields: +- `bus` (string, required) — the id of a declared bus. +- `from` (string or array of strings, optional) — one or more node output + endpoints to connect **into** the bus (fan-in). May not reference an + AudioParam endpoint. +- `to` (string or array of strings, optional) — one or more destination + endpoints to connect **from** the bus (fan-out). May target either a node or + a `node.param` AudioParam endpoint. + +Semantics: each `from` input is linked to the bus, and the bus is linked to + each `to` output. An entry may declare `from`, `to`, or both, enabling pure + fan-in, pure fan-out, or a combined Y-shape. + +Validation rules: +- `bus` must reference a bus declared in `buses`. +- At least one of `from` / `to` must be present. +- A present `from` / `to` must be a string or a non-empty array of strings. +- Every referenced node id must exist in `nodes`. +- `from` endpoints must not reference an AudioParam. + +### Bus Routing Example -`sequences` and `namespaces` are reserved for v0.2. +```json +{ + "version": "0.1", + "buses": { "mix": { "gain": 0.5 } }, + "nodes": { + "osc": { "kind": "oscillator", "params": { "frequency": 220 } }, + "noise": { "kind": "noise", "params": { "color": "white", "level": 0.2 } }, + "amp": { "kind": "gain", "params": { "gain": 0.4 } }, + "out": { "kind": "destination" } + }, + "routing": [ + { "chain": ["osc", "amp"] }, + { "bus": "mix", "from": ["amp", "noise"], "to": ["out"] } + ] +} +``` + +## Namespaces + +`namespaces` is an optional object map that groups existing nodes under named +prefixes. Routing entries may then reference nodes in a namespace using the +`ns//` syntax. Namespaces enable recipe authors to refer +to logically related node groups without hard-coding node ids. -v0.1 behavior: -- Producers should not emit these fields. -- Validators/loaders may run in either mode: - - strict: reject when either field is present - - permissive: ignore with warning -- For implementation consistency, strict mode is recommended by default. +### Namespace Definition + +```json +"namespaces": { + "sfx": { "nodes": ["osc1", "filter1", "env1"] }, + "pad": { "nodes": ["osc2", "filter2", "amp2"] } +} +``` + +Fields: +- `nodes` (array of strings, required) — list of existing node ids that belong + to this namespace. Every node must exist in `nodes`. + +### Namespace Routing Reference + +Routing entries may use `ns//` anywhere a plain node id is +expected: + +```json +"routing": [ + { "chain": ["ns/sfx/osc1", "ns/sfx/filter1", "ns/sfx/env1", "out"] } +] +``` + +The loader resolves `ns/sfx/osc1` to the node `osc1` declared in the `sfx` +namespace. Namespace references in routing are validated at load time: + +- The namespace name must exist in `namespaces`. +- The referenced node id must be listed in that namespace's `nodes` array. +- The namespace name must not collide with a node id. + +### Namespace Example + +```json +{ + "version": "0.1", + "namespaces": { + "sfx": { "nodes": ["osc", "filter", "env"] } + }, + "nodes": { + "osc": { "kind": "oscillator", "params": { "frequency": 440 } }, + "filter": { "kind": "biquadFilter", "params": { "type": "lowpass" } }, + "env": { "kind": "envelope", "params": { "attack": 0.01 } }, + "out": { "kind": "destination" } + }, + "routing": [ + { "chain": ["ns/sfx/osc", "ns/sfx/filter", "ns/sfx/env", "out"] } + ] +} +``` + +## Node Automation + +`automation` is an optional per-node object map from an AudioParam name to an +ordered list of events. It is the inline counterpart to document-level +[`sequences`](#sequences) and uses the same event kinds. Unlike `sequences`, +automation is declared directly on the node definition, which keeps one-shot +pitch contours (laser zaps, explosion sweeps, whooshes) co-located with the +node they modulate. + +```yaml +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 220 + automation: + frequency: + - kind: set + time: 0 + value: 220 + - kind: linearRamp + time: 0.5 + value: 880 +``` + +### Automation Contract + +- `automation` must be an object (not an array, not `null`). +- Each key is an AudioParam name on that node (for example `frequency` on an + `oscillator` or `biquadFilter`, or `gain` on a `gain` node). Keys must not be + empty. +- Each value must be an array of events drawn from the same union as + `sequences` events: `set`, `linearRamp`, `exponentialRamp`, and `lfo`. +- Events are validated at document load. A malformed event (unknown `kind`, + missing or non-finite `time`/`value`, non-array event list) fails validation + with a clear error. +- An `exponentialRamp` whose `value` is `<= 0` is rejected at validation: the + Web Audio API only supports exponential ramps to strictly positive targets. +- The validator **preserves** `automation` on the validated document, so the + loader can schedule the events. +- Invalid param names (ones that do not resolve to a runtime AudioParam) fail + at load time with a descriptive error, exactly like `sequences`. + +### Worked Example: Frequency Sweeps + +The committed example recipe +[`presets/recipes/frequency-sweep-demo.yaml`](../presets/recipes/frequency-sweep-demo.yaml) +demonstrates a one-shot linear rise and an exponential fall: + +```yaml +# rise: 220 Hz -> 880 Hz linear rise +rise: + kind: oscillator + params: + type: sine + frequency: 220 + automation: + frequency: + - kind: set + time: 0 + value: 220 + - kind: linearRamp + time: 0.5 + value: 880 + +# fall: 440 Hz -> 60 Hz exponential fall +fall: + kind: oscillator + params: + type: sine + frequency: 440 + automation: + frequency: + - kind: set + time: 0 + value: 440 + - kind: exponentialRamp + time: 0.5 + value: 60 +``` + +Render it with the CLI: + +```bash +tf generate --recipe frequency-sweep-demo --seed 42 --output frequency-sweep-demo.wav +``` + +Because automation is part of the recipe file, this recipe renders through the +same file-backed path as every other ToneGraph recipe; no TypeScript is +required to author the sweep. + +## Sequences + +`sequences` is an optional array of timed AudioParam event schedules. Each +sequence targets one node's AudioParam and applies its events deterministically +when the graph is loaded. Sequences are the document-level counterpart to +inline per-node `automation`, and use the same event kinds. + +### Sequence Definition + +```json +{ + "sequences": [ + { + "node": "osc", + "param": "frequency", + "events": [ + { "kind": "set", "time": 0, "value": 220 }, + { "kind": "linearRamp", "time": 0.2, "value": 660 } + ] + } + ] +} +``` + +Fields: +- `node` (string, required) — the id of a node declared in `nodes`. +- `param` (string, required) — the AudioParam name on that node. +- `events` (array, required) — ordered event objects. + +### Sequence Events + +- `{ "kind": "set", "time": , "value": }` — `setValueAtTime`. +- `{ "kind": "linearRamp", "time": , "value": }` — + `linearRampToValueAtTime`. +- `{ "kind": "exponentialRamp", "time": , "value": }` — + `exponentialRampToValueAtTime`. `value` must be strictly greater than `0`; + a non-positive target is rejected at validation. +- `{ "kind": "lfo", "rate": , "depth": , ... }` — a stepped + LFO written as repeated `setValueAtTime` calls. Optional fields: `wave` + (`sine`, `square`, `sawtooth`, `triangle`; default `sine`), `offset`, + `start`, `end`, `step`. + +`time`, `start` and `end` are absolute seconds within the rendered graph. Events +are applied in declaration order. + +Validation rules: +- `sequences` must be an array. +- Each entry must declare `node`, `param`, and `events`. +- `node` must reference an existing node id; `param` must not be empty. +- Each event `kind` must be one of `set`, `linearRamp`, `exponentialRamp`, + `lfo`; every numeric field must be a finite number. +- An `exponentialRamp` event must target a `value` greater than `0`. + +Invalid `node`/`param` pairs that cannot be resolved to a runtime AudioParam +fail at load time with a descriptive error. + +The same event kinds are available on node-level `automation`; see +[Node Automation](#node-automation). ## Complete Example (JSON) diff --git a/package-lock.json b/package-lock.json index fa2a6f11..5ebdf07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "toneforge", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "toneforge", - "version": "0.1.0", + "version": "0.1.1", "hasInstallScript": true, - "license": "ISC", + "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.3.0", "buffer": "^6.0.3", @@ -17,10 +17,8 @@ "js-yaml": "^4.1.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", - "node-web-audio-api": "^1.0.8", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", - "tone": "^15.1.22", "unified": "^11.0.5", "yargs": "^17.7.2" }, @@ -36,14 +34,9 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "engines": { - "node": ">=6.9.0" + }, + "optionalDependencies": { + "node-web-audio-api": "^1.0.8" } }, "node_modules/@colors/colors": { @@ -1509,18 +1502,6 @@ "node": ">=12" } }, - "node_modules/automation-events": { - "version": "7.1.19", - "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.19.tgz", - "integrity": "sha512-cD+TLhJTI0q4AI3ktd353lrGZiVa9AchowSDzQzzGjSoYe22js4vlS32VUtWuaulghi1Yq0KYNWKk9wWuGymPA==", - "dependencies": { - "@babel/runtime": "^7.29.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.2.0" - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1579,7 +1560,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz", "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ccount": { "version": "2.0.1", @@ -1799,6 +1781,7 @@ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", + "optional": true, "engines": { "node": ">= 12" } @@ -2057,6 +2040,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" @@ -2076,6 +2060,7 @@ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", + "optional": true, "dependencies": { "fetch-blob": "^3.1.2" }, @@ -3162,6 +3147,7 @@ } ], "license": "MIT", + "optional": true, "engines": { "node": ">=10.5.0" } @@ -3186,6 +3172,7 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", + "optional": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -3204,6 +3191,7 @@ "resolved": "https://registry.npmjs.org/node-web-audio-api/-/node-web-audio-api-1.0.8.tgz", "integrity": "sha512-xEQs4ySdyQ28T0zf8FjP96AzkWwE6dObaBsBxqJAbB4kpYMrVfIdAD0HjpY5tdN0pEDfEHyQ3S+0fTEk8SlqrQ==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "caller": "^1.1.0", "node-fetch": "^3.3.2", @@ -3519,16 +3507,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standardized-audio-context": { - "version": "25.3.77", - "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", - "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", - "dependencies": { - "@babel/runtime": "^7.25.6", - "automation-events": "^7.0.9", - "tslib": "^2.7.0" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3680,15 +3658,6 @@ "node": ">=14.0.0" } }, - "node_modules/tone": { - "version": "15.1.22", - "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", - "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", - "dependencies": { - "standardized-audio-context": "^25.3.70", - "tslib": "^2.3.1" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3713,6 +3682,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -4025,6 +3995,7 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", + "optional": true, "engines": { "node": ">= 8" } @@ -4034,6 +4005,7 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "license": "BSD-2-Clause", + "optional": true, "engines": { "node": ">=12" } diff --git a/package.json b/package.json index fd483851..ca808836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toneforge", - "version": "0.1.1", + "version": "0.1.2", "description": "Generate placeholder sound effects instantly so development never stalls waiting for final audio assets.", "type": "module", "main": "dist/index.js", @@ -14,6 +14,7 @@ "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:web:e2e": "npm run test:e2e:ci --prefix web", + "verify": "npm run typecheck && npm test && npm test --prefix web && npm run test:web:e2e", "test:watch": "vitest", "start": "./bin/dev-cli.js", "generate": "./bin/dev-cli.js generate", @@ -29,7 +30,7 @@ "sfx" ], "author": "", - "license": "ISC", + "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.3.0", "buffer": "^6.0.3", @@ -38,13 +39,14 @@ "js-yaml": "^4.1.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", - "node-web-audio-api": "^1.0.8", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", - "tone": "^15.1.22", "unified": "^11.0.5", "yargs": "^17.7.2" }, + "optionalDependencies": { + "node-web-audio-api": "^1.0.8" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/marked-terminal": "^6.1.1", diff --git a/presets/recipes/ambient-wind-gust.yaml b/presets/recipes/ambient-wind-gust.yaml index 8d5e282e..78572f71 100644 --- a/presets/recipes/ambient-wind-gust.yaml +++ b/presets/recipes/ambient-wind-gust.yaml @@ -17,6 +17,9 @@ meta: max: 1500 unit: Hz default: 203.4370603047863 + overrides: + - target: windFilter.frequency + - target: windFilter.automation.frequency.0.offset - name: filterQ type: number min: 0.5 @@ -29,12 +32,17 @@ meta: max: 4 unit: Hz default: 0.8883498038370976 + overrides: + - target: windFilter.automation.frequency.0.rate - name: lfoDepth type: number min: 100 max: 800 unit: Hz default: 694.563831527383 + overrides: + - target: windFilter.automation.frequency.0.depth + expression: "lfoDepth / 2" - name: attack type: number min: 0.1 diff --git a/presets/recipes/card-transform.yaml b/presets/recipes/card-transform.yaml index dd488b58..9ac3ac9e 100644 --- a/presets/recipes/card-transform.yaml +++ b/presets/recipes/card-transform.yaml @@ -21,24 +21,34 @@ meta: max: 700 unit: Hz default: 301.05755701685734 + overrides: + - target: carrier.frequency - name: modRatio type: number min: 1 max: 4 unit: ratio default: 2.9809359325982947 + overrides: + - target: modulator.frequency + expression: "carrier.frequency * modRatio" - name: modDepthStart type: number min: 50 max: 200 unit: Hz default: 66.6435630215899 + overrides: + - target: modDepth.gain + - target: modDepth.automation.gain.0.value - name: modDepthEnd type: number min: 300 max: 800 unit: Hz default: 724.6884510909879 + overrides: + - target: modDepth.automation.gain.linearRamp.value - name: attack type: number min: 0.02 diff --git a/presets/recipes/frequency-sweep-demo.yaml b/presets/recipes/frequency-sweep-demo.yaml new file mode 100644 index 00000000..83937c69 --- /dev/null +++ b/presets/recipes/frequency-sweep-demo.yaml @@ -0,0 +1,101 @@ +# Worked example for the ToneGraph node `automation` contract. +# +# Demonstrates two one-shot frequency contours on oscillator AudioParams: +# - `rise` : 220 Hz -> 880 Hz linear rise +# - `fall` : 440 Hz -> 60 Hz exponential fall (exponential ramps require +# a strictly positive target, hence the 60 Hz endpoint) +# +# See docs/tonegraph.md#node-automation for the full contract. +version: "0.1" +meta: + name: frequency-sweep-demo + description: Two-oscillator demo of a 220->880 Hz linear rise and a 440->60 Hz exponential fall. + category: Demo + tags: + - demo + - sweep + - automation + duration: 0.6 + parameters: + - name: riseStart + type: number + min: 150 + max: 330 + unit: Hz + default: 220 + - name: riseEnd + type: number + min: 600 + max: 1200 + unit: Hz + default: 880 + - name: fallStart + type: number + min: 300 + max: 600 + unit: Hz + default: 440 + - name: fallEnd + type: number + min: 40 + max: 120 + unit: Hz + default: 60 + - name: mixLevel + type: number + min: 0.1 + max: 0.4 + unit: amplitude + default: 0.2 +nodes: + rise: + kind: oscillator + params: + type: sine + frequency: 220 + automation: + frequency: + - kind: set + time: 0 + value: 220 + - kind: linearRamp + time: 0.5 + value: 880 + riseEnv: + kind: envelope + params: + attack: 0.01 + decay: 0.05 + sustain: 0.8 + release: 0.05 + fall: + kind: oscillator + params: + type: sine + frequency: 440 + automation: + frequency: + - kind: set + time: 0 + value: 440 + - kind: exponentialRamp + time: 0.5 + value: 60 + fallEnv: + kind: envelope + params: + attack: 0.01 + decay: 0.05 + sustain: 0.8 + release: 0.05 + mix: + kind: gain + params: + gain: 0.2 + out: + kind: destination +routing: + - chain: [rise, riseEnv, mix] + - chain: [fall, fallEnv, mix] + - from: mix + to: out diff --git a/presets/recipes/weapon-laser-zap.yaml b/presets/recipes/weapon-laser-zap.yaml index 6f1af844..994547e7 100644 --- a/presets/recipes/weapon-laser-zap.yaml +++ b/presets/recipes/weapon-laser-zap.yaml @@ -17,18 +17,25 @@ meta: max: 2000 unit: Hz default: 204.759006575858 + overrides: + - target: carrier.frequency - name: modulatorFreq type: number min: 50 max: 500 unit: Hz default: 347.1403898897442 + overrides: + - target: modulator.frequency - name: modIndex type: number min: 1 max: 10 unit: ratio default: 1.998613781295394 + overrides: + - target: modDepth.gain + expression: "modIndex * modulator.frequency" - name: noiseBurstLevel type: number min: 0.1 diff --git a/presets/sequences/tableau_play_card_with_landing.json b/presets/sequences/tableau_play_card_with_landing.json new file mode 100644 index 00000000..e21f0418 --- /dev/null +++ b/presets/sequences/tableau_play_card_with_landing.json @@ -0,0 +1,20 @@ +{ + "version": "1.0", + "name": "tableau_play_card_with_landing", + "description": "Player slides a card onto the tableau followed by the full card_play_landing stack (slide + place + glow) as a single wrapped event.", + "tempo": 120, + "events": [ + { + "time": 0, + "event": "card-slide", + "seedOffset": 0, + "gain": 0.7 + }, + { + "time": 0.15, + "event": "stack-card-play-landing", + "seedOffset": 1, + "gain": 1.0 + } + ] +} diff --git a/src/audio/runtime-awareness.test.ts b/src/audio/runtime-awareness.test.ts new file mode 100644 index 00000000..2953742c --- /dev/null +++ b/src/audio/runtime-awareness.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +describe("browser runtime awareness", () => { + it("uses the browser-native OfflineAudioContext global when not in Node", async () => { + class FakeOfflineAudioContext { + readonly numberOfChannels: number; + readonly length: number; + readonly sampleRate: number; + + constructor(numberOfChannels: number, length: number, sampleRate: number) { + this.numberOfChannels = numberOfChannels; + this.length = length; + this.sampleRate = sampleRate; + } + } + + // Simulate a browser: no Node version string, but a native Web Audio + // global. Deleting the (configurable) property is the least invasive way + // to make isNodeRuntime() report false. + const originalNodeVersion = process.versions.node; + Reflect.deleteProperty(process.versions, "node"); + vi.stubGlobal("OfflineAudioContext", FakeOfflineAudioContext); + vi.resetModules(); + + try { + const mod = await import("./web-audio.js"); + + expect(mod.isNodeRuntime()).toBe(false); + expect(mod.getOfflineAudioContextCtor()).toBe(FakeOfflineAudioContext); + + const ctx = new mod.OfflineAudioContext(2, 128, 48000); + expect(ctx).toBeInstanceOf(FakeOfflineAudioContext); + expect(ctx.sampleRate).toBe(48000); + } finally { + (process.versions as unknown as Record).node = + originalNodeVersion; + } + }); +}); + +describe("node-web-audio-api packaging", () => { + it("is an optional dependency, never a hard dependency", () => { + const pkgPath = resolve(here, "..", "..", "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { + dependencies?: Record; + optionalDependencies?: Record; + }; + + expect(pkg.dependencies ?? {}).not.toHaveProperty("node-web-audio-api"); + expect(pkg.optionalDependencies ?? {}).toHaveProperty( + "node-web-audio-api", + ); + }); +}); diff --git a/src/audio/sample-loader.ts b/src/audio/sample-loader.ts index 34b9dda6..df08a167 100644 --- a/src/audio/sample-loader.ts +++ b/src/audio/sample-loader.ts @@ -14,7 +14,7 @@ * Reference: docs/prd/CORE_PRD.md Section 5 */ -import type { OfflineAudioContext, AudioBuffer } from "node-web-audio-api"; +import type { OfflineAudioContext, AudioBuffer } from "../audio/web-audio.js"; /** * Detect whether we are running in a browser environment. diff --git a/src/audio/wav-encoder.ts b/src/audio/wav-encoder.ts index b0991c6b..aaf6c6ce 100644 --- a/src/audio/wav-encoder.ts +++ b/src/audio/wav-encoder.ts @@ -9,6 +9,10 @@ * - data sub-chunk (8 + sample data bytes) */ +// Import Buffer explicitly rather than relying on the Node global so the same +// encoder works in the browser (Vite resolves this to the `buffer` polyfill). +import { Buffer } from "buffer"; + /** Options for WAV encoding. */ export interface WavEncodeOptions { /** Sample rate in Hz (default: 44100). */ diff --git a/src/audio/web-audio.test.ts b/src/audio/web-audio.test.ts new file mode 100644 index 00000000..dec1431f --- /dev/null +++ b/src/audio/web-audio.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { + isNodeRuntime, + getOfflineAudioContextCtor, + OfflineAudioContext, +} from "./web-audio.js"; + +describe("isNodeRuntime", () => { + it("returns true in Node.js", () => { + expect(isNodeRuntime()).toBe(true); + }); + + it("is a function that reports a boolean", () => { + expect(typeof isNodeRuntime()).toBe("boolean"); + }); +}); + +describe("getOfflineAudioContextCtor", () => { + it("returns a constructor that can be used with new", () => { + const Ctor = getOfflineAudioContextCtor(); + const ctx = new Ctor(1, 44100, 44100); + expect(ctx).toBeDefined(); + expect(ctx.length).toBe(44100); + expect(ctx.sampleRate).toBe(44100); + }); + + it("returns a constructor with startRendering method", () => { + const Ctor = getOfflineAudioContextCtor(); + const ctx = new Ctor(1, 4410, 44100); + expect(typeof ctx.startRendering).toBe("function"); + }); + + it("returns the same constructor on repeated calls (cached)", () => { + const first = getOfflineAudioContextCtor(); + const second = getOfflineAudioContextCtor(); + expect(first).toBe(second); + }); +}); + +describe("OfflineAudioContext", () => { + it("is constructable via new", () => { + const ctx = new OfflineAudioContext(1, 44100, 44100); + expect(ctx).toBeDefined(); + expect(ctx.length).toBe(44100); + expect(ctx.sampleRate).toBe(44100); + }); + + it("produces instances with the expected audio graph API", () => { + const ctx = new OfflineAudioContext(1, 4410, 44100); + expect(typeof ctx.createOscillator).toBe("function"); + expect(typeof ctx.createGain).toBe("function"); + expect(typeof ctx.createBuffer).toBe("function"); + expect(typeof ctx.createBufferSource).toBe("function"); + expect(typeof ctx.startRendering).toBe("function"); + }); + + it("renders a silent buffer end-to-end", async () => { + const ctx = new OfflineAudioContext(1, 4410, 44100); + const rendered = await ctx.startRendering(); + expect(rendered.numberOfChannels).toBe(1); + expect(rendered.length).toBe(4410); + }); +}); diff --git a/src/audio/web-audio.ts b/src/audio/web-audio.ts new file mode 100644 index 00000000..a171d076 --- /dev/null +++ b/src/audio/web-audio.ts @@ -0,0 +1,254 @@ +/** + * Cross-Platform Web Audio API Abstraction + * + * Provides `OfflineAudioContext`, `AudioContext`, and `isNodeRuntime()` + * usable in both Node.js (via `node-web-audio-api`) and browser + * (via native Web Audio API). + * + * In Node.js, `node-web-audio-api` is loaded lazily (via a runtime-guarded + * `require`) so that browser bundlers never include the package — it is an + * optional peer dependency for Node.js users who need offline rendering. + * + * In browsers, the classes are available as globals on `globalThis`. + * + * Usage: + * import { + * OfflineAudioContext, + * isNodeRuntime, + * getAudioContext, + * } from "./audio/web-audio.js"; + * + * // Offline rendering (Node.js or browser) + * const ctx = new OfflineAudioContext(1, length, sampleRate); + * + * // Real-time playback (browser only, or Node.js with node-web-audio-api) + * const realtime = getAudioContext(); + * + * References: + * - docs/prd/CORE_PRD.md Section 5 + */ + +import type { + AudioBuffer, + OfflineAudioContext as OfflineAudioContextType, + AudioContext as AudioContextType, +} from "node-web-audio-api"; + +// --------------------------------------------------------------------------- +// Runtime detection +// --------------------------------------------------------------------------- + +/** + * Detect whether the current code is executing under Node.js. + * + * This is a more robust check than `require.main` because it works even + * when the code is bundled (the `process` object is typically shimmed or + * polyfilled by bundlers, but a proper Node shim will retain `process.versions.node`). + */ +export function isNodeRuntime(): boolean { + return typeof process !== "undefined" + && process.versions !== undefined + && typeof process.versions.node === "string"; +} + +// --------------------------------------------------------------------------- +// Type re-exports (structural match across runtimes) +// --------------------------------------------------------------------------- + +/** + * Re-exported `AudioBuffer` type. + * + * The type is structurally identical between `node-web-audio-api` and the + * browser's standard Web Audio API (lib.dom.d.ts), so this re-export is + * safe in both runtimes. + */ +export type { AudioBuffer }; + +/** + * The `OfflineAudioContext` instance type. + * + * Structurally identical between `node-web-audio-api` and the browser's + * standard Web Audio API. Merged with the {@link OfflineAudioContext} + * constructor value below (declaration merging: interface + const). + */ +export interface OfflineAudioContext extends OfflineAudioContextType {} + +/** + * The `AudioContext` instance type. + * + * Structurally identical between `node-web-audio-api` and the browser's + * standard Web Audio API. Merged with the {@link AudioContext} constructor + * value below (declaration merging: interface + const). + */ +export interface AudioContext extends AudioContextType {} + +// --------------------------------------------------------------------------- +// Node.js module resolution (lazy, browser-safe) +// --------------------------------------------------------------------------- + +/** Cached `node-web-audio-api` module (Node.js only). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _nodeWebAudioModule: any; + +/** + * Build a CommonJS `require` for the current module in Node.js. + * + * `node:module` is resolved lazily through `process.getBuiltinModule` rather + * than a static `import { createRequire } from "node:module"`. A static import + * of a Node builtin makes this module un-bundleable for the browser (Rollup + * cannot resolve `node:module`), which breaks the web build as soon as a + * browser entry point imports the abstraction. `process.getBuiltinModule` + * exists only in Node.js, so the browser never reaches this code path. + * + * @throws If called outside a Node.js runtime that supports + * `process.getBuiltinModule`. + */ +function getNodeRequire(): (id: string) => unknown { + const proc = (globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + }).process; + const getBuiltinModule = proc?.getBuiltinModule; + if (typeof getBuiltinModule !== "function") { + throw new Error( + "Cannot load node-web-audio-api: process.getBuiltinModule is " + + "unavailable (requires Node.js >= 22.3). Use a browser runtime or " + + "upgrade Node.js.", + ); + } + const nodeModule = getBuiltinModule("node:module") as { + createRequire: (url: string | URL) => (id: string) => unknown; + }; + return nodeModule.createRequire(import.meta.url); +} + +/** + * Resolve the `node-web-audio-api` module in Node.js. + * + * Loaded lazily at runtime so the package is never statically imported and + * never appears in browser bundles. The module specifier is held in a + * variable so bundlers cannot statically resolve it. + * + * @throws If called outside Node.js or when the package is not installed. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveNodeWebAudio(): any { + if (_nodeWebAudioModule) return _nodeWebAudioModule; + + if (!isNodeRuntime()) { + throw new Error("node-web-audio-api is only available in Node.js"); + } + + const nodeRequire = getNodeRequire(); + const moduleName = "node-web-audio-api"; + try { + _nodeWebAudioModule = nodeRequire(moduleName); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + "node-web-audio-api is required for offline rendering in Node.js " + + `but could not be loaded: ${message}. Install it with ` + + "`npm install node-web-audio-api`.", + ); + } + return _nodeWebAudioModule; +} + +// --------------------------------------------------------------------------- +// OfflineAudioContext — class (runtime) +// --------------------------------------------------------------------------- + +let _OfflineAudioContextCtor: typeof globalThis.OfflineAudioContext | undefined; + +/** + * Get the `OfflineAudioContext` constructor. + * + * In Node.js, this loads `node-web-audio-api` lazily. + * In browsers, it returns the global constructor. + * + * @returns The `OfflineAudioContext` constructor. + */ +export function getOfflineAudioContextCtor(): typeof globalThis.OfflineAudioContext { + if (_OfflineAudioContextCtor) return _OfflineAudioContextCtor; + + if (isNodeRuntime()) { + _OfflineAudioContextCtor = resolveNodeWebAudio() + .OfflineAudioContext as typeof globalThis.OfflineAudioContext; + } else { + _OfflineAudioContextCtor = globalThis.OfflineAudioContext; + } + return _OfflineAudioContextCtor; +} + +/** + * The cross-platform `OfflineAudioContext` constructor. + * + * In Node.js this is backed by `node-web-audio-api`; in browsers it is the + * native global constructor. Construction delegates to + * {@link getOfflineAudioContextCtor} so the Node-only package is never + * imported at module load time. + */ +export const OfflineAudioContext: { + new ( + numberOfChannels: number, + length: number, + sampleRate: number, + ): OfflineAudioContext; +} = new Proxy(class {} as unknown as { + new ( + numberOfChannels: number, + length: number, + sampleRate: number, + ): OfflineAudioContext; +}, { + construct(_target, args) { + const Ctor = getOfflineAudioContextCtor(); + return Reflect.construct(Ctor, args) as OfflineAudioContext; + }, +}); + +// --------------------------------------------------------------------------- +// AudioContext — real-time playback +// --------------------------------------------------------------------------- + +/** + * Create a real-time `AudioContext` instance. + * + * In browsers this creates a native `AudioContext`. + * In Node.js this loads `node-web-audio-api` and returns an + * `AudioContext` instance. + * + * @returns A real-time audio context. + * + * @throws If called in Node.js without `node-web-audio-api` installed. + */ +/** + * The cross-platform `AudioContext` constructor for real-time playback. + * + * In Node.js this is backed by `node-web-audio-api`; in browsers it is the + * native global constructor. + */ +export const AudioContext: { new (): AudioContext } = new Proxy( + class {} as unknown as { new (): AudioContext }, + { + construct(_target, args) { + if (isNodeRuntime()) { + const Ctor = resolveNodeWebAudio().AudioContext as new () => AudioContext; + return Reflect.construct(Ctor, args) as AudioContext; + } + return Reflect.construct(globalThis.AudioContext, args) as AudioContext; + }, + }, +); + +/** + * Create a real-time `AudioContext` instance. + * + * Convenience factory wrapper around {@link AudioContext}. + * + * @returns A real-time audio context. + * + * @throws If called in Node.js without `node-web-audio-api` installed. + */ +export function getAudioContext(): AudioContext { + return new AudioContext(); +} diff --git a/src/cli.integration.test.ts b/src/cli.integration.test.ts index 9f4dade8..24c773a1 100644 --- a/src/cli.integration.test.ts +++ b/src/cli.integration.test.ts @@ -202,6 +202,37 @@ describe("CLI Integration — --output flag end-to-end", () => { expect(fileData.length).toBe(44 + dataSize); }); + it("renders a file-backed frequency-sweep recipe to a non-empty WAV via --output (AC4)", async () => { + const outPath = join(tempDir, "frequency-sweep-demo.wav"); + const { code } = await captureOutput( + () => main(argv("generate", "--recipe", "frequency-sweep-demo", "--seed", "42", "--output", outPath)), + ); + + expect(code).toBe(0); + expect(existsSync(outPath)).toBe(true); + + const data = readFileSync(outPath); + expect(data.toString("ascii", 0, 4)).toBe("RIFF"); + expect(data.toString("ascii", 8, 12)).toBe("WAVE"); + const dataSize = data.readUInt32LE(40); + expect(dataSize).toBeGreaterThan(0); + expect(data.length).toBe(44 + dataSize); + }); + + it("frequency-sweep recipe renders byte-identically for the same seed (AC5)", async () => { + const outA = join(tempDir, "sweep-a.wav"); + const outB = join(tempDir, "sweep-b.wav"); + + await captureOutput( + () => main(argv("generate", "--recipe", "frequency-sweep-demo", "--seed", "7", "--output", outA)), + ); + await captureOutput( + () => main(argv("generate", "--recipe", "frequency-sweep-demo", "--seed", "7", "--output", outB)), + ); + + expect(readFileSync(outA).equals(readFileSync(outB))).toBe(true); + }); + it("--seed-range batch generates correct number of valid WAV files", async () => { const outDir = tempDir + "/"; const { code } = await captureOutput( diff --git a/src/cli.library-add.integration.test.ts b/src/cli.library-add.integration.test.ts new file mode 100644 index 00000000..ea88b0b8 --- /dev/null +++ b/src/cli.library-add.integration.test.ts @@ -0,0 +1,426 @@ +/** + * CLI Integration Tests for `toneforge library add`. + * + * Exercises the command through the real yargs entrypoint (`main`), covering: + * - help output documenting file/inline/stdin input sources (AC1) + * - registration of an external ToneGraph file into the session registry (AC2) + * - recipe-name derivation from the filename basename (AC3) + * - rendering the newly registered recipe from the same registry (AC4) + * - clear errors on parse / schema-validation failure (AC5) + * + * Work item: TF-0MUUC9RTU008UJ18 + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import { main } from "./cli.js"; +import { registry } from "./recipes/index.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Capture stdout/stderr written during a CLI invocation. */ +async function captureOutput(fn: () => Promise): Promise<{ + code: number; + stdout: string; + stderr: string; +}> { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + + const origLog = console.log; + const origError = console.error; + const origStdoutWrite = process.stdout.write; + const origStderrWrite = process.stderr.write; + + console.log = (...args: unknown[]) => { + stdoutLines.push(args.map(String).join(" ")); + }; + console.error = (...args: unknown[]) => { + stderrLines.push(args.map(String).join(" ")); + }; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutLines.push(String(chunk).replace(/\n$/, "")); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrLines.push(String(chunk).replace(/\n$/, "")); + return true; + }) as typeof process.stderr.write; + + try { + const code = await fn(); + return { + code, + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + }; + } finally { + console.log = origLog; + console.error = origError; + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; + } +} + +/** Build a fake argv array matching `node cli.ts ...`. */ +function argv(...args: string[]): string[] { + return ["node", "cli.ts", ...args]; +} + +/** Replace process.stdin with a readable stream carrying `input`. */ +function withStdin(input: string, fn: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, "stdin"); + const stream = Readable.from([input]); + Object.defineProperty(process, "stdin", { + value: stream, + configurable: true, + }); + return fn().finally(() => { + if (original) { + Object.defineProperty(process, "stdin", original); + } + }); +} + +/** A minimal, schema-valid ToneGraph YAML document. */ +function validToneGraphYaml(name: string): string { + return `version: "0.1" +meta: + name: ${name} + description: Integration test recipe for library add. + category: Test + tags: + - test + - library-add + duration: 0.1 + parameters: + - name: frequency + type: number + min: 100 + max: 1000 + unit: Hz + default: 440 +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 440 + gain: + kind: gain + params: + gain: 0.5 + out: + kind: destination +routing: + - chain: [osc, gain, out] +`; +} + +/** A minimal, schema-valid ToneGraph JSON document. */ +function validToneGraphJson(name: string): string { + return JSON.stringify({ + version: "0.1", + meta: { + name, + description: "Integration test recipe for library add (JSON).", + category: "Test", + tags: ["test", "library-add"], + duration: 0.1, + }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + gain: { kind: "gain", params: { gain: 0.5 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "gain", "out"] }], + }); +} + +let tmpRoot: string; +let originalRecipeDir: string | undefined; + +beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "tf-library-add-")); + // Persist recipes into the per-test temp directory so these tests never + // write to the developer's real ~/.toneforge/recipes (AC1/AC5 isolation). + originalRecipeDir = process.env["TONEFORGE_RECIPE_DIR"]; + process.env["TONEFORGE_RECIPE_DIR"] = join(tmpRoot, "external-recipes"); +}); + +afterEach(() => { + if (originalRecipeDir === undefined) { + delete process.env["TONEFORGE_RECIPE_DIR"]; + } else { + process.env["TONEFORGE_RECIPE_DIR"] = originalRecipeDir; + } + try { + rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// --------------------------------------------------------------------------- +// Help (AC1) +// --------------------------------------------------------------------------- + +describe("CLI library add — help", () => { + it("library add --help documents file, inline, and stdin input sources", async () => { + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--help")), + ); + expect(code).toBe(0); + expect(stdout).toContain("--file"); + expect(stdout).toContain("--inline"); + expect(stdout).toContain("--stdin"); + }); + + it("library --help lists the add subcommand", async () => { + const { code, stdout } = await captureOutput( + () => main(argv("library", "--help")), + ); + expect(code).toBe(0); + expect(stdout).toContain("**add**"); + expect(stdout).toContain("--file"); + }); +}); + +// --------------------------------------------------------------------------- +// Validation errors (AC5) +// --------------------------------------------------------------------------- + +describe("CLI library add — validation", () => { + it("with no input source returns a clear error", async () => { + const { code, stderr } = await captureOutput( + () => main(argv("library", "add")), + ); + expect(code).toBe(1); + expect(stderr).toContain("input source is required"); + }); + + it("with no input source returns a JSON error in --json mode", async () => { + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--json")), + ); + expect(code).toBe(1); + const json = JSON.parse(stderr); + expect(json.error).toContain("input source is required"); + }); + + it("with multiple input sources returns a clear error", async () => { + const file = join(tmpRoot, "multi.yaml"); + writeFileSync(file, validToneGraphYaml("multi"), "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--inline", "version: \"0.1\"")), + ); + expect(code).toBe(1); + expect(stderr).toContain("Only one input source"); + }); + + it("non-existent file returns a clear error", async () => { + const missing = join(tmpRoot, "does-not-exist.yaml"); + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", missing)), + ); + expect(code).toBe(1); + expect(stderr).toContain("File not found"); + }); + + it("malformed YAML returns a clear parse error", async () => { + const file = join(tmpRoot, "malformed.yaml"); + writeFileSync(file, "version: \"0.1\"\nnodes: {oops", "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(1); + expect(stderr).toContain("Error:"); + }); + + it("malformed JSON returns a clear parse error", async () => { + const file = join(tmpRoot, "malformed.json"); + writeFileSync(file, "{ this is not json", "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(1); + expect(stderr).toContain("Error:"); + }); + + it("schema-invalid ToneGraph returns a clear validation error", async () => { + const file = join(tmpRoot, "invalid-schema.yaml"); + // Missing required `nodes` and `routing` fields. + writeFileSync(file, 'version: "0.1"\nmeta:\n name: broken\n', "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(1); + expect(stderr).toContain("nodes"); + }); + + it("unsupported extension returns a clear error", async () => { + const file = join(tmpRoot, "recipe.txt"); + writeFileSync(file, validToneGraphYaml("text"), "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(1); + expect(stderr).toContain("Unsupported file extension"); + }); + + it("inline input without --name returns a clear error", async () => { + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--inline", validToneGraphYaml("inline-noname"))), + ); + expect(code).toBe(1); + expect(stderr).toContain("--name"); + }); + + it("parse failure is reported as JSON when --json is set", async () => { + const file = join(tmpRoot, "bad.json"); + writeFileSync(file, "{ nope", "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--json")), + ); + expect(code).toBe(1); + const json = JSON.parse(stderr); + expect(typeof json.error).toBe("string"); + expect(json.error.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Registration (AC2, AC3) +// --------------------------------------------------------------------------- + +describe("CLI library add — registration", () => { + it("registers a YAML recipe and derives its name from the filename", async () => { + const file = join(tmpRoot, "test-lib-add-yaml.yaml"); + writeFileSync(file, validToneGraphYaml("ignored-meta-name"), "utf-8"); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(0); + expect(stdout).toContain("test-lib-add-yaml"); + + // AC2/AC3: registered in the session registry under the basename. + const registration = registry.getRegistration("test-lib-add-yaml"); + expect(registration).toBeDefined(); + expect(registration?.category).toBe("Test"); + }); + + it("registers a JSON recipe under its basename", async () => { + const file = join(tmpRoot, "test-lib-add-json.json"); + writeFileSync(file, validToneGraphJson("ignored-meta-name"), "utf-8"); + + const { code } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(code).toBe(0); + expect(registry.getRegistration("test-lib-add-json")).toBeDefined(); + }); + + it("registers an inline recipe with an explicit --name", async () => { + const { code } = await captureOutput( + () => main(argv("library", "add", "--inline", validToneGraphYaml("inline-src"), "--name", "test-lib-add-inline")), + ); + expect(code).toBe(0); + expect(registry.getRegistration("test-lib-add-inline")).toBeDefined(); + }); + + it("registers a recipe read from stdin with an explicit --name", async () => { + const { code } = await withStdin( + validToneGraphYaml("stdin-src"), + () => captureOutput( + () => main(argv("library", "add", "--stdin", "--name", "test-lib-add-stdin")), + ), + ); + expect(code).toBe(0); + expect(registry.getRegistration("test-lib-add-stdin")).toBeDefined(); + }); + + it("honours the --name override for --file", async () => { + const file = join(tmpRoot, "test-lib-add-override.yaml"); + writeFileSync(file, validToneGraphYaml("ignored"), "utf-8"); + + const { code } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--name", "test-lib-add-renamed")), + ); + expect(code).toBe(0); + expect(registry.getRegistration("test-lib-add-renamed")).toBeDefined(); + expect(registry.getRegistration("test-lib-add-override")).toBeUndefined(); + }); + + it("emits structured JSON on success with --json", async () => { + const file = join(tmpRoot, "test-lib-add-jsonout.yaml"); + writeFileSync(file, validToneGraphYaml("ignored"), "utf-8"); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--json")), + ); + expect(code).toBe(0); + + const json = JSON.parse(stdout); + expect(json.command).toBe("library add"); + expect(json.name).toBe("test-lib-add-jsonout"); + expect(json.file).toBe(file); + expect(json.category).toBe("Test"); + expect(Array.isArray(json.params)).toBe(true); + expect(json.params[0].name).toBe("frequency"); + }); +}); + +// --------------------------------------------------------------------------- +// Rendering the newly added recipe from the same registry (AC4) +// --------------------------------------------------------------------------- + +describe("CLI library add — generate integration", () => { + it("renders the newly registered recipe from the same registry instance", async () => { + const file = join(tmpRoot, "test-lib-add-render.yaml"); + writeFileSync(file, validToneGraphYaml("ignored"), "utf-8"); + + const addResult = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + expect(addResult.code).toBe(0); + + const outputPath = join(tmpRoot, "rendered.wav"); + const genResult = await captureOutput( + () => main(argv("generate", "--recipe", "test-lib-add-render", "--seed", "42", "--output", outputPath)), + ); + + expect(genResult.code).toBe(0); + expect(existsSync(outputPath)).toBe(true); + }); + + it("reports an unknown recipe before it is added", async () => { + // Sanity check that the name is genuinely new and that lookup is + // registry-backed — rendering an unregistered name fails. + expect(registry.getRegistration("test-lib-add-never-added")).toBeUndefined(); + + const outputPath = join(tmpRoot, "never.wav"); + const { code } = await captureOutput( + () => main(argv("generate", "--recipe", "test-lib-add-never-added", "--seed", "1", "--output", outputPath)), + ); + expect(code).toBe(1); + expect(existsSync(outputPath)).toBe(false); + }); +}); diff --git a/src/cli.library-persist.integration.test.ts b/src/cli.library-persist.integration.test.ts new file mode 100644 index 00000000..3099d870 --- /dev/null +++ b/src/cli.library-persist.integration.test.ts @@ -0,0 +1,435 @@ +/** + * CLI Integration Tests for persistent external recipe registration. + * + * Verifies that `tf library add --file` persists recipes to an external + * directory (AC1/AC2), keeps them discoverable by a separate `tf generate` + * process, is idempotent (AC3), rejects invalid recipes without touching the + * library (AC4), resolves relative/absolute paths (AC6), and surfaces external + * recipes in `tf library list` (AC7). + * + * Work item: TF-0MUVC3DZ10089SAU + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; + +import { main } from "./cli.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** The worktree/project root (this test file lives in `src/`). */ +const PROJECT_ROOT = resolve(import.meta.dirname, ".."); +const CLI_ENTRY = join(PROJECT_ROOT, "bin", "dev-cli.js"); + +/** Capture stdout/stderr written during a CLI invocation. */ +async function captureOutput(fn: () => Promise): Promise<{ + code: number; + stdout: string; + stderr: string; +}> { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + + const origLog = console.log; + const origError = console.error; + const origStdoutWrite = process.stdout.write; + const origStderrWrite = process.stderr.write; + + console.log = (...args: unknown[]) => { + stdoutLines.push(args.map(String).join(" ")); + }; + console.error = (...args: unknown[]) => { + stderrLines.push(args.map(String).join(" ")); + }; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutLines.push(String(chunk).replace(/\n$/, "")); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrLines.push(String(chunk).replace(/\n$/, "")); + return true; + }) as typeof process.stderr.write; + + try { + const code = await fn(); + return { + code, + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + }; + } finally { + console.log = origLog; + console.error = origError; + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; + } +} + +/** Build a fake argv array matching `node cli.ts ...`. */ +function argv(...args: string[]): string[] { + return ["node", "cli.ts", ...args]; +} + +/** A minimal, schema-valid ToneGraph YAML document. */ +function validToneGraphYaml(name: string): string { + return `version: "0.1" +meta: + name: ${name} + description: External persistence test recipe. + category: Test + tags: + - test + - external + duration: 0.1 + parameters: + - name: frequency + type: number + min: 100 + max: 1000 + unit: Hz + default: 440 +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 440 + gain: + kind: gain + params: + gain: 0.5 + out: + kind: destination +routing: + - chain: [osc, gain, out] +`; +} + +let tmpRoot: string; +let externalDir: string; +let originalRecipeDir: string | undefined; + +beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "tf-lib-persist-")); + externalDir = join(tmpRoot, "external-recipes"); + originalRecipeDir = process.env["TONEFORGE_RECIPE_DIR"]; + process.env["TONEFORGE_RECIPE_DIR"] = externalDir; +}); + +afterEach(() => { + if (originalRecipeDir === undefined) { + delete process.env["TONEFORGE_RECIPE_DIR"]; + } else { + process.env["TONEFORGE_RECIPE_DIR"] = originalRecipeDir; + } + try { + rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// --------------------------------------------------------------------------- +// Persistence (AC1, AC2) +// --------------------------------------------------------------------------- + +describe("CLI library add — persistent registration", () => { + it("copies the recipe into TONEFORGE_RECIPE_DIR and persists JSON metadata", async () => { + const file = join(tmpRoot, "persist-env.yaml"); + writeFileSync(file, validToneGraphYaml("persist-env"), "utf-8"); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + + expect(code).toBe(0); + const persisted = join(externalDir, "persist-env.yaml"); + expect(existsSync(persisted)).toBe(true); + expect(readFileSync(persisted, "utf-8")).toBe( + validToneGraphYaml("persist-env"), + ); + expect(stdout).toContain("Persisted to"); + }); + + it("stores the persisted file under the --name override", async () => { + const file = join(tmpRoot, "original-name.yaml"); + writeFileSync(file, validToneGraphYaml("ignored"), "utf-8"); + + const { code } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--name", "renamed-recipe")), + ); + + expect(code).toBe(0); + expect(existsSync(join(externalDir, "renamed-recipe.yaml"))).toBe(true); + expect(existsSync(join(externalDir, "original-name.yaml"))).toBe(false); + }); + + it("honours --destination over TONEFORGE_RECIPE_DIR", async () => { + const file = join(tmpRoot, "custom-dest.yaml"); + writeFileSync(file, validToneGraphYaml("custom-dest"), "utf-8"); + const customDest = join(tmpRoot, "custom-recipes"); + + const { code } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--destination", customDest)), + ); + + expect(code).toBe(0); + expect(existsSync(join(customDest, "custom-dest.yaml"))).toBe(true); + expect(existsSync(join(externalDir, "custom-dest.yaml"))).toBe(false); + }); + + it("emits destination and persistedFile in --json mode", async () => { + const file = join(tmpRoot, "json-dest.yaml"); + writeFileSync(file, validToneGraphYaml("json-dest"), "utf-8"); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--json")), + ); + + expect(code).toBe(0); + const json = JSON.parse(stdout); + expect(json.command).toBe("library add"); + expect(json.destination).toBe(resolve(externalDir)); + expect(json.persistedFile).toBe(resolve(externalDir, "json-dest.yaml")); + expect(existsSync(json.persistedFile)).toBe(true); + }); + + it("resolves a relative --destination against the CWD", async () => { + const file = join(tmpRoot, "relative-dest.yaml"); + writeFileSync(file, validToneGraphYaml("relative-dest"), "utf-8"); + const relativeDest = join("relative-dest-dir"); + + const originalCwd = process.cwd(); + process.chdir(tmpRoot); + try { + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--destination", relativeDest, "--json")), + ); + expect(code).toBe(0); + const json = JSON.parse(stdout); + expect(json.destination).toBe(resolve(tmpRoot, relativeDest)); + expect(existsSync(join(tmpRoot, relativeDest, "relative-dest.yaml"))).toBe(true); + } finally { + process.chdir(originalCwd); + } + }); + + it("persists inline input as YAML to the external directory", async () => { + const { code } = await captureOutput( + () => main(argv("library", "add", "--inline", validToneGraphYaml("inline-external"), "--name", "inline-external")), + ); + + expect(code).toBe(0); + expect(existsSync(join(externalDir, "inline-external.yaml"))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency (AC3) +// --------------------------------------------------------------------------- + +describe("CLI library add — idempotency", () => { + it("re-registering the same recipe twice is a clean no-op", async () => { + const file = join(tmpRoot, "idempotent.yaml"); + writeFileSync(file, validToneGraphYaml("idempotent"), "utf-8"); + + const first = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + const second = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + + expect(first.code).toBe(0); + expect(second.code).toBe(0); + const files = readdirSync(externalDir).filter((f) => f.endsWith(".yaml")); + expect(files).toEqual(["idempotent.yaml"]); + }); + + it("overwrites the persisted file with updated contents", async () => { + const file = join(tmpRoot, "overwrite.yaml"); + writeFileSync(file, validToneGraphYaml("overwrite"), "utf-8"); + + await captureOutput(() => main(argv("library", "add", "--file", file))); + writeFileSync( + file, + validToneGraphYaml("overwrite").replace("category: Test", "category: Updated"), + "utf-8", + ); + await captureOutput(() => main(argv("library", "add", "--file", file))); + + const persisted = readFileSync(join(externalDir, "overwrite.yaml"), "utf-8"); + expect(persisted).toContain("category: Updated"); + expect(readdirSync(externalDir)).toEqual(["overwrite.yaml"]); + }); +}); + +// --------------------------------------------------------------------------- +// Validation and error handling (AC4) +// --------------------------------------------------------------------------- + +describe("CLI library add — validation leaves the library unchanged", () => { + it("rejects a schema-invalid recipe with a non-zero exit and no persisted file", async () => { + const file = join(tmpRoot, "invalid-schema.yaml"); + writeFileSync(file, 'version: "0.1"\nmeta:\n name: broken\n', "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + + expect(code).toBe(1); + expect(stderr).toContain("nodes"); + expect(existsSync(externalDir)).toBe(false); + }); + + it("rejects a malformed recipe without creating the destination directory", async () => { + const file = join(tmpRoot, "malformed.yaml"); + writeFileSync(file, "version: \"0.1\"\nnodes: {oops", "utf-8"); + + const { code } = await captureOutput( + () => main(argv("library", "add", "--file", file)), + ); + + expect(code).toBe(1); + expect(existsSync(externalDir)).toBe(false); + }); + + it("rejects a recipe name containing path separators", async () => { + const file = join(tmpRoot, "safe-file.yaml"); + writeFileSync(file, validToneGraphYaml("safe"), "utf-8"); + + const { code, stderr } = await captureOutput( + () => main(argv("library", "add", "--file", file, "--name", "../escape")), + ); + + expect(code).toBe(1); + expect(stderr).toContain("Invalid recipe name"); + expect(existsSync(externalDir)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Listing (AC7) +// --------------------------------------------------------------------------- + +describe("CLI library list — external recipes", () => { + it("surfaces an externally registered recipe with its source directory (JSON)", async () => { + const file = join(tmpRoot, "listed-external.yaml"); + writeFileSync(file, validToneGraphYaml("listed-external"), "utf-8"); + + await captureOutput(() => main(argv("library", "add", "--file", file))); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "list", "--json")), + ); + + expect(code).toBe(0); + const json = JSON.parse(stdout); + const external = json.recipes.find( + (r: { name: string }) => r.name === "listed-external", + ); + expect(external).toBeDefined(); + expect(external.external).toBe(true); + expect(external.source).toBe(resolve(externalDir)); + expect(json.externalRecipeCount).toBeGreaterThanOrEqual(1); + }); + + it("surfaces external recipes with source and kind in human output", async () => { + const file = join(tmpRoot, "listed-human.yaml"); + writeFileSync(file, validToneGraphYaml("listed-human"), "utf-8"); + + await captureOutput(() => main(argv("library", "add", "--file", file))); + + const { code, stdout } = await captureOutput( + () => main(argv("library", "list")), + ); + + expect(code).toBe(0); + expect(stdout).toContain("listed-human"); + expect(stdout).toContain("external"); + // The source column word-wraps long paths, so assert on the intact + // temp-root prefix and the "external" kind rather than the full path. + expect(stdout).toContain(tmpRoot); + expect(stdout).toContain("external"); + }); +}); + +// --------------------------------------------------------------------------- +// Separate process discovery (AC1) — the core requirement +// --------------------------------------------------------------------------- + +describe("CLI library add — separate process discovery", () => { + it( + "a fresh `tf generate` process discovers and renders a persisted recipe", + () => { + const recipeFile = join(tmpRoot, "game-weapon.yaml"); + writeFileSync(recipeFile, validToneGraphYaml("game-weapon"), "utf-8"); + + const childEnv = { + ...process.env, + TONEFORGE_RECIPE_DIR: externalDir, + }; + + // Process 1: register (persist) the recipe. + execFileSync( + process.execPath, + [CLI_ENTRY, "library", "add", "--file", recipeFile, "--json"], + { cwd: PROJECT_ROOT, env: childEnv, encoding: "utf-8", timeout: 120_000 }, + ); + expect(existsSync(join(externalDir, "game-weapon.yaml"))).toBe(true); + + // Process 2: a completely fresh process renders the recipe. + const outputPath = join(tmpRoot, "game-weapon.wav"); + execFileSync( + process.execPath, + [ + CLI_ENTRY, + "generate", + "--recipe", + "game-weapon", + "--seed", + "42", + "--output", + outputPath, + ], + { cwd: PROJECT_ROOT, env: childEnv, encoding: "utf-8", timeout: 120_000 }, + ); + + expect(existsSync(outputPath)).toBe(true); + const wav = readFileSync(outputPath); + // RIFF/WAVE header sanity check. + expect(wav.subarray(0, 4).toString("ascii")).toBe("RIFF"); + expect(wav.subarray(8, 12).toString("ascii")).toBe("WAVE"); + }, + 180_000, + ); +}); + +// --------------------------------------------------------------------------- +// Help documentation (AC9) +// --------------------------------------------------------------------------- + +describe("CLI library add — help documentation", () => { + it("documents --destination, TONEFORGE_RECIPE_DIR and a worked example", async () => { + const { code, stdout } = await captureOutput( + () => main(argv("library", "add", "--help")), + ); + expect(code).toBe(0); + expect(stdout).toContain("--destination"); + expect(stdout).toContain("TONEFORGE_RECIPE_DIR"); + expect(stdout).toContain("~/.toneforge/recipes"); + expect(stdout).toContain("generate --recipe"); + }); +}); diff --git a/src/cli.runtime.ts b/src/cli.runtime.ts index 3ae2c0da..c33a3281 100644 --- a/src/cli.runtime.ts +++ b/src/cli.runtime.ts @@ -22,10 +22,10 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { existsSync, realpathSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, basename, extname } from "node:path"; import { execFile } from "node:child_process"; import { renderRecipe } from "./core/renderer.js"; -import { registry } from "./recipes/index.js"; +import { registry, initializeRecipeRegistry } from "./recipes/index.js"; import { playAudio, getPlayerCommand } from "./audio/player.js"; import { encodeWav } from "./audio/wav-encoder.js"; import { VERSION } from "./index.js"; @@ -77,6 +77,7 @@ import { findSimilar } from "./library/similarity.js"; import { exportEntries } from "./library/export.js"; import { regenerateEntry } from "./library/regenerate.js"; import { loadSequencePreset, validateSequencePresetFile } from "./sequence/preset-loader.js"; +import { validateToneGraph } from "./core/tonegraph-schema.js"; import { simulate, formatTimeline } from "./sequence/simulator.js"; import { renderSequence } from "./sequence/renderer.js"; import type { SequenceDefinition } from "./sequence/schema.js"; @@ -772,6 +773,7 @@ Browse, search, discover similar sounds, export WAVs, and regenerate entries fro | **similar** | Find entries similar to a given entry | | **export** | Export library entries as WAV files by category | | **regenerate** | Re-render an entry from its stored preset | +| **add** | Register a new ToneGraph recipe and persist it to an external directory | ## Usage @@ -781,8 +783,28 @@ toneforge library search [--category ] [--intensity ] [--texture ] [--t toneforge library similar --id [--limit ] [--json] toneforge library export --output [--category ] --format wav [--json] toneforge library regenerate --id [--json] +toneforge library add --file [--destination ] [--name ] [--json] +toneforge library add --inline --name [--destination ] [--json] +toneforge library add --stdin --name [--destination ] [--json] \`\`\` +## External recipe registration + +\`library add\` persists the recipe to an **external recipe directory** so a +separate \`toneforge generate --recipe \` process discovers it. The +directory is resolved in this order: + +1. \`--destination \` (single-operation override) +2. the \`TONEFORGE_RECIPE_DIR\` environment variable +3. \`~/.toneforge/recipes/\` (platform-agnostic default) + +Relative paths for \`--file\`, \`--destination\` and \`TONEFORGE_RECIPE_DIR\` are +resolved against the current working directory; absolute paths are used as-is. +The recipe is written atomically (temp file + rename) and re-registering the +same recipe is an idempotent overwrite. Externally registered recipes are +discovered alongside the baked-in \`presets/recipes/\` recipes and are shown by +\`library list\` with their source directory. + ## Options - \`--category \` — Filter by category @@ -793,6 +815,11 @@ toneforge library regenerate --id [--json] - \`--limit \` — Max results for similarity search (default: 10) - \`--output \` — Output directory for export - \`--format wav\` — Export format (currently only wav) +- \`--file \` — Path to a ToneGraph YAML or JSON file (add subcommand) +- \`--inline \` — Inline ToneGraph YAML/JSON definition (add subcommand) +- \`--stdin\` — Read the ToneGraph definition from stdin (add subcommand) +- \`--name \` — Override recipe name (defaults to the filename without extension for \`--file\`; required for \`--inline\`/\`--stdin\`) +- \`--destination \` — External directory to persist the recipe into (add subcommand; default: \`TONEFORGE_RECIPE_DIR\` or \`~/.toneforge/recipes/\`) - \`--json\` — Output results in JSON format - \`--help\`, \`-h\` — Show this help message @@ -805,6 +832,13 @@ toneforge library search --intensity high --tags hit,impact toneforge library similar --id lib-impact-crack_seed-00042 --limit 5 toneforge library export --output ./export --category creature --format wav toneforge library regenerate --id lib-creature-vocal_seed-04821 --json +toneforge library add --file ./my-recipe.yaml +toneforge library add --file ./my-recipe.yaml --name custom-name --json +toneforge library add --file ./game-weapon.yaml --destination ./project-recipes + +# Persist to a custom location, then render it in a separate process +TONEFORGE_RECIPE_DIR=./project-recipes toneforge library add --file ./game-weapon.yaml +toneforge generate --recipe game-weapon --seed 42 --output ./game-weapon.wav \`\`\``; await outputMarkdown(md); } @@ -1225,6 +1259,10 @@ export async function dispatchCommand( flags: Record, layers: string[], ): Promise { + // Load file-backed ToneGraph recipes before any command runs. Discovery is + // Node-only and idempotent; in non-Node runtimes it resolves to a no-op. + await initializeRecipeRegistry(); + const jsonMode = flags["json"] === true; // Enable profiling when --profile flag is set @@ -3077,6 +3115,21 @@ export async function dispatchCommand( const filter = categoryFilter ? { category: categoryFilter } : undefined; const entries = await listEntries(filter); + // Recipe catalogue: baked-in + externally registered recipes (AC7). + // `sourceDirectory` is only set for file-backed recipes, so built-in + // TypeScript recipes are reported as `built-in`. + const recipeCatalog = registry.list().map((name) => { + const reg = registry.getRegistration(name); + return { + name, + category: reg?.category ?? "", + description: reg?.description ?? "", + source: reg?.sourceDirectory ?? null, + external: reg?.external === true, + }; + }); + const externalRecipeCount = recipeCatalog.filter((r) => r.external).length; + if (jsonMode) { jsonOut({ command: "library list", @@ -3091,6 +3144,9 @@ export async function dispatchCommand( tags: e.tags, promotedAt: e.promotedAt, })), + recipeCount: recipeCatalog.length, + externalRecipeCount, + recipes: recipeCatalog, }); } else if (entries.length === 0) { if (categoryFilter) { @@ -3117,6 +3173,25 @@ export async function dispatchCommand( ); outputInfo(`${entries.length} entr${entries.length !== 1 ? "ies" : "y"} listed`); } + + if (!jsonMode && recipeCatalog.length > 0) { + outputInfo(""); + outputInfo(`Recipes (${recipeCatalog.length} total, ${externalRecipeCount} external):`); + outputTable( + [ + { header: "Recipe", width: 28 }, + { header: "Category", width: 14 }, + { header: "Kind", width: 10 }, + { header: "Source", width: 36 }, + ], + recipeCatalog.map((r) => [ + r.name, + r.category || "\u2014", + r.source ? (r.external ? "external" : "baked-in") : "built-in", + r.source ?? "\u2014", + ]), + ); + } return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -3372,6 +3447,179 @@ export async function dispatchCommand( } } + // ── library add ─────────────────────────────────────────── + if (subcommand === "add") { + if (flags["help"]) { + await printLibraryHelp(); + return 0; + } + + const fileFlag = typeof flags["file"] === "string" ? flags["file"] : undefined; + const inlineFlag = typeof flags["inline"] === "string" ? flags["inline"] : undefined; + const stdinFlag = flags["stdin"] === true; + const nameOverride = typeof flags["name"] === "string" ? flags["name"] : undefined; + const destinationFlag = typeof flags["destination"] === "string" ? flags["destination"] : undefined; + + // Exactly one input source must be provided. + const sourcesProvided = [fileFlag !== undefined, inlineFlag !== undefined, stdinFlag] + .filter(Boolean).length; + if (sourcesProvided === 0) { + const msg = "An input source is required: --file , --inline , or --stdin. Run 'toneforge library add --help' for usage."; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + if (sourcesProvided > 1) { + const msg = "Only one input source may be provided: --file, --inline, or --stdin."; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + + try { + // Lazily load js-yaml; YAML is a superset of JSON so a single loader + // handles both inline and stdin input without an extension hint. + const loadYaml = async (): Promise<(input: string) => unknown> => { + const yamlModule = await import("js-yaml"); + const yamlLoad = (yamlModule as { load?: (input: string) => unknown; default?: { load?: (input: string) => unknown } }).load + ?? (yamlModule as { default?: { load?: (input: string) => unknown } }).default?.load; + if (yamlLoad === undefined) { + throw new Error("js-yaml load function is unavailable."); + } + return yamlLoad; + }; + + let rawDoc: unknown; + let recipeName: string | undefined; + let sourceLabel: string; + let sourceText: string; + // Extension used when persisting the recipe to the external directory. + // File-backed recipes keep their on-disk extension; inline/stdin + // recipes are stored as YAML (a superset of JSON). + let persistExtension = ".yaml"; + + if (fileFlag !== undefined) { + const resolvedPath = resolve(fileFlag); + if (!existsSync(resolvedPath)) { + const msg = `File not found: ${fileFlag}`; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + + const source = await readFile(resolvedPath, "utf-8"); + const ext = extname(resolvedPath).toLowerCase(); + persistExtension = ext; + + if (ext === ".json") { + rawDoc = JSON.parse(source); + } else if (ext === ".yaml" || ext === ".yml") { + rawDoc = (await loadYaml())(source); + } else { + const msg = `Unsupported file extension '${ext || "(none)"}'. Use .json, .yaml, or .yml.`; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + + sourceText = source; + // Derive the recipe name from the filename (basename without extension). + recipeName = nameOverride ?? basename(resolvedPath, extname(resolvedPath)); + sourceLabel = fileFlag; + } else if (inlineFlag !== undefined) { + rawDoc = (await loadYaml())(inlineFlag); + sourceText = inlineFlag; + sourceLabel = "inline"; + } else { + // Read the ToneGraph definition from stdin. + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk as Buffer); + } + const stdinSource = Buffer.concat(chunks).toString("utf-8"); + if (stdinSource.trim().length === 0) { + const msg = "No input received on stdin."; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + rawDoc = (await loadYaml())(stdinSource); + sourceText = stdinSource; + sourceLabel = "stdin"; + } + + // Validate the ToneGraph document. + const graph = validateToneGraph(rawDoc); + + if (recipeName === undefined) { + recipeName = nameOverride; + } + if (recipeName === undefined) { + const msg = "A recipe name is required for inline/stdin input. Pass --name ."; + if (jsonMode) { jsonErr(msg); } else { outputError(`Error: ${msg}`); } + return 1; + } + + const { + assertSafeRecipeName, + createFileBackedRegistration, + persistRecipeDocument, + resolveExternalRecipeDirectory, + } = await import("./core/recipe.js"); + + assertSafeRecipeName(recipeName); + + // Persist the recipe to the external directory BEFORE registering so + // a failed write leaves the registry unchanged (AC4). The destination + // is `--destination`, else `TONEFORGE_RECIPE_DIR`, else + // `~/.toneforge/recipes/` (AC1, AC2, AC6). + const destinationDirectory = await resolveExternalRecipeDirectory({ + destination: destinationFlag, + }); + const persistResult = await persistRecipeDocument({ + contents: sourceText, + destinationDirectory, + fileName: `${recipeName}${persistExtension}`, + }); + + // Create a file-backed registration from the validated ToneGraph document. + const registration = createFileBackedRegistration(recipeName, graph, rawDoc); + + // Register in the session registry, recording the external source so + // `library list` surfaces the recipe immediately as external (AC7). + registry.register(recipeName, { + ...registration, + sourceDirectory: persistResult.destinationDirectory, + external: true, + }); + + if (jsonMode) { + jsonOut({ + command: "library add", + name: recipeName, + file: fileFlag ?? null, + source: sourceLabel, + destination: persistResult.destinationDirectory, + persistedFile: persistResult.destinationPath, + category: registration.category, + description: registration.description, + params: registration.params, + tags: registration.tags ?? [], + }); + } else { + outputSuccess(`Registered recipe '${recipeName}' from ${sourceLabel}`); + outputInfo(` Category: ${registration.category}`); + outputInfo(` Description: ${registration.description}`); + outputInfo(` Parameters: ${registration.params.map(p => p.name).join(", ") || "(none)"}`); + outputInfo(` Signal chain: ${registration.signalChain}`); + if (registration.tags && registration.tags.length > 0) { + outputInfo(` Tags: ${registration.tags.join(", ")}`); + } + outputInfo(` Persisted to: ${persistResult.destinationPath}`); + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (jsonMode) { jsonErr(message); } else { outputError(`Error: ${message}`); } + return 1; + } + } + // Unknown subcommand if (subcommand !== undefined) { const msg = `Unknown library subcommand '${subcommand}'. Run 'toneforge library --help' for usage.`; diff --git a/src/cli.yargs.integration.test.ts b/src/cli.yargs.integration.test.ts index 1d144254..a7984c09 100644 --- a/src/cli.yargs.integration.test.ts +++ b/src/cli.yargs.integration.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { runCli } from "../test/run-yargs-child.js"; +/** Preset directory relative to the repository root. */ +const PROJECT_ROOT = join(import.meta.dirname, ".."); + describe("yargs CLI entrypoint integration", () => { let tempDir: string; @@ -228,4 +231,118 @@ describe("yargs CLI entrypoint integration", () => { expect(code).toBe(1); expect(stderr).toContain("requires an interactive terminal"); }); + + // ----------------------------------------------------------------- + // New resource types: sequences + // ----------------------------------------------------------------- + + it("supports list sequences --json via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "sequences", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.command).toBe("list"); + expect(data.resource).toBe("sequences"); + expect(typeof data.total).toBe("number"); + expect(data.total).toBeGreaterThan(0); + expect(Array.isArray(data.presets)).toBe(true); + expect(data.presets.length).toBe(data.total); + expect(data.presets[0]).toHaveProperty("name"); + expect(data.presets[0]).toHaveProperty("description"); + }); + + it("supports list sequences text output via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "sequences"]); + expect(code).toBe(0); + expect(stdout).toContain("Sequence"); + expect(stdout).toContain("Description"); + }); + + it("supports list sequences --search filter via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "sequences", "--search", "combo", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.resource).toBe("sequences"); + expect(data.presets.every((p: { name: string }) => + p.name.toLowerCase().includes("combo") || p.description.toLowerCase().includes("combo"), + )).toBe(true); + expect(data.presets.length).toBeGreaterThan(0); + }); + + it("supports list sequences --search with no results", async () => { + const { code, stdout } = await runCli(["list", "sequences", "--search", "zzzznotfound", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.total).toBeGreaterThan(0); + expect(data.presets.length).toBe(0); + }); + + it("returns non-zero for unknown resource", async () => { + const { code, stderr } = await runCli(["list", "nonexistent_resource", "--json"]); + expect(code).toBe(1); + const data = JSON.parse(stderr); + expect(typeof data.error).toBe("string"); + expect(data.error).toContain("Unknown resource"); + }); + + // ----------------------------------------------------------------- + // New resource types: stacks + // ----------------------------------------------------------------- + + it("supports list stacks --json via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "stacks", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.command).toBe("list"); + expect(data.resource).toBe("stacks"); + expect(typeof data.total).toBe("number"); + expect(data.total).toBeGreaterThan(0); + expect(Array.isArray(data.presets)).toBe(true); + expect(data.presets.length).toBe(data.total); + expect(data.presets[0]).toHaveProperty("name"); + expect(data.presets[0]).toHaveProperty("description"); + }); + + it("supports list stacks text output via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "stacks"]); + expect(code).toBe(0); + expect(stdout).toContain("Stack"); + expect(stdout).toContain("Description"); + }); + + it("supports list stacks --search filter via yargs entrypoint", async () => { + const { code, stdout } = await runCli(["list", "stacks", "--search", "victory", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.resource).toBe("stacks"); + expect(data.presets.every((p: { name: string }) => + p.name.toLowerCase().includes("victory") || p.description.toLowerCase().includes("victory"), + )).toBe(true); + expect(data.presets.length).toBeGreaterThan(0); + }); + + it("supports list stacks --search with no results", async () => { + const { code, stdout } = await runCli(["list", "stacks", "--search", "zzzznotfound", "--json"]); + expect(code).toBe(0); + const data = JSON.parse(stdout); + expect(data.total).toBeGreaterThan(0); + expect(data.presets.length).toBe(0); + }); + + it("returns non-zero for a malformed preset file", async () => { + const malformedPath = join(PROJECT_ROOT, "presets", "sequences", "__malformed_test__.json"); + writeFileSync(malformedPath, "{ this is not valid json"); + try { + const { code, stderr } = await runCli(["list", "sequences", "--json"]); + expect(code).toBe(1); + const data = JSON.parse(stderr); + expect(data.error).toContain("Malformed"); + expect(data.error).toContain("__malformed_test__.json"); + } finally { + try { + unlinkSync(malformedPath); + } catch { + // best-effort cleanup + } + } + }); }); diff --git a/src/cli.yargs.ts b/src/cli.yargs.ts index d3793e7e..b01b2071 100644 --- a/src/cli.yargs.ts +++ b/src/cli.yargs.ts @@ -383,6 +383,21 @@ export async function yargsMain(argv: string[] = process.argv): Promise ...(argv.id !== undefined ? { id: String(argv.id) } : {}), }), []); }); + y2.command("add", "Register a new ToneGraph recipe from an external file", (y3) => { + y3.option("file", { type: "string", describe: "Path to a ToneGraph YAML or JSON file" }) + .option("inline", { type: "string", describe: "Inline ToneGraph YAML/JSON definition" }) + .option("stdin", { type: "boolean", describe: "Read the ToneGraph definition from stdin" }) + .option("name", { type: "string", describe: "Override recipe name (defaults to filename without extension)" }) + .option("json", { type: "boolean", describe: "Output JSON" }); + }, async (argv) => { + exitCode = await dispatchCommand("library", "add", buildFlags(argv, { + ...(argv.file !== undefined ? { file: String(argv.file) } : {}), + ...(argv.inline !== undefined ? { inline: String(argv.inline) } : {}), + ...(argv.stdin === true ? { stdin: true } : {}), + ...(argv.name !== undefined ? { name: String(argv.name) } : {}), + ...(argv.destination !== undefined ? { destination: String(argv.destination) } : {}), + }), []); + }); }, async (_argv) => { // Re-parse raw argv so dispatchCommand receives the unknown subcommand name // for proper error output. yargs only calls this handler when no subcommand diff --git a/src/cli/commands/library.ts b/src/cli/commands/library.ts index 5c3c6d62..18a57fb3 100644 --- a/src/cli/commands/library.ts +++ b/src/cli/commands/library.ts @@ -28,5 +28,13 @@ export function builder(yargs: any) { .command("regenerate", "Regenerate a library entry", (y: any) => { y.option("id", { type: "string", describe: "Entry ID to regenerate" }) .option("json", { type: "boolean", describe: "Output JSON" }); + }) + .command("add", "Register a new ToneGraph recipe from an external file", (y: any) => { + y.option("file", { type: "string", describe: "Path to a ToneGraph YAML or JSON file" }) + .option("inline", { type: "string", describe: "Inline ToneGraph YAML/JSON definition" }) + .option("stdin", { type: "boolean", describe: "Read the ToneGraph definition from stdin" }) + .option("name", { type: "string", describe: "Override recipe name (defaults to filename without extension)" }) + .option("destination", { type: "string", describe: "External directory to persist the recipe into (default: TONEFORGE_RECIPE_DIR or ~/.toneforge/recipes/)" }) + .option("json", { type: "boolean", describe: "Output JSON" }); }); } diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 8e12c323..1ea0f7b7 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -1,8 +1,56 @@ import type { Arguments } from "yargs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; import { outputInfo, outputTable, outputError, isStdoutTty } from "../../output.js"; import { registry } from "../../recipes/index.js"; import { truncateTags } from "../../cli/helpers.js"; +/** Absolute path to the repository root. */ +const PROJECT_ROOT = join(import.meta.dirname, "../../.."); + +/** Known resource types that the list command supports. */ +const VALID_RESOURCES = ["recipes", "sequences", "stacks"] as const; +type ValidResource = (typeof VALID_RESOURCES)[number]; + +/** Base directory for preset files by resource type. */ +const RESOURCE_DIR: Record, string> = { + sequences: join(PROJECT_ROOT, "presets", "sequences"), + stacks: join(PROJECT_ROOT, "presets", "stacks"), +}; + +/** Minimal preset shape extracted from JSON files. */ +interface PresetEntry { + name: string; + description: string; +} + +/** + * Scan a preset directory and parse each JSON file to extract name and + * description. Malformed files are collected and reported by the caller so + * the command can exit non-zero (AC #4). + */ +async function listPresets(dir: string): Promise<{ presets: PresetEntry[]; malformed: string[] }> { + const presets: PresetEntry[] = []; + const malformed: string[] = []; + const files = await readdir(dir); + + for (const file of files.sort()) { + if (!file.endsWith(".json")) continue; + const filePath = join(dir, file); + try { + const raw = await readFile(filePath, "utf-8"); + const parsed: Record = JSON.parse(raw); + const name = typeof parsed.name === "string" ? parsed.name : file.replace(/\.json$/, ""); + const description = typeof parsed.description === "string" ? parsed.description : ""; + presets.push({ name, description }); + } catch (err) { + malformed.push(`${file} (${err instanceof Error ? err.message : String(err)})`); + } + } + + return { presets, malformed }; +} + export const command = "list [resource]"; export const desc = "List available resources"; @@ -10,7 +58,7 @@ export function builder(yargs: any) { return yargs .positional("resource", { type: "string", - describe: "Resource type to list (e.g. recipes)", + describe: "Resource type to list: recipes, sequences, or stacks", default: "recipes", }) .option("search", { type: "string", describe: "Search filter" }) @@ -24,7 +72,7 @@ export async function handler(argv: Arguments) { const jsonMode = argv.json === true; const res = (resource || "recipes").toString(); - if (res !== "recipes") { + if (!(VALID_RESOURCES as readonly string[]).includes(res)) { if (jsonMode) { // eslint-disable-next-line no-console console.error(JSON.stringify({ error: `Unknown resource: ${res}` })); @@ -34,6 +82,106 @@ export async function handler(argv: Arguments) { return 1; } + const rawSearch = typeof argv.search === "string" ? argv.search : undefined; + const search = rawSearch && rawSearch.trim().length > 0 ? rawSearch.trim().toLowerCase() : undefined; + + if (res === "sequences" || res === "stacks") { + return handlePresets(res, RESOURCE_DIR[res], search, rawSearch, jsonMode); + } + + return handleRecipes(argv, search, rawSearch, jsonMode); +} + +/** Handle the `list sequences` and `list stacks` resource types. */ +async function handlePresets( + resource: "sequences" | "stacks", + dir: string, + search: string | undefined, + rawSearch: string | undefined, + jsonMode: boolean, +): Promise { + let presets: PresetEntry[]; + let malformed: string[]; + try { + ({ presets, malformed } = await listPresets(dir)); + } catch (err) { + const message = `Failed to read ${resource} directory: ${err instanceof Error ? err.message : String(err)}`; + if (jsonMode) { + // eslint-disable-next-line no-console + console.error(JSON.stringify({ error: message })); + } else { + outputError(message); + } + return 1; + } + + if (malformed.length > 0) { + const message = `Malformed ${resource} preset file(s): ${malformed.join(", ")}`; + if (jsonMode) { + // eslint-disable-next-line no-console + console.error(JSON.stringify({ error: message })); + } else { + outputError(message); + } + return 1; + } + + const total = presets.length; + const filtered = search + ? presets.filter( + (p) => p.name.toLowerCase().includes(search) || p.description.toLowerCase().includes(search), + ) + : presets; + + if (jsonMode) { + const out: any = { + command: "list", + resource, + total, + presets: filtered.map((p) => ({ name: p.name, description: p.description })), + }; + if (search) out.filters = { search: rawSearch }; + // eslint-disable-next-line no-console + console.log(JSON.stringify(out, null, 2)); + return 0; + } + + if (filtered.length === 0) { + if (search) { + outputInfo(`Found 0 of ${total} ${resource}`); + } else { + outputInfo(`Showing 0 ${resource}`); + } + return 0; + } + + const label = resource === "sequences" ? "Sequence" : "Stack"; + const rows = filtered.map((p) => [p.name, p.description.replace(/\s+/g, " ").trim()]); + + outputTable( + [ + { header: label, width: 30 }, + { header: "Description", width: 60 }, + ], + rows, + ); + + if (search) { + outputInfo(`Found ${filtered.length} of ${total} ${resource}`); + } else { + outputInfo(`Showing ${filtered.length} ${resource}`); + } + + return 0; +} + +/** Handle the existing `list recipes` resource type. */ +function handleRecipes( + argv: Arguments, + search: string | undefined, + rawSearch: string | undefined, + jsonMode: boolean, +): number { // Gather registry entries const names = registry.list(); const total = names.length; @@ -49,8 +197,6 @@ export async function handler(argv: Arguments) { }); // Parse filters - const rawSearch = typeof argv.search === "string" ? argv.search : undefined; - const search = rawSearch && rawSearch.trim().length > 0 ? rawSearch.trim().toLowerCase() : undefined; const rawCategory = typeof argv.category === "string" ? argv.category : undefined; const categoryNorm = rawCategory ? rawCategory.toLowerCase().replace(/\s+/g, "-") : undefined; const rawTags = typeof argv.tags === "string" ? argv.tags : undefined; diff --git a/src/core/recipe-overrides.test.ts b/src/core/recipe-overrides.test.ts new file mode 100644 index 00000000..e1cb3df0 --- /dev/null +++ b/src/core/recipe-overrides.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect } from "vitest"; +import { OfflineAudioContext } from "node-web-audio-api"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyFileBackedMappings, + parseFileBackedMappings, + type FileBackedParameterMapping, +} from "./recipe-overrides.js"; +import { validateToneGraph, type ToneGraphDocument } from "./tonegraph-schema.js"; +import { + createFileBackedRegistration, + discoverFileBackedRecipes, + RecipeRegistry, + type RecipeRegistration, +} from "./recipe.js"; +import { createRng } from "./rng.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; + +const SAMPLE_RATE = 44100; + +/** Build a minimal validated graph for mapping tests. */ +function makeGraph(overrides: Record = {}): ToneGraphDocument { + const raw = { + version: "0.1", + meta: { + name: "mapping-fixture", + duration: 0.2, + parameters: [ + { name: "frequency", type: "number", min: 200, max: 800, default: 440 }, + { name: "ratio", type: "number", min: 1, max: 4, default: 2 }, + { name: "depthEnd", type: "number", min: 0, max: 1, default: 0.5 }, + ], + ...overrides, + }, + nodes: { + osc: { + kind: "oscillator", + params: { type: "sine", frequency: 440 }, + }, + amp: { + kind: "gain", + params: { gain: 0.25 }, + automation: { + gain: [ + { kind: "set", time: 0, value: 0.25 }, + { kind: "linearRamp", time: 0.2, value: 0.5 }, + ], + }, + }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "amp", "out"] }], + }; + return validateToneGraph(raw); +} + +const DERIVED: Record = { + frequency: 440, + ratio: 2, + depthEnd: 0.5, +}; + +describe("parseFileBackedMappings", () => { + it("returns an empty list when no mappings are declared", () => { + expect(parseFileBackedMappings({ version: "0.1" })).toEqual([]); + expect( + parseFileBackedMappings({ + meta: { parameters: [{ name: "frequency", type: "number" }] }, + }), + ).toEqual([]); + }); + + it("parses direct, automation and computed targets", () => { + const mappings = parseFileBackedMappings({ + meta: { + parameters: [ + { + name: "frequency", + type: "number", + overrides: [ + { target: "osc.frequency" }, + { target: "amp.automation.gain.linearRamp.value" }, + { target: "osc.frequency", expression: "frequency * ratio" }, + ], + }, + ], + }, + }); + + expect(mappings).toEqual([ + { + parameter: "frequency", + overrides: [ + { target: "osc.frequency", expression: undefined }, + { target: "amp.automation.gain.linearRamp.value", expression: undefined }, + { target: "osc.frequency", expression: "frequency * ratio" }, + ], + }, + ]); + }); + + it("rejects a non-array overrides declaration", () => { + expect(() => + parseFileBackedMappings({ + meta: { parameters: [{ name: "frequency", overrides: "nope" }] }, + }), + ).toThrow(/must be an array/); + }); + + it("rejects a target without a non-empty target string", () => { + expect(() => + parseFileBackedMappings({ + meta: { parameters: [{ name: "frequency", overrides: [{}] }] }, + }), + ).toThrow(/requires a non-empty "target"/); + }); + + it("rejects a non-string expression", () => { + expect(() => + parseFileBackedMappings({ + meta: { + parameters: [ + { name: "frequency", overrides: [{ target: "osc.frequency", expression: 5 }] }, + ], + }, + }), + ).toThrow(/non-string "expression"/); + }); +}); + +describe("applyFileBackedMappings", () => { + it("is a no-op when no mappings are supplied", () => { + const cloned = makeGraph(); + const before = JSON.stringify(cloned); + applyFileBackedMappings([], DERIVED, cloned); + expect(JSON.stringify(cloned)).toBe(before); + }); + + it("sets a node param directly from the parameter value", () => { + const cloned = makeGraph(); + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "osc.frequency" }] }], + { ...DERIVED, frequency: 660 }, + cloned, + ); + expect((cloned.nodes.osc as { params?: { frequency?: number } }).params?.frequency).toBe(660); + }); + + it("sets an automation event field selected by kind", () => { + const cloned = makeGraph(); + applyFileBackedMappings( + [ + { + parameter: "depthEnd", + overrides: [{ target: "amp.automation.gain.linearRamp.value" }], + }, + ], + { ...DERIVED, depthEnd: 0.9 }, + cloned, + ); + const automation = (cloned.nodes.amp as { + automation?: Record>>; + }).automation; + expect(automation?.gain?.[1]?.value).toBe(0.9); + }); + + it("sets an automation event field selected by index", () => { + const cloned = makeGraph(); + applyFileBackedMappings( + [{ parameter: "depthEnd", overrides: [{ target: "amp.automation.gain.0.value" }] }], + { ...DERIVED, depthEnd: 0.1 }, + cloned, + ); + const automation = (cloned.nodes.amp as { + automation?: Record>>; + }).automation; + expect(automation?.gain?.[0]?.value).toBe(0.1); + }); + + it("evaluates a computed mapping against a parameter and a node field", () => { + const cloned = makeGraph(); + applyFileBackedMappings( + [ + { + parameter: "ratio", + overrides: [{ target: "osc.frequency", expression: "frequency * ratio" }], + }, + ], + { ...DERIVED, frequency: 300, ratio: 3 }, + cloned, + ); + expect((cloned.nodes.osc as { params?: { frequency?: number } }).params?.frequency).toBe(900); + }); + + it("evaluates a division mapping against a constant", () => { + const cloned = makeGraph(); + // depth = depthEnd / 2 written back onto the param field. + applyFileBackedMappings( + [{ parameter: "depthEnd", overrides: [{ target: "amp.gain", expression: "depthEnd / 2" }] }], + { ...DERIVED, depthEnd: 0.5 }, + cloned, + ); + expect((cloned.nodes.amp as { params?: { gain?: number } }).params?.gain).toBe(0.25); + }); + + it("resolves computed mappings against post-override parameter values regardless of order", () => { + // The mapping for `ratio` is declared before the mapping for `frequency`, + // yet both must see the overridden `frequency` value. + const cloned = makeGraph(); + const mappings: FileBackedParameterMapping[] = [ + { + parameter: "ratio", + overrides: [{ target: "osc.frequency", expression: "frequency * ratio" }], + }, + { + parameter: "frequency", + overrides: [{ target: "amp.gain", expression: "frequency / 1000" }], + }, + ]; + applyFileBackedMappings(mappings, { ...DERIVED, frequency: 500, ratio: 2 }, cloned); + + const oscFreq = (cloned.nodes.osc as { params?: { frequency?: number } }).params?.frequency; + const ampGain = (cloned.nodes.amp as { params?: { gain?: number } }).params?.gain; + expect(oscFreq).toBe(1000); + expect(ampGain).toBe(0.5); + }); + + it("overwrites a value already written by the heuristic (mapping wins)", () => { + const cloned = makeGraph(); + // Simulate the heuristic having written the parameter value directly. + (cloned.nodes.amp as { params?: { gain?: number } }).params!.gain = 0.5; + applyFileBackedMappings( + [{ parameter: "depthEnd", overrides: [{ target: "amp.gain", expression: "depthEnd / 2" }] }], + { ...DERIVED, depthEnd: 0.5 }, + cloned, + ); + expect((cloned.nodes.amp as { params?: { gain?: number } }).params?.gain).toBe(0.25); + }); + + it("throws for an unknown parameter", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "doesNotExist", overrides: [{ target: "osc.frequency" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/unknown parameter "doesNotExist"/); + }); + + it("throws for an unknown node", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "missing.frequency" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/unknown node "missing"/); + }); + + it("throws for an unknown node param field", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "osc.unknownField" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/unknown param field "unknownField"/); + }); + + it("throws for an unknown automation param", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "amp.automation.nope.0.value" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/unknown automation param "nope"/); + }); + + it("throws for an unknown automation field", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "amp.automation.gain.0.unknown" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/unknown automation field "unknown"/); + }); + + it("throws for a malformed target path", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "osc" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/Invalid override target/); + }); + + it("throws when an expression references an unknown symbol", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "osc.frequency", expression: "frequency * nope" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/Unknown reference "nope"/); + }); + + it("throws on division by zero", () => { + expect(() => + applyFileBackedMappings( + [{ parameter: "frequency", overrides: [{ target: "osc.frequency", expression: "frequency / 0" }] }], + DERIVED, + makeGraph(), + ), + ).toThrow(/Division by zero/); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: mappings applied through createFileBackedRegistration +// --------------------------------------------------------------------------- + +function makeRawDoc(options: { + overrides?: unknown; + ampGain?: number; + paramName?: string; + paramDefault?: number; +} = {}): Record { + const paramName = options.paramName ?? "level"; + const paramDefault = options.paramDefault ?? 0.25; + const parameter: Record = { + name: paramName, + type: "number", + min: 0.1, + max: 0.5, + default: paramDefault, + }; + if (options.overrides !== undefined) { + parameter.overrides = options.overrides; + } + + return { + version: "0.1", + meta: { + name: "mapping-integration", + duration: 0.1, + parameters: [parameter], + }, + nodes: { + dc: { kind: "constant", params: { value: 1 } }, + amp: { kind: "gain", params: { gain: options.ampGain ?? 0.001 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["dc", "amp", "out"] }], + }; +} + +async function renderRegistration( + registration: RecipeRegistration, + seed: number, + overrides: Record, +): Promise { + const duration = registration.getDuration(createRng(seed), overrides); + const ctx = new OfflineAudioContext(1, Math.ceil(SAMPLE_RATE * duration), SAMPLE_RATE); + await registration.buildOfflineGraph(createRng(seed), ctx, duration, overrides); + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); +} + +function peak(samples: Float32Array): number { + let result = 0; + for (const sample of samples) { + const abs = Math.abs(sample); + if (abs > result) { + result = abs; + } + } + return result; +} + +describe("file-backed mapping integration", () => { + it("applies a declared direct mapping through the registration", async () => { + // The heuristic cannot reach `amp.gain` from `level` (different name, and + // the node's default does not match the parameter default), so any change + // must come from the explicit mapping. + const rawDoc = makeRawDoc({ + ampGain: 0.001, + paramDefault: 0.25, + overrides: [{ target: "amp.gain" }], + }); + const registration = createFileBackedRegistration( + "mapped-direct", + validateToneGraph(rawDoc), + rawDoc, + ); + + const low = await renderRegistration(registration, 5, { level: 0.1 }); + const high = await renderRegistration(registration, 5, { level: 0.4 }); + + expect(compareBuffers(low, high).identical).toBe(false); + expect(peak(high)).toBeGreaterThan(peak(low)); + }); + + it("explicit mapping wins over the heuristic for the same field", async () => { + const withMappingRaw = makeRawDoc({ + overrides: [{ target: "amp.gain", expression: "level * 2" }], + }); + const withoutMappingRaw = makeRawDoc(); + + const withMapping = createFileBackedRegistration( + "with-mapping", + validateToneGraph(withMappingRaw), + withMappingRaw, + ); + const withoutMapping = createFileBackedRegistration( + "without-mapping", + validateToneGraph(withoutMappingRaw), + withoutMappingRaw, + ); + + const overrides = { level: 0.2 }; + const mapped = await renderRegistration(withMapping, 7, overrides); + const heuristic = await renderRegistration(withoutMapping, 7, overrides); + + // Heuristic writes 0.2; the mapping overrides it with 0.4. + expect(peak(mapped)).toBeGreaterThan(peak(heuristic) * 1.5); + }); + + it("leaves a recipe without mappings byte-identical across renders", async () => { + const rawDoc = makeRawDoc(); + const registration = createFileBackedRegistration( + "no-mapping", + validateToneGraph(rawDoc), + rawDoc, + ); + + const first = await renderRegistration(registration, 11, { level: 0.3 }); + const second = await renderRegistration(registration, 11, { level: 0.3 }); + expect(compareBuffers(first, second).identical).toBe(true); + }); + + it("fails loudly at registration time for an unknown target field", () => { + const rawDoc = makeRawDoc({ overrides: [{ target: "amp.nope" }] }); + expect(() => + createFileBackedRegistration("bad-field", validateToneGraph(rawDoc), rawDoc), + ).toThrow(/unknown param field "nope"/); + }); + + it("skips a file with an invalid mapping at discovery time", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "toneforge-mapping-")); + try { + await writeFile( + join(tempDir, "bad-mapping.json"), + JSON.stringify(makeRawDoc({ overrides: [{ target: "amp.nope" }] })), + "utf-8", + ); + + const registry = new RecipeRegistry(); + const warnings: string[] = []; + const discovered = await discoverFileBackedRecipes(registry, { + recipeDirectory: tempDir, + logger: { warn: (message) => warnings.push(message) }, + }); + + expect(discovered).toEqual([]); + expect(warnings.some((message) => message.includes('unknown param field "nope"'))).toBe(true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/recipe-overrides.ts b/src/core/recipe-overrides.ts new file mode 100644 index 00000000..005a6574 --- /dev/null +++ b/src/core/recipe-overrides.ts @@ -0,0 +1,466 @@ +/** + * Declarative file-backed override mappings. + * + * File-backed recipes may attach an `overrides` list to each entry in + * `meta.parameters`. Each entry maps the declared parameter onto one or more + * node fields or automation event fields, optionally transforming it with a + * small arithmetic expression. This lets a recipe express computed + * relationships (for example `modulator.frequency = carrier.frequency * + * modRatio`) without duplicating derived literals in the YAML. + * + * The mechanism is deliberately explicit: mappings win over the generic + * name/default-value injection heuristic in `createFileBackedRegistration`, + * and an invalid mapping (unknown parameter, node or field) fails loudly at + * registration time. + * + * Declaration format: + * + * ```yaml + * meta: + * parameters: + * - name: modRatio + * type: number + * min: 1 + * max: 4 + * overrides: + * - target: modulator.frequency + * expression: "carrier.frequency * modRatio" + * - name: modDepthEnd + * type: number + * min: 300 + * max: 800 + * overrides: + * - target: modDepth.automation.gain.linearRamp.value + * ``` + * + * Target paths are one of: + * - `.` — a node `params` field. + * - `.automation...` — an automation event + * field, selected by zero-based event index or by event `kind`. + */ + +import type { ToneGraphDocument } from "./tonegraph-schema.js"; + +/** A single declarative target for a parameter override. */ +export interface FileBackedOverrideTarget { + /** Dotted target path (see module documentation). */ + target: string; + /** + * Optional arithmetic expression evaluated against resolved parameters and + * node fields. Supported operators: `*` and `/`. Operands are numeric + * literals, declared parameter names, or `.` references. + * When omitted, the parameter's resolved value is written directly. + */ + expression?: string; +} + +/** Mapping declared on a single parameter descriptor. */ +export interface FileBackedParameterMapping { + /** Declared parameter name the mapping belongs to. */ + parameter: string; + /** Ordered targets for the parameter. */ + overrides: FileBackedOverrideTarget[]; +} + +type ParsedTarget = + | { kind: "param"; node: string; field: string } + | { + kind: "automation"; + node: string; + param: string; + selector: EventSelector; + field: string; + }; + +type EventSelector = + | { type: "index"; index: number } + | { type: "kind"; kind: string }; + +const NUMERIC_LITERAL = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Parse the `meta.parameters[].overrides` declarations from a raw recipe + * document. Returns an empty list when no mappings are declared. Throws when a + * declaration is structurally malformed. + */ +export function parseFileBackedMappings(rawDoc: unknown): FileBackedParameterMapping[] { + if (!isRecord(rawDoc)) { + return []; + } + const meta = rawDoc.meta; + if (!isRecord(meta)) { + return []; + } + const parameters = meta.parameters; + if (!Array.isArray(parameters)) { + return []; + } + + const mappings: FileBackedParameterMapping[] = []; + for (const entry of parameters) { + if (!isRecord(entry)) { + continue; + } + const name = entry.name; + if (typeof name !== "string" || name.length === 0) { + continue; + } + const overridesRaw = entry.overrides; + if (overridesRaw === undefined) { + continue; + } + if (!Array.isArray(overridesRaw)) { + throw new Error( + `Override mapping for parameter "${name}" must be an array.`, + ); + } + + const overrides: FileBackedOverrideTarget[] = []; + for (const override of overridesRaw) { + if (!isRecord(override)) { + throw new Error( + `Override mapping for parameter "${name}" must be an object with a "target".`, + ); + } + const target = override.target; + if (typeof target !== "string" || target.length === 0) { + throw new Error( + `Override mapping for parameter "${name}" requires a non-empty "target".`, + ); + } + const expression = override.expression; + if (expression !== undefined && typeof expression !== "string") { + throw new Error( + `Override mapping for parameter "${name}" has a non-string "expression".`, + ); + } + overrides.push({ target, expression }); + } + + if (overrides.length > 0) { + mappings.push({ parameter: name, overrides }); + } + } + + return mappings; +} + +/** + * Validate declarative mappings against a resolved parameter set and graph. + * + * Called both at registration/discovery time (with the seed-independent + * parameter names and canonical graph) and before each application, so an + * invalid mapping fails loudly rather than rendering silently. + * + * @param mappings - Parsed mappings from {@link parseFileBackedMappings}. + * @param declaredNames - Set of declared parameter names. + * @param graph - Graph the targets are resolved against. + */ +export function validateFileBackedMappings( + mappings: FileBackedParameterMapping[], + declaredNames: Set, + graph: ToneGraphDocument, +): void { + for (const mapping of mappings) { + if (!declaredNames.has(mapping.parameter)) { + throw new Error( + `Override mapping references unknown parameter "${mapping.parameter}".`, + ); + } + for (const override of mapping.overrides) { + const target = parseTargetPath(override.target); + assertTargetExists(graph, target, override.target); + } + } +} + +/** + * Apply declarative mappings to a cloned graph. + * + * Resolution order: direct (expression-less) mappings are written first, then + * computed mappings. Parameter references always read the post-override values + * supplied in `derived`, so declaration order does not affect parameter + * resolution. Node-field references read the current cloned graph, picking up + * any previously written mapping. + * + * @param mappings - Parsed mappings from {@link parseFileBackedMappings}. + * @param derived - Resolved parameter values (seed-derived + overrides). + * @param cloned - Cloned graph mutated in place. + */ +export function applyFileBackedMappings( + mappings: FileBackedParameterMapping[], + derived: Record, + cloned: ToneGraphDocument, +): void { + if (mappings.length === 0) { + return; + } + + const declaredNames = new Set(Object.keys(derived)); + + // Validate every mapping before applying any so an invalid recipe fails + // loudly rather than rendering silently. + validateFileBackedMappings(mappings, declaredNames, cloned); + + const directWrites: Array<{ value: number; target: ParsedTarget; raw: string }> = []; + const computedWrites: Array<{ expression: string; target: ParsedTarget; raw: string }> = []; + + for (const mapping of mappings) { + const value = derived[mapping.parameter]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error( + `Override mapping for parameter "${mapping.parameter}" has no finite resolved value.`, + ); + } + + for (const override of mapping.overrides) { + const target = parseTargetPath(override.target); + if (override.expression === undefined) { + directWrites.push({ value, target, raw: override.target }); + } else { + computedWrites.push({ + expression: override.expression, + target, + raw: override.target, + }); + } + } + } + + for (const { value, target } of directWrites) { + writeTarget(cloned, target, value); + } + + for (const { expression, target, raw } of computedWrites) { + const value = evaluateExpression(expression, (reference) => + resolveReference(reference, derived, cloned, declaredNames), + ); + if (!Number.isFinite(value)) { + throw new Error( + `Override expression "${expression}" for target "${raw}" did not produce a finite value.`, + ); + } + writeTarget(cloned, target, value); + } +} + +function parseTargetPath(raw: string): ParsedTarget { + const parts = raw.split("."); + if (parts.length === 2 && parts[0] && parts[1]) { + return { kind: "param", node: parts[0], field: parts[1] }; + } + if ( + parts.length === 5 + && parts[0] + && parts[1] === "automation" + && parts[2] + && parts[3] + && parts[4] + ) { + return { + kind: "automation", + node: parts[0], + param: parts[2], + selector: parseEventSelector(parts[3]), + field: parts[4], + }; + } + throw new Error( + `Invalid override target "${raw}": expected "." or ".automation...".`, + ); +} + +function parseEventSelector(raw: string): EventSelector { + const index = Number(raw); + if (Number.isInteger(index) && index >= 0 && String(index) === raw) { + return { type: "index", index }; + } + return { type: "kind", kind: raw }; +} + +function findEvent( + events: unknown[], + selector: EventSelector, +): Record | undefined { + if (selector.type === "index") { + const event = events[selector.index]; + return isRecord(event) ? event : undefined; + } + return events.find( + (event) => isRecord(event) && event.kind === selector.kind, + ) as Record | undefined; +} + +function assertTargetExists( + cloned: ToneGraphDocument, + target: ParsedTarget, + raw: string, +): void { + const node = (cloned.nodes as Record)[target.node]; + if (!isRecord(node)) { + throw new Error( + `Override target "${raw}" references unknown node "${target.node}".`, + ); + } + + if (target.kind === "param") { + if (!isRecord(node.params)) { + throw new Error( + `Override target "${raw}" references a node with no params.`, + ); + } + if (!(target.field in node.params)) { + throw new Error( + `Override target "${raw}" references unknown param field "${target.field}" on node "${target.node}".`, + ); + } + return; + } + + if (!isRecord(node.automation)) { + throw new Error( + `Override target "${raw}" references a node with no automation.`, + ); + } + const events = node.automation[target.param]; + if (!Array.isArray(events)) { + throw new Error( + `Override target "${raw}" references unknown automation param "${target.param}" on node "${target.node}".`, + ); + } + const event = findEvent(events, target.selector); + if (!event) { + const selectorLabel = target.selector.type === "index" + ? `index ${target.selector.index}` + : `kind "${target.selector.kind}"`; + throw new Error( + `Override target "${raw}" found no automation event matching ${selectorLabel}.`, + ); + } + if (!(target.field in event)) { + throw new Error( + `Override target "${raw}" references unknown automation field "${target.field}".`, + ); + } +} + +function writeTarget( + cloned: ToneGraphDocument, + target: ParsedTarget, + value: number, +): void { + const node = (cloned.nodes as Record)[target.node]; + if (!isRecord(node)) { + throw new Error(`Override target references unknown node "${target.node}".`); + } + + if (target.kind === "param") { + if (!isRecord(node.params)) { + throw new Error(`Node "${target.node}" has no params.`); + } + node.params[target.field] = value; + return; + } + + if (!isRecord(node.automation)) { + throw new Error(`Node "${target.node}" has no automation.`); + } + const events = node.automation[target.param]; + if (!Array.isArray(events)) { + throw new Error( + `Node "${target.node}" has no automation param "${target.param}".`, + ); + } + const event = findEvent(events, target.selector); + if (!event) { + throw new Error( + `Node "${target.node}" has no matching automation event for "${target.param}".`, + ); + } + event[target.field] = value; +} + +function evaluateExpression( + expression: string, + resolve: (reference: string) => number | undefined, +): number { + const tokens = expression + .split(/([*/])/) + .map((token) => token.trim()) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + throw new Error("Override expression is empty."); + } + + let result = evaluateTerm(tokens[0], expression, resolve); + for (let i = 1; i < tokens.length; i += 2) { + const operator = tokens[i]; + const rightToken = tokens[i + 1]; + if (rightToken === undefined) { + throw new Error(`Malformed override expression "${expression}".`); + } + const right = evaluateTerm(rightToken, expression, resolve); + if (operator === "*") { + result *= right; + } else if (operator === "/") { + if (right === 0) { + throw new Error(`Division by zero in override expression "${expression}".`); + } + result /= right; + } else { + throw new Error( + `Unsupported operator "${operator}" in override expression "${expression}".`, + ); + } + } + return result; +} + +function evaluateTerm( + term: string, + expression: string, + resolve: (reference: string) => number | undefined, +): number { + if (NUMERIC_LITERAL.test(term)) { + return Number(term); + } + const value = resolve(term); + if (value === undefined) { + throw new Error( + `Unknown reference "${term}" in override expression "${expression}".`, + ); + } + return value; +} + +function resolveReference( + reference: string, + derived: Record, + cloned: ToneGraphDocument, + declaredNames: Set, +): number | undefined { + // A declared parameter always wins over a node-field lookup. + if (declaredNames.has(reference)) { + const value = derived[reference]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; + } + + const dot = reference.indexOf("."); + if (dot > 0 && dot < reference.length - 1) { + const nodeId = reference.slice(0, dot); + const field = reference.slice(dot + 1); + const node = (cloned.nodes as Record)[nodeId]; + if (isRecord(node) && isRecord(node.params)) { + const value = node.params[field]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + } + return undefined; +} diff --git a/src/core/recipe.test.ts b/src/core/recipe.test.ts index 7627ba9f..2437c4ad 100644 --- a/src/core/recipe.test.ts +++ b/src/core/recipe.test.ts @@ -1,10 +1,16 @@ import { describe, it, expect } from "vitest"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { OfflineAudioContext } from "node-web-audio-api"; import { createRng } from "./rng.js"; -import { RecipeRegistry, discoverFileBackedRecipes } from "./recipe.js"; +import { + RecipeRegistry, + assertSafeRecipeName, + discoverFileBackedRecipes, + persistRecipeDocument, + resolveExternalRecipeDirectory, +} from "./recipe.js"; function makeRegistration(overrides: Record = {}) { return { @@ -754,3 +760,247 @@ routing: } }); }); + +describe("resolveExternalRecipeDirectory", () => { + it("prefers an explicit destination over the env var and home", async () => { + const dir = await resolveExternalRecipeDirectory({ + destination: "relative-dest", + env: { TONEFORGE_RECIPE_DIR: "/env/recipes" }, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("relative-dest")); + }); + + it("resolves an absolute destination unchanged", async () => { + const dir = await resolveExternalRecipeDirectory({ + destination: "/absolute/recipes", + env: { TONEFORGE_RECIPE_DIR: "/env/recipes" }, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("/absolute/recipes")); + }); + + it("falls back to TONEFORGE_RECIPE_DIR when no destination is given", async () => { + const dir = await resolveExternalRecipeDirectory({ + env: { TONEFORGE_RECIPE_DIR: "/env/recipes" }, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("/env/recipes")); + }); + + it("resolves a relative TONEFORGE_RECIPE_DIR against the CWD", async () => { + const dir = await resolveExternalRecipeDirectory({ + env: { TONEFORGE_RECIPE_DIR: "relative/recipes" }, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("relative/recipes")); + }); + + it("defaults to /.toneforge/recipes", async () => { + const dir = await resolveExternalRecipeDirectory({ + env: {}, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("/home/tester", ".toneforge", "recipes")); + }); + + it("ignores blank destination and env values", async () => { + const dir = await resolveExternalRecipeDirectory({ + destination: " ", + env: { TONEFORGE_RECIPE_DIR: " " }, + homeDirectory: "/home/tester", + }); + expect(dir).toBe(resolve("/home/tester", ".toneforge", "recipes")); + }); +}); + +describe("assertSafeRecipeName", () => { + it("accepts ordinary recipe names", () => { + expect(() => assertSafeRecipeName("game-weapon")).not.toThrow(); + expect(() => assertSafeRecipeName("recipe.v2")).not.toThrow(); + expect(() => assertSafeRecipeName("Card_Coin_Collect-2")).not.toThrow(); + }); + + it.each(["", ".", "..", "a/b", "a\\b", "nul\0byte"])( + "rejects unsafe name %j", + (name) => { + expect(() => assertSafeRecipeName(name)).toThrow(/Invalid recipe name/); + }, + ); +}); + +describe("persistRecipeDocument", () => { + it("creates nested destination directories and writes the document", async () => { + const root = await mkdtemp(join(tmpdir(), "toneforge-persist-")); + try { + const destinationDirectory = join(root, "nested", "recipes"); + const result = await persistRecipeDocument({ + contents: 'version: "0.1"\n', + destinationDirectory, + fileName: "my-recipe.yaml", + }); + + expect(result.destinationDirectory).toBe(resolve(destinationDirectory)); + expect(result.destinationPath).toBe( + join(resolve(destinationDirectory), "my-recipe.yaml"), + ); + expect(await readFile(result.destinationPath, "utf-8")).toBe( + 'version: "0.1"\n', + ); + // No leftover temp files. + expect(await readdir(resolve(destinationDirectory))).toEqual([ + "my-recipe.yaml", + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("overwrites an existing recipe idempotently without duplicating", async () => { + const root = await mkdtemp(join(tmpdir(), "toneforge-persist-")); + try { + await persistRecipeDocument({ + contents: "first", + destinationDirectory: root, + fileName: "same.yaml", + }); + const second = await persistRecipeDocument({ + contents: "second", + destinationDirectory: root, + fileName: "same.yaml", + }); + + expect(await readFile(second.destinationPath, "utf-8")).toBe("second"); + expect(await readdir(root)).toEqual(["same.yaml"]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects file names that would escape the destination directory", async () => { + const root = await mkdtemp(join(tmpdir(), "toneforge-persist-")); + try { + await expect( + persistRecipeDocument({ + contents: "x", + destinationDirectory: root, + fileName: "../escape.yaml", + }), + ).rejects.toThrow(/Invalid recipe file name/); + await expect( + persistRecipeDocument({ + contents: "x", + destinationDirectory: root, + fileName: "nested/escape.yaml", + }), + ).rejects.toThrow(/Invalid recipe file name/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("discoverFileBackedRecipes — additional directories", () => { + const bakedRecipe = `version: "0.1" +meta: + description: Baked recipe + category: UI +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 440 + out: + kind: destination +routing: + - chain: [osc, out] +`; + + const externalRecipe = (description: string) => `version: "0.1" +meta: + description: ${description} + category: External +nodes: + osc: + kind: oscillator + params: + type: triangle + frequency: 220 + out: + kind: destination +routing: + - chain: [osc, out] +`; + + it("discovers from the baked directory and marks additional dirs external", async () => { + const bakedDir = await mkdtemp(join(tmpdir(), "toneforge-baked-")); + const externalDir = await mkdtemp(join(tmpdir(), "toneforge-external-")); + try { + await writeFile(join(bakedDir, "baked-recipe.yaml"), bakedRecipe, "utf-8"); + await writeFile( + join(externalDir, "external-recipe.yaml"), + externalRecipe("External recipe"), + "utf-8", + ); + + const reg = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(reg, { + recipeDirectory: bakedDir, + additionalRecipeDirectories: [externalDir], + }); + // When recipeDirectory is supplied, only that directory is scanned. + expect(discovered).toEqual(["baked-recipe"]); + expect(reg.getRegistration("external-recipe")).toBeUndefined(); + + // Without an explicit override, the baked default dir is used and the + // additional directory is scanned alongside it. + const reg2 = new RecipeRegistry(); + const discovered2 = await discoverFileBackedRecipes(reg2, { + additionalRecipeDirectories: [externalDir], + }); + expect(discovered2).toContain("external-recipe"); + const external = reg2.getRegistration("external-recipe"); + expect(external).toBeDefined(); + expect(external!.external).toBe(true); + expect(external!.sourceDirectory).toBe(resolve(externalDir)); + } finally { + await rm(bakedDir, { recursive: true, force: true }); + await rm(externalDir, { recursive: true, force: true }); + } + }); + + it("skips a missing additional directory without throwing", async () => { + const reg = new RecipeRegistry(); + const missing = join(tmpdir(), `toneforge-missing-${Date.now()}`); + const discovered = await discoverFileBackedRecipes(reg, { + additionalRecipeDirectories: [missing], + }); + // Baked-in recipes are still discovered; the missing dir contributes none. + expect(discovered.length).toBeGreaterThan(0); + expect(reg.list().length).toBe(discovered.length); + }); + + it("lets a later external directory override a same-named baked recipe", async () => { + const externalDir = await mkdtemp(join(tmpdir(), "toneforge-override-")); + try { + await writeFile( + join(externalDir, "weapon-laser-zap.yaml"), + externalRecipe("External override"), + "utf-8", + ); + + const reg = new RecipeRegistry(); + await discoverFileBackedRecipes(reg, { + additionalRecipeDirectories: [externalDir], + }); + + const overridden = reg.getRegistration("weapon-laser-zap"); + expect(overridden).toBeDefined(); + expect(overridden!.description).toBe("External override"); + expect(overridden!.external).toBe(true); + } finally { + await rm(externalDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/recipe.ts b/src/core/recipe.ts index 62fb2b5d..896c8e01 100644 --- a/src/core/recipe.ts +++ b/src/core/recipe.ts @@ -4,10 +4,15 @@ * Stores recipe metadata plus deterministic offline graph builders. */ -import type { OfflineAudioContext } from "node-web-audio-api"; +import type { OfflineAudioContext } from "../audio/web-audio.js"; import type { Rng } from "./rng.js"; import { normalizeCategory as normalizeCategoryFn } from "./normalize-category.js"; import type { ToneGraphDocument } from "./tonegraph-schema.js"; +import { + applyFileBackedMappings, + parseFileBackedMappings, + validateFileBackedMappings, +} from "./recipe-overrides.js"; export interface ParamDescriptor { name: string; @@ -17,11 +22,23 @@ export interface ParamDescriptor { } export interface RecipeRegistration { - getDuration: (rng: Rng) => number; + getDuration: (rng: Rng, overrides?: Record) => number; + /** + * Build the recipe's offline audio graph. + * + * `overrides` replace the seed-derived value for named declared parameters. + * File-backed recipes resolve them in this order: seed-derived values, base + * overrides, the generic name/default-value injection heuristic, then any + * explicit declarative mappings declared in `meta.parameters[].overrides` + * (see `recipe-overrides.ts`). Mappings therefore win over the heuristic and + * can express computed relationships such as + * `modulator.frequency = carrier.frequency * modRatio`. + */ buildOfflineGraph: ( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides?: Record, ) => void | Promise; description: string; category: string; @@ -29,6 +46,50 @@ export interface RecipeRegistration { signalChain: string; params: ParamDescriptor[]; getParams: (rng: Rng) => Record; + /** + * Absolute filesystem directory the recipe was discovered from. + * + * Only set for file-backed ToneGraph recipes loaded from disk; built-in + * TypeScript recipes leave this undefined. Used by `tf library list` to + * surface where an externally registered recipe lives. + */ + sourceDirectory?: string; + /** + * True when the recipe was discovered from a directory outside the + * repository's baked-in `presets/recipes/` directory (i.e. an externally + * registered recipe). Built-in and baked-in recipes set this to false or + * leave it undefined. + */ + external?: boolean; +} + +/** + * Merge preset overrides on top of seed-derived parameter values. + * + * Only keys already present in `params` are applied, so an override can never + * introduce a parameter the recipe does not declare; unknown or non-finite + * values are ignored. Returns `params` unchanged when there is nothing to + * apply, keeping the baseline path allocation-free and byte-identical. + */ +export function applyOverrides( + params: T, + overrides?: Record, +): T { + if (!overrides) { + return params; + } + const keys = Object.keys(overrides); + if (keys.length === 0) { + return params; + } + const merged = { ...(params as Record) }; + for (const name of keys) { + const value = overrides[name]; + if (name in merged && typeof value === "number" && Number.isFinite(value)) { + merged[name] = value; + } + } + return merged as T; } export interface RecipeFilterQuery { @@ -146,8 +207,20 @@ interface FileBackedRecipeParam { integer?: boolean; } -interface DiscoverFileBackedRecipesOptions { +export interface DiscoverFileBackedRecipesOptions { + /** + * Override the single directory to scan. When provided, the baked-in + * `presets/recipes/` directory and any `additionalRecipeDirectories` are + * not scanned (legacy single-directory behaviour). + */ recipeDirectory?: string; + /** + * Extra directories to scan after the baked-in `presets/recipes/` + * directory (e.g. the persistent external recipe directory). Directories + * that do not exist are skipped silently; recipes from later directories + * override same-named recipes from earlier ones. + */ + additionalRecipeDirectories?: string[]; logger?: { warn: (message: string) => void; }; @@ -299,25 +372,42 @@ function buildSignalChain(graph: ToneGraphDocument): string { return "ToneGraph (no routes)"; } + const toEndpointList = (value: string | string[] | undefined): string[] => + value === undefined ? [] : Array.isArray(value) ? value : [value]; + const parts = graph.routing.map((entry) => { if ("chain" in entry) { return entry.chain.join(" -> "); } + if ("bus" in entry) { + const inputs = toEndpointList(entry.from); + const outputs = toEndpointList(entry.to); + return `[${inputs.join(", ")}] -> bus:${entry.bus} -> [${outputs.join(", ")}]`; + } return `${entry.from} -> ${entry.to}`; }); return parts.join(" | "); } -function createFileBackedRegistration( +export function createFileBackedRegistration( recipeName: string, graph: ToneGraphDocument, rawDoc: unknown, ): RecipeRegistration { const extractedParams = extractFileBackedParams(graph, rawDoc); + const overrideMappings = parseFileBackedMappings(rawDoc); + + // Validate the declarative mappings once, at registration/discovery time, so + // an invalid recipe is skipped with a warning instead of failing a render. + validateFileBackedMappings( + overrideMappings, + new Set(extractedParams.map((param) => param.name)), + graph, + ); return { getDuration: () => computeDurationHint(graph), - buildOfflineGraph: async (rng, ctx, duration) => { + buildOfflineGraph: async (rng, ctx, duration, overrides) => { const { loadToneGraph } = await import("./tonegraph.js"); // Create a shallow-cloned graph to avoid mutating the canonical @@ -337,49 +427,90 @@ function createFileBackedRegistration( derived[p.name] = p.integer ? Math.round(value) : value; } - // Apply derived parameters to node params. + // Apply preset overrides on top of the seed-derived values. Values for + // integer-declared parameters are rounded so the graph stays consistent + // with the declared parameter type. + if (overrides) { + for (const p of extractedParams) { + const value = overrides[p.name]; + if (typeof value === "number" && Number.isFinite(value)) { + derived[p.name] = p.integer ? Math.round(value) : value; + } + } + } + + // Apply derived parameters to node params and automation values. // Strategy: - // 1) If a node.params key exactly matches a declared parameter name, set it. - // 2) Otherwise, if the declared parameter included a defaultValue and a node - // param currently equals that defaultValue, assume they're the same logical - // parameter and replace it. + // 1) If a key exactly matches a declared parameter name, set it. + // 2) Otherwise, if the declared parameter included a defaultValue and the + // current value equals that defaultValue, assume they're the same + // logical parameter and replace it. + const applyDerived = ( + container: Record, + key: string, + ): boolean => { + const current = container[key]; + if (typeof current !== "number") return false; + for (const p of extractedParams) { + if (p.defaultValue === undefined) continue; + if (Math.abs(current - p.defaultValue) < 1e-6) { + container[key] = derived[p.name]; + return true; + } + } + if (Object.prototype.hasOwnProperty.call(derived, key)) { + container[key] = derived[key]; + return true; + } + return false; + }; + + const automationFields = [ + "value", + "rate", + "depth", + "offset", + "start", + "end", + "step", + ]; + for (const node of Object.values(cloned.nodes)) { // ToneGraphNodeDefinition is a discriminated union where not all // variants declare a `params` field (e.g. destination). Use a - // runtime check and a narrow local `nodeParams` alias typed as any - // to avoid TypeScript union property errors while preserving the - // original runtime behavior. + // runtime check and narrow aliases typed as any to avoid TypeScript + // union property errors while preserving runtime behavior. const nodeParams = (node as any).params; - if (!nodeParams || typeof nodeParams !== "object") continue; - - for (const [k, v] of Object.entries(nodeParams)) { - let applied = false; - - // Prefer mapping by matching defaultValue where available. This - // disambiguates nodes that share a generic param name like - // "frequency" (oscillator vs filter) by using the default values - // declared in meta.parameters. - if (typeof v === "number") { - for (const p of extractedParams) { - if (p.defaultValue === undefined) continue; - if (Math.abs(v - p.defaultValue) < 1e-6) { - (nodeParams as Record)[k] = derived[p.name]; - applied = true; - break; - } - } + if (nodeParams && typeof nodeParams === "object") { + for (const k of Object.keys(nodeParams)) { + applyDerived(nodeParams as Record, k); } + } - if (applied) continue; - - // Fallback: exact name match between node param key and declared param name - if (Object.prototype.hasOwnProperty.call(derived, k)) { - (nodeParams as Record)[k] = derived[k]; - continue; + // Automation curves (LFO rate/depth, ramps) hold numeric values that + // declared parameters may map onto (e.g. modDepthEnd, lfoRate). + const automation = (node as any).automation; + if (automation && typeof automation === "object") { + for (const events of Object.values(automation)) { + if (!Array.isArray(events)) continue; + for (const event of events) { + if (!event || typeof event !== "object") continue; + for (const field of automationFields) { + if (field in (event as Record)) { + applyDerived(event as Record, field); + } + } + } } } } + // Explicit declarative mappings win over the name/default-value + // heuristic above. They are applied last so a computed mapping sees the + // post-override parameter values and any node fields written by a + // direct mapping (see `recipe-overrides.ts`). + applyFileBackedMappings(overrideMappings, derived, cloned); + // Optional diagnostics: set TF_DIAG=1 to print derived params and // cloned node parameter values before rendering. This is intentionally // gated by an env var to avoid noisy output in normal runs. @@ -448,6 +579,168 @@ function isNodeRuntime(): boolean { && typeof process.versions.node === "string"; } +/** + * Options for {@link resolveExternalRecipeDirectory}. + */ +export interface ResolveExternalRecipeDirectoryOptions { + /** Explicit destination directory. Takes precedence over the env var. */ + destination?: string; + /** Environment map to read `TONEFORGE_RECIPE_DIR` from (defaults to `process.env`). */ + env?: Record; + /** Home directory override (defaults to `os.homedir()`). */ + homeDirectory?: string; +} + +/** Default external recipe directory relative to the OS home directory. */ +const DEFAULT_EXTERNAL_RECIPE_SUBDIR = [".toneforge", "recipes"]; + +/** + * Resolve the directory that holds externally registered recipes. + * + * Precedence (AC2, AC6): + * 1. `options.destination` (the CLI `--destination` flag). + * 2. The `TONEFORGE_RECIPE_DIR` environment variable. + * 3. `~/.toneforge/recipes/` (the platform-agnostic default). + * + * Relative paths are resolved against the current working directory and + * absolute paths are returned unchanged. + * + * Node-only: guarded by {@link isNodeRuntime}. Throws a clear error when + * called outside Node so callers never silently fall back to a wrong location. + */ +export async function resolveExternalRecipeDirectory( + options: ResolveExternalRecipeDirectoryOptions = {}, +): Promise { + if (!isNodeRuntime()) { + throw new Error( + "External recipe directories are only supported in Node.js runtimes.", + ); + } + + const [{ resolve }, osModule] = await Promise.all([ + import("node:path"), + import("node:os"), + ]); + + const destination = options.destination?.trim(); + if (destination) { + return resolve(destination); + } + + const env = options.env ?? process.env; + const envDirectory = env["TONEFORGE_RECIPE_DIR"]?.trim(); + if (envDirectory) { + return resolve(envDirectory); + } + + const home = options.homeDirectory ?? osModule.homedir(); + return resolve(home, ...DEFAULT_EXTERNAL_RECIPE_SUBDIR); +} + +/** + * Validate that a recipe name is safe to use as a file name. + * + * Rejects empty names, path separators, parent-directory segments and NUL + * bytes so persistence can never escape the destination directory. + */ +export function assertSafeRecipeName(name: string): void { + const isUnsafe = name.length === 0 + || name === "." + || name === ".." + || name.includes("/") + || name.includes("\\") + || name.includes("\0"); + if (isUnsafe) { + throw new Error( + `Invalid recipe name '${name}': names must not be empty or contain path separators.`, + ); + } +} + +/** + * Options for {@link persistRecipeDocument}. + */ +export interface PersistRecipeDocumentOptions { + /** Raw recipe document text (YAML or JSON) to write. */ + contents: string; + /** Destination directory (relative paths resolve against the CWD). */ + destinationDirectory: string; + /** File name to store the recipe as (e.g. `game-weapon.yaml`). */ + fileName: string; +} + +/** + * Result of {@link persistRecipeDocument}. + */ +export interface PersistRecipeDocumentResult { + /** Absolute path of the written recipe file. */ + destinationPath: string; + /** Absolute destination directory. */ + destinationDirectory: string; + /** File name the recipe was written as. */ + fileName: string; +} + +/** + * Persist a recipe document to the external recipe directory. + * + * The write is atomic: the contents are written to a temporary file in the + * destination directory and then renamed into place, so a concurrent reader + * (e.g. a separate `tf generate` process) never observes a partial file + * (AC1, AC3). Re-persisting the same file overwrites it cleanly with no + * duplicate. + * + * The destination directory is created recursively when missing. A missing + * or unreadable source is the caller's responsibility; this function writes + * the supplied `contents` verbatim. + */ +export async function persistRecipeDocument( + options: PersistRecipeDocumentOptions, +): Promise { + if (!isNodeRuntime()) { + throw new Error( + "Persisting recipes is only supported in Node.js runtimes.", + ); + } + + if (options.fileName.includes("/") || options.fileName.includes("\\") || options.fileName.includes("\0")) { + throw new Error( + `Invalid recipe file name '${options.fileName}': names must not contain path separators.`, + ); + } + assertSafeRecipeName(options.fileName.replace(/\.[^.]+$/, "")); + + const [{ mkdir, writeFile, rename, rm }, { resolve, join }] = await Promise.all([ + import("node:fs/promises"), + import("node:path"), + ]); + + const destinationDirectory = resolve(options.destinationDirectory); + const destinationPath = join(destinationDirectory, options.fileName); + const tempPath = join( + destinationDirectory, + `.${options.fileName}.${process.pid}.${Date.now()}.tmp`, + ); + + await mkdir(destinationDirectory, { recursive: true }); + + try { + await writeFile(tempPath, options.contents, "utf-8"); + await rename(tempPath, destinationPath); + } catch (error) { + await rm(tempPath, { force: true }).catch(() => { + /* best-effort cleanup */ + }); + throw error; + } + + return { + destinationPath, + destinationDirectory, + fileName: options.fileName, + }; +} + export async function discoverFileBackedRecipes( registry: RecipeRegistry, options: DiscoverFileBackedRecipesOptions = {}, @@ -482,48 +775,67 @@ export async function discoverFileBackedRecipes( "presets", "recipes", ); - const recipeDirectory = options.recipeDirectory ?? defaultRecipeDirectory; - let entries: Array<{ name: string; isFile: () => boolean }> = []; - try { - entries = await readdir(recipeDirectory, { withFileTypes: true }); - } catch (error) { - const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; - if (code === "ENOENT") { - return []; - } - throw error; - } + // When an explicit single directory is supplied, honour the legacy + // behaviour and ignore the baked-in directory and any additional dirs. + // Otherwise scan the baked-in presets directory first, followed by the + // externally registered directories, so externally registered recipes + // override same-named baked-in ones. + const recipeDirectories = options.recipeDirectory !== undefined + ? [options.recipeDirectory] + : [defaultRecipeDirectory, ...(options.additionalRecipeDirectories ?? [])]; const discovered: string[] = []; - for (const entry of entries) { - if (!entry.isFile()) { - continue; - } - - const ext = extname(entry.name).toLowerCase(); - if (ext !== ".json" && ext !== ".yaml" && ext !== ".yml") { - continue; - } + const seen = new Set(); - const filePath = resolve(recipeDirectory, entry.name); + for (const rawDirectory of recipeDirectories) { + const recipeDirectory = resolve(rawDirectory); + const isExternal = recipeDirectory !== defaultRecipeDirectory; + let entries: Array<{ name: string; isFile: () => boolean }> = []; try { - const source = await readFile(filePath, "utf-8"); - const rawDoc = ext === ".json" - ? JSON.parse(source) - : yamlLoad(source); - const graph = validateToneGraph(rawDoc); - - const recipeName = basename(entry.name, ext); - registry.register( - recipeName, - createFileBackedRegistration(recipeName, graph, rawDoc), - ); - discovered.push(recipeName); + entries = await readdir(recipeDirectory, { withFileTypes: true }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Skipping invalid ToneGraph recipe file ${entry.name}: ${message}`); + const code = isRecord(error) && typeof error.code === "string" ? error.code : ""; + if (code === "ENOENT") { + continue; + } + throw error; + } + + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const ext = extname(entry.name).toLowerCase(); + if (ext !== ".json" && ext !== ".yaml" && ext !== ".yml") { + continue; + } + + const filePath = resolve(recipeDirectory, entry.name); + + try { + const source = await readFile(filePath, "utf-8"); + const rawDoc = ext === ".json" + ? JSON.parse(source) + : yamlLoad(source); + const graph = validateToneGraph(rawDoc); + + const recipeName = basename(entry.name, ext); + registry.register(recipeName, { + ...createFileBackedRegistration(recipeName, graph, rawDoc), + sourceDirectory: recipeDirectory, + external: isExternal, + }); + if (!seen.has(recipeName)) { + seen.add(recipeName); + discovered.push(recipeName); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Skipping invalid ToneGraph recipe file ${entry.name}: ${message}`); + } } } diff --git a/src/core/renderer.browser.test.ts b/src/core/renderer.browser.test.ts new file mode 100644 index 00000000..05742518 --- /dev/null +++ b/src/core/renderer.browser.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { OfflineAudioContext as NodeOfflineAudioContext } from "node-web-audio-api"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +/** + * Verifies the renderer is Runtime-aware: when it runs outside Node.js it + * resolves OfflineAudioContext from the browser-native global (via the + * cross-platform abstraction) instead of importing node-web-audio-api. + */ +describe("renderRecipe in a browser-like runtime", () => { + it("renders using the browser-native OfflineAudioContext global", async () => { + let constructed = 0; + const BrowserOfflineAudioContext = new Proxy(NodeOfflineAudioContext, { + construct(target, args) { + constructed += 1; + return Reflect.construct(target, args); + }, + }); + + const originalNodeVersion = process.versions.node; + Reflect.deleteProperty(process.versions, "node"); + vi.stubGlobal("OfflineAudioContext", BrowserOfflineAudioContext); + vi.resetModules(); + + try { + const { renderRecipe } = await import("./renderer.js"); + // footstep-stone is a synchronously-registered built-in recipe, so it + // is available even in a browser-like runtime (file-backed discovery is + // a Node-only path and intentionally skipped here). + const result = await renderRecipe("footstep-stone", 42); + + expect(constructed).toBeGreaterThan(0); + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + expect(result.sampleRate).toBe(44100); + expect(result.numberOfChannels).toBe(1); + } finally { + (process.versions as unknown as Record).node = + originalNodeVersion; + } + }); +}); diff --git a/src/core/renderer.overrides.test.ts b/src/core/renderer.overrides.test.ts new file mode 100644 index 00000000..ffbb13f6 --- /dev/null +++ b/src/core/renderer.overrides.test.ts @@ -0,0 +1,344 @@ +/** + * Override-aware deterministic render path tests. + * + * ACs (TF-0MUV11RXS003Y6JI): + * - renderPreset returns a RenderResult and applies overrides. + * - Determinism: same preset twice → byte-identical samples; a changed + * override → different samples. + * - Registry-wide coverage: every recipe honours an override for every + * declared parameter. + * - Baseline unchanged: empty overrides == renderRecipe. + * + * Per-parameter coverage (TF-0MUVK21BQ004E6ZO): + * - Each declared parameter of every registered recipe is tested individually + * via the `assertOverrideChangesOutput` helper. + * - Allowlisted (KNOWN_UNWIRED) parameters assert identical output today; + * a stale entry (now changed) fails; a missing entry (no declared param) + * fails. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { renderPreset, renderRecipe } from "./renderer.js"; +import { registry, initializeRecipeRegistry } from "../recipes/index.js"; +import { createRng } from "./rng.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +async function baseline(recipe: string, seed: number): Promise { + return (await renderRecipe(recipe, seed)).samples; +} + +/** A target value guaranteed to differ from the seed-derived baseline. */ +function oppositeExtreme( + min: number, + max: number, + baselineValue: number, +): number { + const midpoint = min + (max - min) / 2; + return baselineValue <= midpoint ? max : min; +} + +/** + * Assert that overriding a single parameter changes the rendered output. + * + * @param recipe - Recipe name. + * @param param - Parameter name to override. + * @param seed - Seed for deterministic rendering. + * @param expectedChange - Whether the override is expected to change output. + * `true` for non-allowlisted params (should change), + * `false` for KNOWN_UNWIRED params (should not yet change). + */ +export async function assertOverrideChangesOutput( + recipe: string, + param: string, + seed: number, + expectedChange: boolean, +): Promise { + const registration = registry.getRegistration(recipe)!; + const baseParams = registration.getParams(createRng(seed)); + const descriptor = registration.params.find((p) => p.name === param); + if (!descriptor) { + throw new Error(`No descriptor for parameter "${param}" in recipe "${recipe}"`); + } + if (!(param in baseParams)) { + throw new Error(`No baseline value for parameter "${param}" in recipe "${recipe}"`); + } + const target = oppositeExtreme( + descriptor.min, + descriptor.max, + baseParams[param], + ); + const base = await baseline(recipe, seed); + const overridden = await renderPreset({ + recipe, + seed, + overrides: { [param]: target }, + }); + const changed = !compareBuffers(base, overridden.samples).identical; + + if (expectedChange) { + expect( + changed, + `Override of "${param}" on recipe "${recipe}" had no effect — this parameter should be wired into the render path`, + ).toBe(true); + } else { + const reason = KNOWN_UNWIRED[`${recipe}.${param}`] ?? "unknown"; + expect( + changed, + `Override of "${param}" on recipe "${recipe}" now changes output — "${reason}" — remove this entry from KNOWN_UNWIRED`, + ).toBe(false); + } +} + +/** + * KNOWN_UNWIRED: declared parameters whose override does not change rendered + * output. + * + * TF-0MUV9U9QU001SORH closed every entry: every declared parameter of every + * registered recipe is now honoured, so the allowlist is intentionally empty. + * It (and its guard helper below) is retained as the self-updating guard: if a + * future change makes a declared parameter's override a no-op, the + * registry-wide coverage test fails; if a listed parameter starts working it is + * reported stale; if a listed key no longer names a declared parameter it is + * reported missing. See {@link evaluateAllowlist} for the guard logic. + */ +const KNOWN_UNWIRED: Record = {}; + +/** + * Evaluate the KNOWN_UNWIRED allowlist against observed override results. + * + * Keeps the guard honest: + * - an allowlisted key whose override now changes output is **stale**; + * - an allowlisted key with no matching declared parameter is **missing**. + * + * Both are returned so the caller can fail the suite rather than silently + * accepting a weakened guard. + * + * @param allowlist - Allowlist mapping parameter key -> reason. + * @param declaredKeys - Every declared `recipe.param` key discovered. + * @param observations - Whether each evaluated key's override changed output. + */ +export function evaluateAllowlist( + allowlist: Record, + declaredKeys: Set, + observations: Map, +): { stale: string[]; missing: string[] } { + const stale: string[] = []; + const missing: string[] = []; + + for (const [key, reason] of Object.entries(allowlist)) { + if (!declaredKeys.has(key)) { + missing.push(key); + continue; + } + if (observations.get(key) === true) { + stale.push(`${key}: now honoured — remove from KNOWN_UNWIRED (${reason})`); + } + } + + return { stale, missing }; +} + +// --------------------------------------------------------------------------- +// renderPreset baseline assertions +// --------------------------------------------------------------------------- + +describe("renderPreset", () => { + it("returns a RenderResult", async () => { + const result = await renderPreset({ + recipe: "ui-scifi-confirm", + seed: 42, + overrides: {}, + }); + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + expect(result.sampleRate).toBe(44100); + expect(result.numberOfChannels).toBe(1); + }); + + it("is byte-identical to renderRecipe when there are no overrides", async () => { + const withoutOverrides = await renderPreset({ + recipe: "ui-scifi-confirm", + seed: 42, + overrides: {}, + }); + const direct = await renderRecipe("ui-scifi-confirm", 42); + expect(compareBuffers(withoutOverrides.samples, direct.samples).identical).toBe( + true, + ); + }); + + it("renders the same preset twice byte-identically", async () => { + const preset = { + recipe: "weapon-laser-zap", + seed: 1234, + overrides: { carrierFreq: 1500 }, + }; + const first = await renderPreset(preset); + const second = await renderPreset(preset); + expect(compareBuffers(first.samples, second.samples).identical).toBe(true); + }); + + for (const entry of [ + { recipe: "weapon-laser-zap", seed: 1234, param: "carrierFreq" }, + { recipe: "footstep-stone", seed: 42, param: "filterFreq" }, + { recipe: "ambient-wind-gust", seed: 7, param: "filterFreq" }, + ]) { + it(`applies an override to change output for ${entry.recipe}`, async () => { + const registration = registry.getRegistration(entry.recipe); + expect(registration).toBeDefined(); + const descriptor = registration?.params.find((p) => p.name === entry.param); + expect(descriptor).toBeDefined(); + + const seedRng = createRng(entry.seed); + await initializeRecipeRegistry(); + const baseParams = registration!.getParams(seedRng); + const target = oppositeExtreme( + descriptor!.min, + descriptor!.max, + baseParams[entry.param], + ); + + const base = await baseline(entry.recipe, entry.seed); + const overridden = await renderPreset({ + recipe: entry.recipe, + seed: entry.seed, + overrides: { [entry.param]: target }, + }); + + expect(compareBuffers(base, overridden.samples).identical).toBe(false); + }); + } +}); + +// --------------------------------------------------------------------------- +// Per-parameter override coverage +// +// Each declared parameter of every registered recipe is tested individually +// via the `assertOverrideChangesOutput` helper, grouped by recipe. The +// registry is discovered asynchronously (file-backed recipes), so a single +// `it()` iterates over all recipes and calls the helper for each parameter — +// each parameter gets its own named expect assertion with a clear failure +// message. +// --------------------------------------------------------------------------- + +describe("registry-wide override coverage", () => { + beforeAll(async () => { + await initializeRecipeRegistry(); + }); + + it( + "honours an override for every declared parameter of every recipe", + async () => { + const recipes = registry.list(); + expect(recipes.length).toBeGreaterThan(0); + + const failures: string[] = []; + const declaredKeys = new Set(); + const observations = new Map(); + + for (const recipe of recipes) { + const registration = registry.getRegistration(recipe)!; + const seed = 4321; + const baseParams = registration.getParams(createRng(seed)); + + for (const descriptor of registration.params) { + const key = `${recipe}.${descriptor.name}`; + declaredKeys.add(key); + + if (!(descriptor.name in baseParams)) { + failures.push(`${key}: no baseline value from getParams`); + continue; + } + + const expectedChange = !(key in KNOWN_UNWIRED); + + try { + await assertOverrideChangesOutput( + recipe, + descriptor.name, + seed, + expectedChange, + ); + observations.set(key, expectedChange); + } catch (err) { + const message = (err as Error).message; + if (expectedChange) { + failures.push(`${key}: ${message}`); + } else { + // Allowlisted, but the override now changes output: stale. + observations.set(key, true); + } + } + } + } + + const { stale, missing } = evaluateAllowlist( + KNOWN_UNWIRED, + declaredKeys, + observations, + ); + + expect(failures, `Override failures:\n${failures.join("\n")}`).toEqual([]); + expect( + stale, + `Stale KNOWN_UNWIRED entries:\n${stale.join("\n")}`, + ).toEqual([]); + expect( + missing, + `KNOWN_UNWIRED entries with no matching declared parameter:\n${missing.join("\n")}`, + ).toEqual([]); + }, + 180_000, + ); + + /** + * Unit tests of the override-check helper and the allowlist guard. + * + * The real allowlist is empty, so these tests exercise the guard machinery + * with synthetic entries to prove it still fails on a weakened guard. + */ + describe("override guard correctness", () => { + it("fails when a non-allowlisted parameter's override changes output", async () => { + // weapon-laser-zap.carrierFreq is wired, so `expectedChange = true` + // passes. Passing `false` must throw (stale detection). + await expect( + assertOverrideChangesOutput("weapon-laser-zap", "carrierFreq", 4321, false), + ).rejects.toThrow(/now changes output/); + }); + + it("flags an allowlisted parameter that now changes output as stale", () => { + const result = evaluateAllowlist( + { "recipe.param": "legacy gap" }, + new Set(["recipe.param"]), + new Map([["recipe.param", true]]), + ); + expect(result.stale).toEqual([ + "recipe.param: now honoured — remove from KNOWN_UNWIRED (legacy gap)", + ]); + expect(result.missing).toEqual([]); + }); + + it("flags an allowlisted key with no declared parameter as missing", () => { + const result = evaluateAllowlist( + { "recipe.ghost": "legacy gap" }, + new Set(["recipe.param"]), + new Map(), + ); + expect(result.stale).toEqual([]); + expect(result.missing).toEqual(["recipe.ghost"]); + }); + + it("accepts an allowlisted parameter whose override still has no effect", () => { + const result = evaluateAllowlist( + { "recipe.param": "legacy gap" }, + new Set(["recipe.param"]), + new Map([["recipe.param", false]]), + ); + expect(result.stale).toEqual([]); + expect(result.missing).toEqual([]); + }); + }); +}); diff --git a/src/core/renderer.ts b/src/core/renderer.ts index ed29c6e0..a36d5d27 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -2,7 +2,9 @@ * Offline Renderer * * Renders recipe audio to buffers using OfflineAudioContext. - * Uses node-web-audio-api for Node.js compatibility. + * Uses the cross-platform OfflineAudioContext abstraction from + * `src/audio/web-audio.ts`: the browser-native Web Audio API in the browser + * and node-web-audio-api in Node.js. * * Recipes are discovered via the RecipeRegistry — adding a new recipe * requires only registering it in src/recipes/index.ts; no changes to @@ -11,10 +13,10 @@ * Reference: docs/prd/CORE_PRD.md Section 8 */ -import { OfflineAudioContext } from "node-web-audio-api"; import { createRng } from "./rng.js"; -import { registry } from "../recipes/index.js"; +import { registry, initializeRecipeRegistry } from "../recipes/index.js"; import { profiler } from "./profiler.js"; +import { OfflineAudioContext } from "../audio/web-audio.js"; /** Result of an offline render containing sample data. */ export interface RenderResult { @@ -31,7 +33,7 @@ export interface RenderResult { /** * Renders a named recipe with the given seed to an audio buffer. * - * Uses OfflineAudioContext (via node-web-audio-api) to produce + * Uses the cross-platform OfflineAudioContext abstraction to produce * deterministic output for the same recipe + seed combination. * * The recipe is looked up in the RecipeRegistry. Its `getDuration` @@ -45,11 +47,60 @@ export interface RenderResult { * @returns Promise resolving to the rendered audio data. * @throws If the recipe is not found in the registry. */ +/** Preset-shaped input accepted by {@link renderPreset}. */ +export interface RenderPresetInput { + /** Registered recipe name. */ + recipe: string; + /** Deterministic integer seed. */ + seed: number; + /** + * Parameter overrides applied on top of the seed-derived baseline. + * + * Only declared parameters are honoured. File-backed recipes apply their + * explicit declarative mappings after the base overrides, so a computed + * mapping (for example `modulator.frequency = carrier.frequency * + * modRatio`) sees the overridden value of every parameter it references. + */ + overrides?: Record; +} + export async function renderRecipe( recipeName: string, seed: number, duration?: number, ): Promise { + return renderRecipeInternal(recipeName, seed, duration, undefined); +} + +/** + * Renders a preset-shaped `{ recipe, seed, overrides }` input. + * + * Overrides replace the seed-derived value for the named parameters; an empty + * or omitted `overrides` object produces output byte-identical to + * `renderRecipe(recipe, seed)`. + */ +export async function renderPreset( + input: RenderPresetInput, + duration?: number, +): Promise { + return renderRecipeInternal( + input.recipe, + input.seed, + duration, + input.overrides, + ); +} + +async function renderRecipeInternal( + recipeName: string, + seed: number, + duration: number | undefined, + overrides: Record | undefined, +): Promise { + // File-backed recipes are discovered asynchronously; ensure they are + // registered before resolving the recipe. Idempotent and cached. + await initializeRecipeRegistry(); + const registration = registry.getRegistration(recipeName); if (!registration) { throw new Error(`Recipe not found: ${recipeName}`); @@ -60,7 +111,7 @@ export async function renderRecipe( // so the parameter sequence is deterministic regardless of whether // a duration override is provided. const durationRng = createRng(seed); - const renderDuration = duration ?? registration.getDuration(durationRng); + const renderDuration = duration ?? registration.getDuration(durationRng, overrides); const sampleRate = 44100; const length = Math.ceil(sampleRate * renderDuration); @@ -72,7 +123,7 @@ export async function renderRecipe( // Await the result to support both sync recipes (returning void) // and async recipes (returning Promise) that load samples. const graphRng = createRng(seed); - await registration.buildOfflineGraph(graphRng, ctx, renderDuration); + await registration.buildOfflineGraph(graphRng, ctx, renderDuration, overrides); profiler.mark("graph_build"); const audioBuffer = await ctx.startRendering(); diff --git a/src/core/tonegraph-schema.test.ts b/src/core/tonegraph-schema.test.ts index b483acc3..fd580589 100644 --- a/src/core/tonegraph-schema.test.ts +++ b/src/core/tonegraph-schema.test.ts @@ -130,30 +130,427 @@ describe("validateToneGraph", () => { expect(() => validateToneGraph(doc)).toThrow("Unsupported ToneGraph version"); }); - it("rejects sequences as reserved for v0.2", () => { + it("accepts sequences targeting a node AudioParam", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", params: { frequency: 220 } }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.2, value: 660 }, + ], + }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.sequences).toHaveLength(1); + expect(validated.sequences?.[0]?.node).toBe("osc"); + expect(validated.sequences?.[0]?.events).toEqual([ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.2, value: 660 }, + ]); + }); + + it("accepts a sequence lfo event with an optional wave", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "lfo", rate: 4, depth: 20, offset: 440, start: 0, end: 0.2, step: 1 / 64, wave: "square" }, + ], + }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.sequences?.[0]?.events[0]).toMatchObject({ kind: "lfo", rate: 4, depth: 20, wave: "square" }); + }); + + it("rejects a sequence that references an unknown node", () => { const doc = { version: "0.1", nodes: { osc: { kind: "oscillator" }, }, routing: [], - sequences: [], + sequences: [{ node: "ghost", param: "frequency", events: [{ kind: "set", time: 0, value: 1 }] }], }; - expect(() => validateToneGraph(doc)).toThrow("reserved for v0.2"); + expect(() => validateToneGraph(doc)).toThrow('sequences[0].node references unknown node "ghost"'); }); - it("rejects namespaces as reserved for v0.2", () => { + it("rejects a sequence event with an unsupported kind", () => { const doc = { version: "0.1", nodes: { osc: { kind: "oscillator" }, }, routing: [], - namespaces: {}, + sequences: [{ node: "osc", param: "frequency", events: [{ kind: "explode", time: 0, value: 1 }] }], + }; + + expect(() => validateToneGraph(doc)).toThrow('is invalid. Allowed kinds: set, linearRamp, exponentialRamp, lfo'); + }); + + it("accepts and preserves node automation with all event kinds", () => { + const doc = { + version: "0.1", + nodes: { + osc: { + kind: "oscillator", + params: { type: "sine", frequency: 220 }, + automation: { + frequency: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.2, value: 880 }, + { kind: "exponentialRamp", time: 0.4, value: 60 }, + { kind: "lfo", rate: 4, depth: 20, offset: 440, wave: "sine" }, + ], + }, + }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.nodes.osc?.automation).toEqual({ + frequency: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.2, value: 880 }, + { kind: "exponentialRamp", time: 0.4, value: 60 }, + { kind: "lfo", rate: 4, depth: 20, offset: 440, wave: "sine" }, + ], + }); + }); + + it("accepts node automation on a biquadFilter frequency", () => { + const doc = { + version: "0.1", + nodes: { + filter: { + kind: "biquadFilter", + params: { type: "bandpass", frequency: 1000 }, + automation: { + frequency: [ + { kind: "set", time: 0, value: 1000 }, + { kind: "exponentialRamp", time: 0.3, value: 200 }, + ], + }, + }, + out: { kind: "destination" }, + }, + routing: [{ from: "filter", to: "out" }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.nodes.filter?.automation?.frequency).toHaveLength(2); + }); + + it("rejects node automation that is not an object", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", automation: [] }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow( + "must be an object mapping AudioParam names to event arrays", + ); + }); + + it("rejects a node automation entry whose events are not an array", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", automation: { frequency: { kind: "set" } } }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("must be an array of events"); + }); + + it("rejects node automation with an unsupported event kind", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", automation: { frequency: [{ kind: "wobble", time: 0, value: 1 }] } }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow( + 'Allowed kinds: set, linearRamp, exponentialRamp, lfo', + ); + }); + + it("rejects node automation with a non-finite time or value", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", automation: { frequency: [{ kind: "set", time: 0, value: Number.NaN }] } }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("must be a finite number"); + }); + + it("rejects an exponentialRamp node automation to a value <= 0", () => { + const doc = { + version: "0.1", + nodes: { + osc: { + kind: "oscillator", + automation: { frequency: [{ kind: "exponentialRamp", time: 0.2, value: 0 }] }, + }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("must be greater than 0 for an exponentialRamp"); + }); + + it("accepts a sequence exponentialRamp event", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", params: { frequency: 440 } }, + }, + routing: [], + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "set", time: 0, value: 440 }, + { kind: "exponentialRamp", time: 0.2, value: 60 }, + ], + }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.sequences?.[0]?.events).toEqual([ + { kind: "set", time: 0, value: 440 }, + { kind: "exponentialRamp", time: 0.2, value: 60 }, + ]); + }); + + it("rejects a sequence exponentialRamp to a value <= 0", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + sequences: [ + { + node: "osc", + param: "frequency", + events: [{ kind: "exponentialRamp", time: 0.2, value: -1 }], + }, + ], + }; + + expect(() => validateToneGraph(doc)).toThrow("must be greater than 0 for an exponentialRamp"); + }); + + it("rejects a sequence that omits events", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + sequences: [{ node: "osc", param: "frequency" }], + }; + + expect(() => validateToneGraph(doc)).toThrow(".events must be an array"); + }); + + it("rejects a sequence event with a non-finite value", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + sequences: [{ node: "osc", param: "frequency", events: [{ kind: "set", time: 0, value: Number.NaN }] }], + }; + + expect(() => validateToneGraph(doc)).toThrow("must be a finite number"); + }); + + it("accepts bus routing with declared buses", () => { + const doc = { + version: "0.1", + buses: { + mix: { gain: 0.5 }, + }, + nodes: { + osc: { kind: "oscillator" }, + noise: { kind: "noise" }, + filter: { kind: "biquadFilter" }, + out: { kind: "destination" }, + }, + routing: [ + { bus: "mix", from: ["osc", "noise"], to: ["filter"] }, + { from: "filter", to: "out" }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.buses).toEqual({ mix: { gain: 0.5 } }); + expect(validated.routing).toHaveLength(2); + }); + + it("accepts a bus entry with a single string input and output", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: "osc", to: "out" }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([{ bus: "mix", from: "osc", to: "out" }]); + }); + + it("rejects bus routing that references an undeclared bus", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ bus: "missing", from: "osc", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow('unknown bus "missing"'); + }); + + it("rejects a bus entry with neither inputs nor outputs", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [{ bus: "mix" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("must declare at least one input or output"); + }); + + it("rejects a bus entry with an empty input list", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: [], to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("must not be empty"); + }); + + it("rejects a bus definition with a non-numeric gain", () => { + const doc = { + version: "0.1", + buses: { mix: { gain: "loud" } }, + nodes: { + out: { kind: "destination" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("buses.mix.gain must be a finite number"); + }); + + it("rejects a bus id that collides with a node id", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + mix: { kind: "gain" }, + out: { kind: "destination" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("collides with a node id"); + }); + + it("rejects a node id that collides with a generated bus reference", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + "bus:mix": { kind: "gain" }, + out: { kind: "destination" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("conflicts with bus reference"); + }); + + it("rejects bus inputs that reference an AudioParam endpoint", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: "osc.frequency", to: "out" }], }; - expect(() => validateToneGraph(doc)).toThrow("reserved for v0.2"); + expect(() => validateToneGraph(doc)).toThrow("cannot reference AudioParam endpoint"); + }); + + it("rejects bus routing that references an unknown node", () => { + const doc = { + version: "0.1", + buses: { mix: {} }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: ["osc", "ghost"], to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("unknown node"); }); it("rejects invalid meta.parameters bounds", () => { @@ -172,4 +569,214 @@ describe("validateToneGraph", () => { expect(() => validateToneGraph(doc)).toThrow("min must be <= max"); }); + + // --- Namespace tests --- + + it("accepts a valid namespace declaration", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + filter: { kind: "biquadFilter" }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "filter", "out"] }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.namespaces).toEqual({ sfx: { nodes: ["osc", "filter"] } }); + }); + + it("accepts routing with namespace-qualified references in flat links", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + filter: { kind: "biquadFilter" }, + out: { kind: "destination" }, + }, + routing: [ + { from: "ns/sfx/osc", to: "ns/sfx/filter" }, + { from: "ns/sfx/filter", to: "out" }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([ + { from: "ns/sfx/osc", to: "ns/sfx/filter" }, + { from: "ns/sfx/filter", to: "out" }, + ]); + }); + + it("accepts routing with namespace-qualified references in chains", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter", "env"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + filter: { kind: "biquadFilter" }, + env: { kind: "envelope" }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["ns/sfx/osc", "ns/sfx/filter", "ns/sfx/env", "out"] }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([ + { chain: ["ns/sfx/osc", "ns/sfx/filter", "ns/sfx/env", "out"] }, + ]); + }); + + it("accepts namespace-qualified references in bus routing", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "noise"] }, + }, + buses: { mix: {} }, + nodes: { + osc: { kind: "oscillator" }, + noise: { kind: "noise" }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: ["ns/sfx/osc", "ns/sfx/noise"], to: "out" }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([ + { bus: "mix", from: ["ns/sfx/osc", "ns/sfx/noise"], to: "out" }, + ]); + }); + + it("accepts namespace references with AudioParam endpoints", () => { + const doc = { + version: "0.1", + namespaces: { + mod: { nodes: ["lfo"] }, + sfx: { nodes: ["osc"] }, + }, + nodes: { + lfo: { kind: "lfo" }, + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [ + { from: "ns/mod/lfo", to: "ns/sfx/osc.frequency" }, + { from: "ns/sfx/osc", to: "out" }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toHaveLength(2); + }); + + it("rejects a namespace reference to an AudioParam endpoint on \"from\"", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "ns/sfx/osc.frequency", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("cannot reference AudioParam endpoint"); + }); + + it("rejects a namespace that references an unknown node", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "ghost"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow('references unknown node "ghost"'); + }); + + it("rejects routing that references an unknown namespace", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "ns/ghost/osc", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow('references unknown namespace "ghost"'); + }); + + it("rejects routing that references a node not in the namespace", () => { + const doc = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + filter: { kind: "biquadFilter" }, + out: { kind: "destination" }, + }, + routing: [{ from: "ns/sfx/filter", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow('references unknown node "filter" in namespace "sfx"'); + }); + + it("rejects a namespace that collides with a node id", () => { + const doc = { + version: "0.1", + namespaces: { + osc: { nodes: ["osc"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow('collides with a node id'); + }); + + it("rejects an empty namespace name", () => { + const doc = { + version: "0.1", + namespaces: { + "": { nodes: ["osc"] }, + }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("contains an empty namespace name"); + }); }); diff --git a/src/core/tonegraph-schema.ts b/src/core/tonegraph-schema.ts index 3a9bf153..dfa139fd 100644 --- a/src/core/tonegraph-schema.ts +++ b/src/core/tonegraph-schema.ts @@ -35,8 +35,27 @@ export interface ToneGraphTransport { timeSignature?: [number, number]; } +/** + * A named mixer bus declared at the document level. The loader materialises + * each bus as a GainNode in the Web Audio graph, addressed by the reserved + * reference `bus:` from routing entries. + */ +export interface ToneGraphBusDefinition { + gain?: number; +} + +/** + * A namespace groups existing nodes under a named prefix. Routing entries + * may reference namespace-qualified nodes using the `ns//` + * syntax, which resolves to the actual node id declared in the namespace. + */ +export interface ToneGraphNamespaceDefinition { + nodes: string[]; +} + export interface ToneGraphDestinationNode { kind: "destination"; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphGainNode { @@ -44,6 +63,7 @@ export interface ToneGraphGainNode { params?: { gain?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphOscillatorNode { @@ -53,6 +73,7 @@ export interface ToneGraphOscillatorNode { frequency?: number; detune?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphNoiseNode { @@ -61,6 +82,7 @@ export interface ToneGraphNoiseNode { color?: "white" | "pink" | "brown"; level?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphBiquadFilterNode { @@ -71,6 +93,7 @@ export interface ToneGraphBiquadFilterNode { Q?: number; gain?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphBufferSourceNode { @@ -80,6 +103,7 @@ export interface ToneGraphBufferSourceNode { loop?: boolean; playbackRate?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphEnvelopeNode { @@ -90,6 +114,7 @@ export interface ToneGraphEnvelopeNode { sustain?: number; release?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphLfoNode { @@ -100,6 +125,7 @@ export interface ToneGraphLfoNode { depth?: number; offset?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphConstantNode { @@ -107,6 +133,7 @@ export interface ToneGraphConstantNode { params?: { value?: number; }; + automation?: ToneGraphNodeAutomation; } export interface ToneGraphFmPatternNode { @@ -116,6 +143,7 @@ export interface ToneGraphFmPatternNode { modulatorFrequency?: number; modulationIndex?: number; }; + automation?: ToneGraphNodeAutomation; } export type ToneGraphNodeDefinition = @@ -139,7 +167,71 @@ export interface ToneGraphRoutingChain { chain: [string, string, ...string[]]; } -export type ToneGraphRoutingEntry = ToneGraphRoutingLink | ToneGraphRoutingChain; +/** + * Bus routing entry: fans `from` inputs into the named bus and fans the bus + * out to `to` outputs. `from`/`to` accept either a single endpoint reference + * or a list, enabling fan-in (`from` array) and fan-out (`to` array). + */ +export interface ToneGraphRoutingBus { + bus: string; + from?: string | string[]; + to?: string | string[]; +} + +export type ToneGraphSequenceEventKind = "set" | "linearRamp" | "exponentialRamp" | "lfo"; + +export interface ToneGraphSequenceSetEvent { + kind: "set"; + time: number; + value: number; +} + +export interface ToneGraphSequenceLinearRampEvent { + kind: "linearRamp"; + time: number; + value: number; +} + +export interface ToneGraphSequenceExponentialRampEvent { + kind: "exponentialRamp"; + time: number; + value: number; +} + +export interface ToneGraphSequenceLfoEvent { + kind: "lfo"; + rate: number; + depth: number; + wave?: "sine" | "square" | "sawtooth" | "triangle"; + offset?: number; + start?: number; + end?: number; + step?: number; +} + +export type ToneGraphSequenceEvent = + | ToneGraphSequenceSetEvent + | ToneGraphSequenceLinearRampEvent + | ToneGraphSequenceExponentialRampEvent + | ToneGraphSequenceLfoEvent; + +/** + * Per-node automation map: AudioParam name -> ordered event list. Uses the + * same event kinds as document-level `sequences` so authors only learn one + * contract. Preserved verbatim by the validator and consumed by the loader. + */ +export type ToneGraphNodeAutomation = Record; + +export interface ToneGraphSequence { + node: string; + param: string; + events: ToneGraphSequenceEvent[]; +} + +export type ToneGraphRoutingEntry = + | ToneGraphRoutingLink + | ToneGraphRoutingChain + | ToneGraphRoutingBus; export interface ToneGraphDocument { version: ToneGraphVersion; @@ -149,6 +241,9 @@ export interface ToneGraphDocument { transport?: ToneGraphTransport; nodes: Record; routing: ToneGraphRoutingEntry[]; + buses?: Record; + sequences?: ToneGraphSequence[]; + namespaces?: Record; } type UnknownRecord = Record; @@ -302,15 +397,19 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe const paramsRaw = value.params; assertOptionalRecord(paramsRaw, `${path}.params`); const params = paramsRaw ?? undefined; + const automation = validateNodeAutomation(value.automation, `${path}.automation`); + let node: ToneGraphNodeDefinition; switch (kind) { case "destination": - return { kind }; + node = { kind }; + break; case "gain": if (params?.gain !== undefined) { assertNumber(params.gain, `${path}.params.gain`); } - return { kind, params: params as ToneGraphGainNode["params"] }; + node = { kind, params: params as ToneGraphGainNode["params"] }; + break; case "oscillator": if (params?.type !== undefined) { assertString(params.type, `${path}.params.type`); @@ -324,7 +423,8 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.detune !== undefined) { assertNumber(params.detune, `${path}.params.detune`); } - return { kind, params: params as ToneGraphOscillatorNode["params"] }; + node = { kind, params: params as ToneGraphOscillatorNode["params"] }; + break; case "noise": if (params?.color !== undefined) { assertString(params.color, `${path}.params.color`); @@ -335,7 +435,8 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.level !== undefined) { assertNumber(params.level, `${path}.params.level`); } - return { kind, params: params as ToneGraphNoiseNode["params"] }; + node = { kind, params: params as ToneGraphNoiseNode["params"] }; + break; case "biquadFilter": if (params?.type !== undefined) { assertString(params.type, `${path}.params.type`); @@ -352,7 +453,8 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.gain !== undefined) { assertNumber(params.gain, `${path}.params.gain`); } - return { kind, params: params as ToneGraphBiquadFilterNode["params"] }; + node = { kind, params: params as ToneGraphBiquadFilterNode["params"] }; + break; case "bufferSource": if (params?.sample !== undefined) { assertString(params.sample, `${path}.params.sample`); @@ -363,7 +465,8 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.playbackRate !== undefined) { assertNumber(params.playbackRate, `${path}.params.playbackRate`); } - return { kind, params: params as ToneGraphBufferSourceNode["params"] }; + node = { kind, params: params as ToneGraphBufferSourceNode["params"] }; + break; case "envelope": if (params?.attack !== undefined) { assertNumber(params.attack, `${path}.params.attack`); @@ -377,7 +480,8 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.release !== undefined) { assertNumber(params.release, `${path}.params.release`); } - return { kind, params: params as ToneGraphEnvelopeNode["params"] }; + node = { kind, params: params as ToneGraphEnvelopeNode["params"] }; + break; case "lfo": if (params?.type !== undefined) { assertString(params.type, `${path}.params.type`); @@ -394,12 +498,14 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.offset !== undefined) { assertNumber(params.offset, `${path}.params.offset`); } - return { kind, params: params as ToneGraphLfoNode["params"] }; + node = { kind, params: params as ToneGraphLfoNode["params"] }; + break; case "constant": if (params?.value !== undefined) { assertNumber(params.value, `${path}.params.value`); } - return { kind, params: params as ToneGraphConstantNode["params"] }; + node = { kind, params: params as ToneGraphConstantNode["params"] }; + break; case "fmPattern": if (params?.carrierFrequency !== undefined) { assertNumber(params.carrierFrequency, `${path}.params.carrierFrequency`); @@ -410,10 +516,24 @@ function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDe if (params?.modulationIndex !== undefined) { assertNumber(params.modulationIndex, `${path}.params.modulationIndex`); } - return { kind, params: params as ToneGraphFmPatternNode["params"] }; + node = { kind, params: params as ToneGraphFmPatternNode["params"] }; + break; default: throw new Error(`${path}.kind is unsupported.`); } + + if (automation !== undefined) { + node.automation = automation; + } + + return node; +} + +function isEndpointList(value: unknown): value is string | string[] { + if (typeof value === "string") { + return true; + } + return Array.isArray(value) && value.every((item) => typeof item === "string"); } function validateRoutingEntry(value: unknown, index: number): ToneGraphRoutingEntry { @@ -423,6 +543,50 @@ function validateRoutingEntry(value: unknown, index: number): ToneGraphRoutingEn const hasFrom = Object.prototype.hasOwnProperty.call(value, "from"); const hasTo = Object.prototype.hasOwnProperty.call(value, "to"); const hasChain = Object.prototype.hasOwnProperty.call(value, "chain"); + const hasBus = Object.prototype.hasOwnProperty.call(value, "bus"); + + if (hasBus) { + if (hasChain) { + throw new Error(`${path} must not combine "bus" with "chain".`); + } + + const bus = value.bus; + assertString(bus, `${path}.bus`); + if (bus.trim().length === 0) { + throw new Error(`${path}.bus must not be empty.`); + } + + const from = value.from; + const to = value.to; + + if (from === undefined && to === undefined) { + throw new Error(`${path} must declare at least one input or output.`); + } + + if (from !== undefined) { + if (!isEndpointList(from)) { + throw new Error(`${path}.from must be a string or an array of strings.`); + } + if (Array.isArray(from) && from.length === 0) { + throw new Error(`${path}.from must not be empty.`); + } + } + + if (to !== undefined) { + if (!isEndpointList(to)) { + throw new Error(`${path}.to must be a string or an array of strings.`); + } + if (Array.isArray(to) && to.length === 0) { + throw new Error(`${path}.to must not be empty.`); + } + } + + return { + bus, + from: from as string | string[] | undefined, + to: to as string | string[] | undefined, + }; + } if (hasChain) { if (hasFrom || hasTo) { @@ -453,30 +617,138 @@ function validateRoutingEntry(value: unknown, index: number): ToneGraphRoutingEn return { from, to }; } -function parseEndpointReference(ref: string): { nodeId: string; param?: string } { - const dotIndex = ref.indexOf("."); - if (dotIndex < 0) { - return { nodeId: ref }; +const SUPPORTED_WAVES = new Set(["sine", "square", "sawtooth", "triangle"]); + +function validateSequenceEvent( + event: unknown, + path: string, +): ToneGraphSequenceEvent { + assertRecord(event, path); + + const kind = event.kind; + assertString(kind, `${path}.kind`); + + if (kind === "set" || kind === "linearRamp") { + return { + kind, + time: ensureFiniteNumber(event.time, `${path}.time`), + value: ensureFiniteNumber(event.value, `${path}.value`), + }; } - const nodeId = ref.slice(0, dotIndex); - const param = ref.slice(dotIndex + 1); - if (nodeId.length === 0 || param.length === 0) { - throw new Error(`Invalid endpoint reference "${ref}".`); + if (kind === "exponentialRamp") { + const value = ensureFiniteNumber(event.value, `${path}.value`); + if (value <= 0) { + throw new Error(`${path}.value must be greater than 0 for an exponentialRamp event.`); + } + return { + kind: "exponentialRamp", + time: ensureFiniteNumber(event.time, `${path}.time`), + value, + }; } - return { nodeId, param }; + if (kind === "lfo") { + const lfoEvent: ToneGraphSequenceLfoEvent = { + kind: "lfo", + rate: ensureFiniteNumber(event.rate, `${path}.rate`), + depth: ensureFiniteNumber(event.depth, `${path}.depth`), + }; + + if (event.wave !== undefined) { + assertString(event.wave, `${path}.wave`); + if (!SUPPORTED_WAVES.has(event.wave)) { + throw new Error(`${path}.wave must be one of: sine, square, sawtooth, triangle.`); + } + lfoEvent.wave = event.wave as ToneGraphSequenceLfoEvent["wave"]; + } + if (event.offset !== undefined) { + lfoEvent.offset = ensureFiniteNumber(event.offset, `${path}.offset`); + } + if (event.start !== undefined) { + lfoEvent.start = ensureFiniteNumber(event.start, `${path}.start`); + } + if (event.end !== undefined) { + lfoEvent.end = ensureFiniteNumber(event.end, `${path}.end`); + } + if (event.step !== undefined) { + lfoEvent.step = ensureFiniteNumber(event.step, `${path}.step`); + } + + return lfoEvent; + } + + throw new Error( + `${path}.kind "${kind}" is invalid. Allowed kinds: set, linearRamp, exponentialRamp, lfo.`, + ); } -export function validateToneGraph(doc: unknown): ToneGraphDocument { - assertRecord(doc, "ToneGraph document"); +function validateNodeAutomation( + raw: unknown, + path: string, +): ToneGraphNodeAutomation | undefined { + if (raw === undefined) { + return undefined; + } + + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`${path} must be an object mapping AudioParam names to event arrays.`); + } + + const automation: ToneGraphNodeAutomation = {}; + for (const [paramName, events] of Object.entries(raw as UnknownRecord)) { + if (paramName.trim().length === 0) { + throw new Error(`${path} contains an empty AudioParam name.`); + } + if (!Array.isArray(events)) { + throw new Error(`${path}.${paramName} must be an array of events.`); + } + automation[paramName] = events.map((event, index) => + validateSequenceEvent(event, `${path}.${paramName}[${index}]`), + ); + } + + return automation; +} - if (Object.prototype.hasOwnProperty.call(doc, "sequences")) { - throw new Error("ToneGraph field \"sequences\" is reserved for v0.2 and is not allowed in v0.1."); +function validateSequences(value: unknown, path: string): ToneGraphSequence[] { + if (!Array.isArray(value)) { + throw new Error(`${path} must be an array.`); } - if (Object.prototype.hasOwnProperty.call(doc, "namespaces")) { - throw new Error("ToneGraph field \"namespaces\" is reserved for v0.2 and is not allowed in v0.1."); + + return value.map((entry, index) => { + const entryPath = `${path}[${index}]`; + assertRecord(entry, entryPath); + + const node = entry.node; + const param = entry.param; + assertString(node, `${entryPath}.node`); + assertString(param, `${entryPath}.param`); + + const events = entry.events; + if (!Array.isArray(events)) { + throw new Error(`${entryPath}.events must be an array.`); + } + + return { + node, + param, + events: events.map((event, eventIndex) => + validateSequenceEvent(event, `${entryPath}.events[${eventIndex}]`), + ), + }; + }); +} + +function ensureFiniteNumber(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${path} must be a finite number.`); } + return value; +} + +export function validateToneGraph(doc: unknown): ToneGraphDocument { + assertRecord(doc, "ToneGraph document"); const version = doc.version; assertString(version, "version"); @@ -581,27 +853,142 @@ export function validateToneGraph(doc: unknown): ToneGraphDocument { const routing = doc.routing.map((entry, index) => validateRoutingEntry(entry, index)); const nodeIds = new Set(Object.keys(nodes)); + + const buses: Record = {}; + if (doc.buses !== undefined) { + assertRecord(doc.buses, "buses"); + for (const [busId, busDef] of Object.entries(doc.buses)) { + if (busId.trim().length === 0) { + throw new Error("buses contains an empty bus id."); + } + if (nodeIds.has(busId)) { + throw new Error(`Bus id "${busId}" collides with a node id.`); + } + if (nodeIds.has(`bus:${busId}`)) { + throw new Error(`Node id "bus:${busId}" conflicts with bus reference for bus "${busId}".`); + } + assertRecord(busDef, `buses.${busId}`); + if (busDef.gain !== undefined) { + assertNumber(busDef.gain, `buses.${busId}.gain`); + } + buses[busId] = { gain: busDef.gain as number | undefined }; + } + } + + const busIds = new Set(Object.keys(buses)); + + // --- Namespace validation --- + const namespaces: Record = {}; + if (doc.namespaces !== undefined) { + assertRecord(doc.namespaces, "namespaces"); + for (const [nsName, nsDef] of Object.entries(doc.namespaces)) { + if (nsName.trim().length === 0) { + throw new Error("namespaces contains an empty namespace name."); + } + if (nodeIds.has(nsName)) { + throw new Error(`Namespace "${nsName}" collides with a node id.`); + } + if (!isRecord(nsDef)) { + throw new Error(`namespaces."${nsName}" must be an object.`); + } + const nodesRaw = nsDef.nodes; + if (!Array.isArray(nodesRaw)) { + throw new Error(`namespaces."${nsName}".nodes must be an array.`); + } + const nsNodeIds: string[] = []; + for (const nodeId of nodesRaw) { + assertString(nodeId, `namespaces."${nsName}".nodes`); + if (!nodeIds.has(nodeId)) { + throw new Error(`namespaces."${nsName}" references unknown node "${nodeId}".`); + } + nsNodeIds.push(nodeId); + } + namespaces[nsName] = { nodes: nsNodeIds }; + } + } + + const nsIds = new Set(Object.keys(namespaces)); + + // Namespace-aware endpoint reference parsing. + function parseNamespaceReference(ref: string): { nodeId: string; param?: string; ns?: string } { + const dotIndex = ref.indexOf("."); + const nodeId = dotIndex >= 0 ? ref.slice(0, dotIndex) : ref; + const param = dotIndex >= 0 ? ref.slice(dotIndex + 1) : undefined; + + // Check for namespace prefix: ns// + if (nodeId.startsWith("ns/")) { + const parts = nodeId.slice(3).split("/"); + if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) { + throw new Error(`Invalid namespace reference "${ref}".`); + } + return { nodeId: parts[1], param, ns: parts[0] }; + } + + return { nodeId, param }; + } + + function assertEndpointReference(ref: string, path: string, allowParam: boolean): void { + const parsed = parseNamespaceReference(ref); + if (parsed.ns !== undefined) { + if (parsed.param !== undefined && !allowParam) { + throw new Error(`${path} cannot reference AudioParam endpoint "${ref}".`); + } + if (!nsIds.has(parsed.ns)) { + throw new Error(`${path} references unknown namespace "${parsed.ns}".`); + } + const nsDef = namespaces[parsed.ns]; + if (!nsDef.nodes.includes(parsed.nodeId)) { + throw new Error(`${path} references unknown node "${parsed.nodeId}" in namespace "${parsed.ns}".`); + } + return; + } + + if (parsed.param !== undefined && !allowParam) { + throw new Error(`${path} cannot reference AudioParam endpoint "${ref}".`); + } + if (!nodeIds.has(parsed.nodeId)) { + throw new Error(`${path} references unknown node "${ref}".`); + } + } + + const toEndpointList = (value: string | string[] | undefined): string[] => + value === undefined ? [] : Array.isArray(value) ? value : [value]; + routing.forEach((entry, index) => { if ("chain" in entry) { entry.chain.forEach((nodeId, chainIndex) => { - if (!nodeIds.has(nodeId)) { - throw new Error(`routing[${index}].chain[${chainIndex}] references unknown node \"${nodeId}\".`); - } + assertEndpointReference(nodeId, `routing[${index}].chain[${chainIndex}]`, false); }); return; } - const fromEndpoint = parseEndpointReference(entry.from); - const toEndpoint = parseEndpointReference(entry.to); - - if (fromEndpoint.param !== undefined) { - throw new Error(`routing[${index}].from cannot reference AudioParam endpoint \"${entry.from}\".`); + if ("bus" in entry) { + if (!busIds.has(entry.bus)) { + throw new Error(`routing[${index}].bus references unknown bus "${entry.bus}".`); + } + toEndpointList(entry.from).forEach((ref, refIndex) => { + assertEndpointReference(ref, `routing[${index}].from[${refIndex}]`, false); + }); + toEndpointList(entry.to).forEach((ref, refIndex) => { + assertEndpointReference(ref, `routing[${index}].to[${refIndex}]`, true); + }); + return; } - if (!nodeIds.has(fromEndpoint.nodeId)) { - throw new Error(`routing[${index}].from references unknown node \"${entry.from}\".`); + + // For flat links, reject AudioParam endpoints on "from". + assertEndpointReference(entry.from, `routing[${index}].from`, false); + assertEndpointReference(entry.to, `routing[${index}].to`, true); + }); + + const sequences = doc.sequences !== undefined + ? validateSequences(doc.sequences, "sequences") + : undefined; + sequences?.forEach((sequence, index) => { + if (!nodeIds.has(sequence.node)) { + throw new Error(`sequences[${index}].node references unknown node "${sequence.node}".`); } - if (!nodeIds.has(toEndpoint.nodeId)) { - throw new Error(`routing[${index}].to references unknown node \"${entry.to}\".`); + if (sequence.param.trim().length === 0) { + throw new Error(`sequences[${index}].param must not be empty.`); } }); @@ -611,6 +998,14 @@ export function validateToneGraph(doc: unknown): ToneGraphDocument { routing, }; + if (sequences !== undefined) { + validated.sequences = sequences; + } + + if (doc.buses !== undefined) { + validated.buses = buses; + } + if (doc.engine !== undefined) { validated.engine = { backend: doc.engine.backend as ToneGraphEngine["backend"] }; } @@ -638,6 +1033,9 @@ export function validateToneGraph(doc: unknown): ToneGraphDocument { timeSignature: doc.transport.timeSignature as [number, number] | undefined, }; } + if (Object.keys(namespaces).length > 0) { + validated.namespaces = namespaces; + } return validated; } diff --git a/src/core/tonegraph.integration.test.ts b/src/core/tonegraph.integration.test.ts index 90f29983..93a26df0 100644 --- a/src/core/tonegraph.integration.test.ts +++ b/src/core/tonegraph.integration.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect } from "vitest"; import { OfflineAudioContext } from "node-web-audio-api"; -import { resolve, dirname } from "node:path"; +import { resolve, dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { createRng } from "./rng.js"; +import yaml from "js-yaml"; +import { createRng, rr } from "./rng.js"; import { compareBuffers, formatCompareResult } from "../test-utils/buffer-compare.js"; import { RecipeRegistry, discoverFileBackedRecipes, type RecipeRegistration } from "./recipe.js"; +import { validateToneGraph, type ToneGraphDocument } from "./tonegraph-schema.js"; +import { loadToneGraph } from "./tonegraph.js"; const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "presets", "recipes"); const SAMPLE_RATE = 44100; @@ -18,6 +23,7 @@ const MIGRATED = [ "footstep-gravel", "ambient-wind-gust", "card-transform", + "frequency-sweep-demo", ] as const; interface RenderResult { @@ -26,6 +32,53 @@ interface RenderResult { peak: number; } +/** + * Frozen historical reference: the removed TypeScript `ui-scifi-confirm` + * graph builder (`uiSciFiConfirmOfflineGraph` in `src/recipes/index.ts`, + * deleted when the recipe was migrated to file-backed ToneGraph in + * TF-0MN0XQ1AN111ZB8F). + * + * This is a deliberate characterisation reference, not production logic: it + * is intentionally self-contained so the parity test can prove the migrated + * `presets/recipes/ui-scifi-confirm.yaml` reproduces the historical graph + * byte-for-byte. It uses the same seed-derived parameter ranges and the same + * Web Audio API calls as the original implementation. + */ +async function renderHistoricalUiSciFiConfirm(seed: number): Promise { + const rng = createRng(seed); + const frequency = rr(rng, 400, 1200); + const attack = rr(rng, 0.001, 0.01); + const decay = rr(rng, 0.05, 0.3); + const filterCutoff = rr(rng, 800, 4000); + const duration = attack + decay; + + const frameCount = Math.ceil(SAMPLE_RATE * duration); + const ctx = new OfflineAudioContext(1, frameCount, SAMPLE_RATE); + + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.value = frequency; + + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = filterCutoff; + + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0, 0); + gain.gain.linearRampToValueAtTime(1, attack); + gain.gain.linearRampToValueAtTime(0, attack + decay); + + osc.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + + osc.start(0); + osc.stop(duration); + + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); +} + async function renderRegistration(registration: RecipeRegistration, seed: number): Promise { const duration = registration.getDuration(createRng(seed)); const ctx = new OfflineAudioContext(1, Math.ceil(SAMPLE_RATE * duration), SAMPLE_RATE); @@ -76,6 +129,147 @@ describe("ToneGraph integration parity", () => { } }); + it("ui-scifi-confirm renders byte-identical to the historical TypeScript implementation at seed 42", async () => { + const fileBackedRegistry = new RecipeRegistry(); + await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + const fileBacked = fileBackedRegistry.getRegistration("ui-scifi-confirm"); + expect(fileBacked, "ui-scifi-confirm should be discovered from presets/recipes").toBeDefined(); + + const reference = await renderHistoricalUiSciFiConfirm(SEED); + const migrated = (await renderRegistration(fileBacked!, SEED)).samples; + + // AC1: raw samples must match byte-for-byte. + const comparison = compareBuffers(reference, migrated); + expect( + comparison.identical, + `ui-scifi-confirm ToneGraph output diverged from the historical TypeScript implementation at seed ${SEED}:\n${formatCompareResult(comparison)}`, + ).toBe(true); + + // AC4: repeated runs on the same platform stay deterministic. + const referenceAgain = await renderHistoricalUiSciFiConfirm(SEED); + const migratedAgain = (await renderRegistration(fileBacked!, SEED)).samples; + expect(compareBuffers(reference, referenceAgain).identical).toBe(true); + expect(compareBuffers(migrated, migratedAgain).identical).toBe(true); + }); + + it("reports the first divergent sample when buffers differ", () => { + // AC2: a mismatch must produce actionable diagnostics — not a bare failure. + const expected = new Float32Array([0, 0.1, 0.2, 0.3]); + const actual = new Float32Array([0, 0.1, 0.9, 0.3]); + + const comparison = compareBuffers(expected, actual); + + expect(comparison.identical).toBe(false); + expect(comparison.firstDivergentIndex).toBe(2); + expect(comparison.valueA).toBeCloseTo(0.2); + expect(comparison.valueB).toBeCloseTo(0.9); + expect(formatCompareResult(comparison)).toContain("sample 2"); + }); + + it("preserves node automation through the file-backed path so it changes output", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "tonegraph-automation-")); + try { + const baseGraph = { + version: "0.1", + meta: { name: "automation-fixture", duration: 0.3 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + gain: { kind: "gain", params: { gain: 0.2 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "gain", "out"] }], + }; + + const automationGraph = JSON.parse(JSON.stringify(baseGraph)) as typeof baseGraph; + (automationGraph.nodes.osc as Record).automation = { + frequency: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.15, value: 880 }, + { kind: "exponentialRamp", time: 0.3, value: 60 }, + ], + }; + + await writeFile(join(tempDir, "automation-fixture.json"), JSON.stringify(automationGraph)); + await writeFile(join(tempDir, "static-fixture.json"), JSON.stringify(baseGraph)); + + const registry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(registry, { recipeDirectory: tempDir }); + expect(discovered).toContain("automation-fixture"); + expect(discovered).toContain("static-fixture"); + + const automated = registry.getRegistration("automation-fixture")!; + const statically = registry.getRegistration("static-fixture")!; + + const automatedFirst = await renderRegistration(automated, SEED); + const automatedSecond = await renderRegistration(automated, SEED); + const staticRender = await renderRegistration(statically, SEED); + + // AC5: same recipe + seed twice is byte-identical. + expect(compareBuffers(automatedFirst.samples, automatedSecond.samples).identical).toBe(true); + expect(automatedFirst.peak).toBeGreaterThan(PEAK_FLOOR); + + // AC1/AC2: automation survives validation, so the swept render differs + // from a static-frequency render of the same graph. + expect(automatedFirst.samples.length).toBe(staticRender.samples.length); + const identical = automatedFirst.samples.every( + (sample, index) => sample === staticRender.samples[index], + ); + expect(identical).toBe(false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("discovers a file-backed recipe that uses sequences and renders it deterministically", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "tonegraph-seq-")); + try { + const graph = { + version: "0.1", + meta: { + name: "sequence-fixture", + duration: 0.25, + parameters: [ + { name: "frequency", type: "number", min: 200, max: 800, default: 220 }, + ], + }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + amp: { kind: "gain", params: { gain: 0.25 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "amp", "out"] }], + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.25, value: 660 }, + ], + }, + ], + }; + + await writeFile(join(tempDir, "sequence-fixture.json"), JSON.stringify(graph)); + + const registry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(registry, { recipeDirectory: tempDir }); + expect(discovered).toContain("sequence-fixture"); + + const registration = registry.getRegistration("sequence-fixture"); + expect(registration).toBeDefined(); + + const first = await renderRegistration(registration!, SEED); + const second = await renderRegistration(registration!, SEED); + + expect(compareBuffers(first.samples, second.samples).identical).toBe(true); + expect(first.peak).toBeGreaterThan(PEAK_FLOOR); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("migrated recipes remain structurally valid", async () => { const fileBackedRegistry = new RecipeRegistry(); await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); @@ -95,4 +289,57 @@ describe("ToneGraph integration parity", () => { expect(fileRender.duration).toBeGreaterThan(0); } }); + + /** + * AC5 (TF-0MUVK21Q9003PW21): `ambient-wind-gust` and `card-transform` declare + * inline node automation. Validation must preserve it so the rendered output + * is actually time-varying (not the static parameter value), and the render + * must remain deterministic. + */ + it.each(["ambient-wind-gust", "card-transform"])( + "honours inline node automation for %s", + async (recipeName) => { + const source = await readFile(join(PRESETS_DIR, `${recipeName}.yaml`), "utf-8"); + const graph = validateToneGraph(yaml.load(source)); + + // The validated graph must carry at least one node-level automation map. + const automatedNodes = Object.entries(graph.nodes).filter( + ([, node]) => (node as { automation?: unknown }).automation !== undefined, + ); + expect( + automatedNodes.length, + `${recipeName} should have at least one node with inline automation after validation`, + ).toBeGreaterThan(0); + + async function renderGraph(candidate: ToneGraphDocument): Promise { + const duration = candidate.meta?.duration ?? 0.5; + const ctx = new OfflineAudioContext(1, Math.ceil(SAMPLE_RATE * duration), SAMPLE_RATE); + const handle = await loadToneGraph(candidate, ctx, createRng(SEED)); + handle.start(0); + handle.stop(handle.duration); + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); + } + + // Strip the automation from a clone to obtain the static-graph baseline. + const staticGraph = JSON.parse(JSON.stringify(graph)) as ToneGraphDocument; + for (const node of Object.values(staticGraph.nodes)) { + delete (node as { automation?: unknown }).automation; + } + + const automated = await renderGraph(graph); + const staticRender = await renderGraph(staticGraph); + + // Time-varying: automation must change the samples versus the static graph. + expect(automated.length).toBe(staticRender.length); + expect( + compareBuffers(automated, staticRender).identical, + `${recipeName} inline automation should change the rendered output`, + ).toBe(false); + + // Determinism: the same graph + seed twice is byte-identical. + const repeat = await renderGraph(graph); + expect(compareBuffers(automated, repeat).identical).toBe(true); + }, + ); }); diff --git a/src/core/tonegraph.test.ts b/src/core/tonegraph.test.ts index 05c241f5..0776b200 100644 --- a/src/core/tonegraph.test.ts +++ b/src/core/tonegraph.test.ts @@ -82,7 +82,7 @@ describe("loadToneGraph", () => { }); it("supports automation set, linear ramp, and lfo", async () => { - const graph = { + const graph: ToneGraphDocument = { version: "0.1", meta: { duration: 0.2 }, nodes: { @@ -101,12 +101,150 @@ describe("loadToneGraph", () => { out: { kind: "destination" }, }, routing: [{ chain: ["osc", "gain", "out"] }], - } as unknown as ToneGraphDocument; + }; const samples = await renderGraph(graph, 4); expect(samples.some((sample) => sample !== 0)).toBe(true); }); + it("applies exponentialRamp node automation to oscillator frequency", async () => { + const sweptGraph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + osc: { + kind: "oscillator", + params: { type: "sine", frequency: 440 }, + automation: { + frequency: [ + { kind: "set", time: 0, value: 440 }, + { kind: "exponentialRamp", time: 0.2, value: 60 }, + ], + }, + }, + gain: { kind: "gain", params: { gain: 0.2 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "gain", "out"] }], + }; + + const baselineGraph = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + gain: { kind: "gain", params: { gain: 0.2 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "gain", "out"] }], + } as ToneGraphDocument; + + const swept = await renderGraph(sweptGraph, 4); + const baseline = await renderGraph(baselineGraph, 4); + + expect(swept.some((sample) => sample !== 0)).toBe(true); + expect(swept.length).toBe(baseline.length); + const identical = swept.every((sample, index) => sample === baseline[index]); + expect(identical).toBe(false); + + const repeat = await renderGraph(sweptGraph, 4); + for (let i = 0; i < swept.length; i += 1) { + expect(swept[i]).toBe(repeat[i]); + } + }); + + it("throws when exponentialRamp automation targets a non-positive value", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.1 }, + nodes: { + osc: { + kind: "oscillator", + params: { frequency: 440 }, + automation: { frequency: [{ kind: "exponentialRamp", time: 0.1, value: 0 }] }, + }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow( + "value must be greater than 0", + ); + }); + + it("schedules the envelope release inside the render window", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.22 }, + nodes: { + dc: { kind: "constant", params: { value: 1 } }, + env: { + kind: "envelope", + params: { attack: 0.01, decay: 0.01, sustain: 1, release: 0.1 }, + }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["dc", "env", "out"] }], + }; + + const duration = graph.meta!.duration!; + const sampleRate = 44100; + const ctx = new OfflineAudioContext(1, Math.ceil(sampleRate * duration), sampleRate); + const handle = await loadToneGraph(graph, ctx, createRng(1)); + handle.start(0); + handle.stop(handle.duration); + const rendered = await ctx.startRendering(); + const samples = new Float32Array(rendered.getChannelData(0)); + const at = (seconds: number): number => + samples[Math.min(samples.length - 1, Math.floor(seconds * sampleRate))]!; + + // Sustain plateau is held before the release window. + expect(at(0.1)).toBeCloseTo(1, 2); + + // The release ramp lies inside the buffer: half-way through it the + // envelope is non-zero and still below the sustain level. + const midRelease = at(duration - 0.05); + expect(midRelease).toBeGreaterThan(0); + expect(midRelease).toBeLessThan(1); + + // The envelope reaches silence by the end of the render window. + expect(Math.abs(samples[samples.length - 1]!)).toBeLessThan(0.05); + }); + + it("holds a release = 0 envelope at sustain until the stop time", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + dc: { kind: "constant", params: { value: 1 } }, + env: { + kind: "envelope", + params: { attack: 0.01, decay: 0.01, sustain: 0.75, release: 0 }, + }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["dc", "env", "out"] }], + }; + + const sampleRate = 44100; + const ctx = new OfflineAudioContext(1, Math.ceil(sampleRate * 0.2), sampleRate); + const handle = await loadToneGraph(graph, ctx, createRng(1)); + handle.start(0); + handle.stop(handle.duration); + const rendered = await ctx.startRendering(); + const samples = new Float32Array(rendered.getChannelData(0)); + const at = (seconds: number): number => + samples[Math.min(samples.length - 1, Math.floor(seconds * sampleRate))]!; + + // Release = 0 keeps the original behaviour: the sustain plateau holds + // through the whole render window and the drop to silence happens at the + // stop boundary (outside the buffer). + expect(at(0.15)).toBeCloseTo(0.75, 2); + expect(samples[samples.length - 1]!).toBeCloseTo(0.75, 2); + }); + it("uses graph.random.seed deterministically for noise", async () => { const graph: ToneGraphDocument = { version: "0.1", @@ -165,6 +303,203 @@ describe("loadToneGraph", () => { expect(samples.some((sample) => sample !== 0)).toBe(true); }); + it("supports bus routing fan-in and fan-out", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + buses: { mix: { gain: 0.5 } }, + nodes: { + oscA: { kind: "oscillator", params: { type: "sine", frequency: 330 } }, + oscB: { kind: "oscillator", params: { type: "sine", frequency: 550 } }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: ["oscA", "oscB"], to: ["out"] }], + }; + + const samples = await renderGraph(graph, 7); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("creates a gain node for each declared bus and applies its gain", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + buses: { mix: { gain: 0.5 } }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: "osc", to: "out" }], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + const handle = await loadToneGraph(graph, ctx, createRng(7)); + + const busNode = handle.nodes["bus:mix"] as GainNode | undefined; + expect(busNode).toBeDefined(); + expect(busNode!.gain.value).toBeCloseTo(0.5, 6); + }); + + it("mixes flat links, chain routing and bus routing together", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + buses: { mix: { gain: 0.4 } }, + nodes: { + osc: { kind: "oscillator", params: { type: "sawtooth", frequency: 220 } }, + filter: { kind: "biquadFilter", params: { type: "lowpass", frequency: 900 } }, + noise: { kind: "noise", params: { color: "white", level: 0.2 } }, + amp: { kind: "gain", params: { gain: 0.3 } }, + out: { kind: "destination" }, + }, + routing: [ + { chain: ["osc", "filter"] }, + { from: "noise", to: "amp" }, + { bus: "mix", from: ["filter", "amp"], to: "out" }, + ], + }; + + const samples = await renderGraph(graph, 8); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("scales bus output by the declared bus gain", async () => { + const makeGraph = (gain: number): ToneGraphDocument => ({ + version: "0.1", + meta: { duration: 0.1 }, + buses: { mix: { gain } }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + out: { kind: "destination" }, + }, + routing: [{ bus: "mix", from: "osc", to: "out" }], + }); + + const full = await renderGraph(makeGraph(1), 9); + const quiet = await renderGraph(makeGraph(0.25), 9); + + const peak = (samples: Float32Array): number => { + let max = 0; + for (const sample of samples) { + const abs = Math.abs(sample); + if (abs > max) { + max = abs; + } + } + return max; + }; + + expect(peak(full)).toBeGreaterThan(0); + expect(peak(quiet) / peak(full)).toBeCloseTo(0.25, 2); + }); + + it("applies sequence events deterministically", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.25 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + amp: { kind: "gain", params: { gain: 0.25 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "amp", "out"] }], + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.25, value: 660 }, + ], + }, + ], + }; + + const first = await renderGraph(graph, 11); + const second = await renderGraph(graph, 11); + + expect(first.length).toBe(second.length); + for (let i = 0; i < first.length; i += 1) { + expect(first[i]).toBe(second[i]); + } + }); + + it("sequence changes rendered output relative to an unsequenced baseline", async () => { + const baseNodes = { + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + amp: { kind: "gain", params: { gain: 0.25 } }, + out: { kind: "destination" }, + } as const; + + const baseline: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.25 }, + nodes: baseNodes, + routing: [{ chain: ["osc", "amp", "out"] }], + }; + + const sequenced: ToneGraphDocument = { + ...baseline, + nodes: { + ...baseNodes, + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + }, + sequences: [ + { + node: "osc", + param: "frequency", + events: [ + { kind: "set", time: 0, value: 220 }, + { kind: "set", time: 0.1, value: 880 }, + ], + }, + ], + }; + + const plainSamples = await renderGraph(baseline, 12); + const sequencedSamples = await renderGraph(sequenced, 12); + + expect(plainSamples.length).toBe(sequencedSamples.length); + const identical = plainSamples.every((sample, index) => sample === sequencedSamples[index]); + expect(identical).toBe(false); + }); + + it("throws when a sequence targets an unknown AudioParam", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.1 }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + sequences: [ + { node: "osc", param: "notAParam", events: [{ kind: "set", time: 0, value: 1 }] }, + ], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow('targets unknown AudioParam "notAParam"'); + }); + + it("throws when a sequence references an unknown node", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.1 }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + sequences: [ + { node: "ghost", param: "frequency", events: [{ kind: "set", time: 0, value: 1 }] }, + ], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow('references unknown node "ghost"'); + }); + it("throws for unsupported node kind values", async () => { const graph = { version: "0.1", @@ -178,4 +513,157 @@ describe("loadToneGraph", () => { const ctx = new OfflineAudioContext(1, 4410, 44100); await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow("Unsupported node kind"); }); + + // --- Namespace resolution tests --- + + it("resolves namespace-qualified chain routing", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter", "env"] }, + }, + meta: { duration: 0.15 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + filter: { kind: "biquadFilter", params: { type: "lowpass", frequency: 2000 } }, + env: { kind: "envelope", params: { attack: 0.005, decay: 0.1, sustain: 0 } }, + out: { kind: "destination" }, + }, + routing: [ + { chain: ["ns/sfx/osc", "ns/sfx/filter", "ns/sfx/env", "out"] }, + ], + }; + + const samples = await renderGraph(graph, 1); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("resolves namespace-qualified flat routing links", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + mod: { nodes: ["lfo"] }, + sfx: { nodes: ["osc"] }, + }, + meta: { duration: 0.1 }, + nodes: { + lfo: { kind: "lfo", params: { rate: 5 } }, + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [ + { from: "ns/mod/lfo", to: "ns/sfx/osc.frequency" }, + { from: "ns/sfx/osc", to: "out" }, + ], + }; + + const samples = await renderGraph(graph, 1); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("resolves namespace references in bus routing", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "noise"] }, + }, + meta: { duration: 0.1 }, + buses: { mix: { gain: 0.5 } }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 220 } }, + noise: { kind: "noise", params: { color: "white", level: 0.3 } }, + out: { kind: "destination" }, + }, + routing: [ + { bus: "mix", from: ["ns/sfx/osc", "ns/sfx/noise"], to: "out" }, + ], + }; + + const samples = await renderGraph(graph, 1); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("mixes namespace-qualified and plain references in chain", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter"] }, + }, + meta: { duration: 0.15 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + filter: { kind: "biquadFilter", params: { type: "highpass", frequency: 800 } }, + amp: { kind: "gain", params: { gain: 0.3 } }, + out: { kind: "destination" }, + }, + routing: [ + { chain: ["ns/sfx/osc", "ns/sfx/filter", "amp", "out"] }, + ], + }; + + const samples = await renderGraph(graph, 1); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("preserves determinism with namespace references", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc", "filter", "env"] }, + }, + meta: { duration: 0.15 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 440 } }, + filter: { kind: "biquadFilter", params: { type: "lowpass", frequency: 2000 } }, + env: { kind: "envelope", params: { attack: 0.005, decay: 0.1, sustain: 0 } }, + out: { kind: "destination" }, + }, + routing: [ + { chain: ["ns/sfx/osc", "ns/sfx/filter", "ns/sfx/env", "out"] }, + ], + }; + + const samplesA = await renderGraph(graph, 42); + const samplesB = await renderGraph(graph, 42); + expect(samplesA).toEqual(samplesB); + }); + + it("throws for namespace reference to unknown namespace", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc"] }, + }, + meta: { duration: 0.1 }, + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "ns/ghost/osc", to: "out" }], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow('Unknown namespace "ghost"'); + }); + + it("throws for namespace reference to a node not declared in the namespace", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + namespaces: { + sfx: { nodes: ["osc"] }, + }, + meta: { duration: 0.1 }, + nodes: { + osc: { kind: "oscillator" }, + filter: { kind: "biquadFilter" }, + out: { kind: "destination" }, + }, + routing: [{ from: "ns/sfx/filter", to: "out" }], + }; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow( + 'Node "filter" is not declared in namespace "sfx"', + ); + }); }); diff --git a/src/core/tonegraph.ts b/src/core/tonegraph.ts index 93e7b921..8694512d 100644 --- a/src/core/tonegraph.ts +++ b/src/core/tonegraph.ts @@ -20,6 +20,12 @@ interface AutomationLinearRampEvent { value: number; } +interface AutomationExponentialRampEvent { + kind: "exponentialRamp"; + time: number; + value: number; +} + interface AutomationLfoEvent { kind: "lfo"; rate: number; @@ -34,6 +40,7 @@ interface AutomationLfoEvent { type AutomationEvent = | AutomationSetEvent | AutomationLinearRampEvent + | AutomationExponentialRampEvent | AutomationLfoEvent; interface RuntimeNode { @@ -56,6 +63,15 @@ export interface ToneGraphHandle { nodes: Record; duration: number; start: (time?: number) => void; + /** + * Schedule the end of playback at `time` (defaults to {@link duration}). + * + * Envelope nodes hold their sustain level from the end of the decay phase + * and ramp to silence over the final `release` seconds *inside* the render + * window — i.e. the ramp spans `[time - release, time]` (clamped at `0`). + * This matches the authored convention `duration = attack + decay + + * release`. Sources stop at `time`. + */ stop: (time?: number) => void; dispose: () => void; } @@ -177,6 +193,20 @@ function parseAutomationList(raw: unknown, nodeId: string, paramName: string): A }; } + if (kindRaw === "exponentialRamp") { + const value = ensureNumber(event.value, `Node "${nodeId}" automation[${index}].value`); + if (value <= 0) { + throw new Error( + `Node "${nodeId}" automation[${index}] exponentialRamp value must be greater than 0.`, + ); + } + return { + kind: "exponentialRamp", + time: ensureNumber(event.time, `Node "${nodeId}" automation[${index}].time`), + value, + }; + } + if (kindRaw === "lfo") { const waveRaw = event.wave; const wave: ModulationWave = @@ -261,6 +291,14 @@ function applyAutomationEvents( continue; } + if (event.kind === "exponentialRamp") { + if (event.value <= 0) { + throw new Error("exponentialRamp value must be greater than 0."); + } + param.exponentialRampToValueAtTime(event.value, baseTime + event.time); + continue; + } + const start = baseTime + (event.start ?? 0); const end = baseTime + (event.end ?? durationHint); const step = event.step ?? (1 / 128); @@ -275,10 +313,65 @@ function applyAutomationEvents( } } +/** + * Parse a routing endpoint reference that may be namespace-qualified. + * Formats: "nodeId", "nodeId.param", "ns//", "ns//.param" + */ +function parseEndpointReference(ref: string): { nodeId: string; param?: string; ns?: string } { + const dotIndex = ref.indexOf("."); + let rawNodeId: string; + let param: string | undefined; + + if (dotIndex < 0) { + rawNodeId = ref; + } else { + rawNodeId = ref.slice(0, dotIndex); + param = ref.slice(dotIndex + 1); + } + + // Check for namespace prefix: ns// + if (rawNodeId.startsWith("ns/")) { + const nsMatch = rawNodeId.match(/^ns\/([^/]+)\/([^/]+)$/); + if (!nsMatch) { + throw new Error(`Invalid namespace reference "${ref}".`); + } + return { nodeId: nsMatch[2], param, ns: nsMatch[1] }; + } + + return { nodeId: rawNodeId, param }; +} + function resolveEndpoint( ref: string, nodes: Map, + namespaces?: Record, ): { output?: AudioNode; param?: AudioParam } { + const parsed = parseEndpointReference(ref); + + // Namespace-qualified reference: validate and resolve the actual node id. + if (parsed.ns !== undefined) { + const nsDef = namespaces?.[parsed.ns]; + if (!nsDef) { + throw new Error(`Unknown namespace "${parsed.ns}" in endpoint reference "${ref}".`); + } + if (!nsDef.nodes.includes(parsed.nodeId)) { + throw new Error( + `Node "${parsed.nodeId}" is not declared in namespace "${parsed.ns}" (reference "${ref}").`, + ); + } + + const node = nodes.get(parsed.nodeId); + if (!node) { + throw new Error( + `Namespace "${parsed.ns}" references missing node "${parsed.nodeId}" (reference "${ref}").`, + ); + } + if (parsed.param) { + return { param: node.params[parsed.param] }; + } + return { output: node.output }; + } + const dotIndex = ref.indexOf("."); if (dotIndex < 0) { const node = nodes.get(ref); @@ -501,6 +594,10 @@ async function createRuntimeNode( function expandRouting(entries: ToneGraphRoutingEntry[]): Array<{ from: string; to: string }> { const links: Array<{ from: string; to: string }> = []; + + const toEndpointList = (value: string | string[] | undefined): string[] => + value === undefined ? [] : Array.isArray(value) ? value : [value]; + for (const entry of entries) { if ("chain" in entry) { for (let i = 0; i < entry.chain.length - 1; i += 1) { @@ -508,6 +605,18 @@ function expandRouting(entries: ToneGraphRoutingEntry[]): Array<{ from: string; } continue; } + + if ("bus" in entry) { + const busRef = `bus:${entry.bus}`; + for (const input of toEndpointList(entry.from)) { + links.push({ from: input, to: busRef }); + } + for (const output of toEndpointList(entry.to)) { + links.push({ from: busRef, to: output }); + } + continue; + } + links.push(entry); } return links; @@ -526,9 +635,24 @@ export async function loadToneGraph( runtimeNodes.set(id, await createRuntimeNode(ctx, id, def, activeRng, duration)); } + // Materialise each declared bus as a GainNode addressed by the reserved + // reference `bus:`. Validation guarantees bus ids do not collide with + // node ids or generated `bus:` references. + for (const [busId, busDef] of Object.entries(graph.buses ?? {})) { + const gainNode = ctx.createGain(); + gainNode.gain.value = busDef.gain ?? 1; + runtimeNodes.set(`bus:${busId}`, { + output: gainNode, + params: { gain: gainNode.gain }, + startables: [], + stoppables: [], + internals: [gainNode], + }); + } + for (const route of expandRouting(graph.routing)) { - const from = resolveEndpoint(route.from, runtimeNodes); - const to = resolveEndpoint(route.to, runtimeNodes); + const from = resolveEndpoint(route.from, runtimeNodes, graph.namespaces); + const to = resolveEndpoint(route.to, runtimeNodes, graph.namespaces); if (from.param) { throw new Error(`Invalid route ${route.from} -> ${route.to}: routing from AudioParam is not supported.`); @@ -563,6 +687,29 @@ export async function loadToneGraph( } } + // Sequences schedule timed events on an existing node's AudioParam. They are + // the document-level counterpart to inline node automation and are applied + // deterministically at load time (identical graphs produce identical + // schedules). `graph.random.seed` is resolved above so sequences that depend + // on noise buffers remain deterministic. + for (const [sequenceIndex, sequence] of (graph.sequences ?? []).entries()) { + const runtime = runtimeNodes.get(sequence.node); + if (!runtime) { + throw new Error( + `Sequence ${sequenceIndex} references unknown node "${sequence.node}".`, + ); + } + + const param = runtime.params[sequence.param]; + if (!param) { + throw new Error( + `Sequence ${sequenceIndex} targets unknown AudioParam "${sequence.param}" on node "${sequence.node}".`, + ); + } + + applyAutomationEvents(param, sequence.events, duration); + } + const started = new Set(); const nodes: Record = {}; for (const [id, runtime] of runtimeNodes) { @@ -593,10 +740,16 @@ export async function loadToneGraph( const stop = (time = duration): void => { for (const runtime of runtimeNodes.values()) { if (runtime.envelope) { - const releaseEnd = time + runtime.envelope.release; - runtime.envelope.gain.cancelScheduledValues(time); - runtime.envelope.gain.setValueAtTime(runtime.envelope.sustain, time); - runtime.envelope.gain.linearRampToValueAtTime(0, releaseEnd); + // The authored duration is `attack + decay + release`, so the release + // ramp is intended to occupy the final `release` seconds of the render + // window: [time - release, time]. Clamp at 0 so short buffers never + // schedule events before the render window. `cancelScheduledValues` + // starts at the release so the attack/decay schedule is preserved and + // the sustain plateau is re-asserted up to the release. + const releaseStart = Math.max(0, time - runtime.envelope.release); + runtime.envelope.gain.cancelScheduledValues(releaseStart); + runtime.envelope.gain.setValueAtTime(runtime.envelope.sustain, releaseStart); + runtime.envelope.gain.linearRampToValueAtTime(0, time); } for (const source of runtime.stoppables) { diff --git a/src/mixer/__tests__/rules-fixtures.ts b/src/mixer/__tests__/rules-fixtures.ts new file mode 100644 index 00000000..3abe9921 --- /dev/null +++ b/src/mixer/__tests__/rules-fixtures.ts @@ -0,0 +1,119 @@ +/** + * Mixer Voice-Limiting Test Fixtures + * + * Deterministic, reusable fixtures for the Mixer voice-limiting and rule + * outcome tests. Everything here is seeded/fixed — no wall-clock or + * load-order dependence — so repeated runs produce byte-stable results. + * + * Work item: TF-0MMLC8SQ302DVYOZ + * Reference: docs/prd/MIXER_PRD.md §4.1, §6; docs/mixer-rules.md + */ + +import { + MIX_GROUPS, + MIXER_DEFAULTS, + parseMixRuleSet, + type MixGroupName, + type MixRuleSet, +} from "../schema.js"; + +/** The six confirmed mix groups as a mutable array for iteration. */ +export const GROUPS: MixGroupName[] = [...MIX_GROUPS]; + +/** + * A fixture rule set exercising duck, boost and limit across every one of + * the six groups. Rules are keyed off a fixture-only context dimension so a + * test can activate exactly one capability at a time without cross-talk. + */ +export const ALL_GROUPS_RULE_SET: MixRuleSet = parseMixRuleSet( + { + version: "1.0", + defaults: { ...MIXER_DEFAULTS }, + rules: [ + { + id: "fixture-duck-all", + when: { context: { fixture: "duck-all" } }, + then: { duck: [...GROUPS] }, + }, + { + id: "fixture-boost-all", + when: { context: { fixture: "boost-all" } }, + then: { boost: GROUPS.map((group) => ({ group, gainDb: 3 })) }, + }, + { + id: "fixture-limit-all", + when: { context: { fixture: "limit-all" } }, + then: { limit: GROUPS.map((group) => ({ group, maxVoices: 2 })) }, + }, + { + id: "combat-focus", + when: { state: "combat" }, + then: { duck: ["ambience"], boost: ["combat"], limit: ["ui"] }, + }, + { + id: "ui-clarity", + when: { state: "ui" }, + then: { duck: ["ambience", "footsteps"] }, + }, + ], + }, + "", +); + +/** Expected linear gain for a duck depth of `db` decibels. */ +export function duckedGain(db: number): number { + return Math.pow(10, -db / 20); +} + +/** Expected linear gain for a boost of `db` decibels. */ +export function boostedGain(db: number): number { + return Math.pow(10, db / 20); +} + +/** The expected default duck gain (6 dB attenuation). */ +export const DEFAULT_DUCK_GAIN = duckedGain(MIXER_DEFAULTS.duckDepthDb); + +// ── Deterministic voice-load simulation ──────────────────────────── + +/** A single simulated voice-lifecycle event. */ +export interface VoiceEvent { + group: MixGroupName; + action: "start" | "stop"; +} + +/** + * A small, fast, deterministic PRNG (mulberry32). Used only to generate + * reproducible test *input* sequences — it is not production logic. + * + * @param seed - 32-bit unsigned seed. + * @returns A function yielding floats in `[0, 1)`. + */ +export function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Generate a deterministic sequence of start/stop voice events across all + * six groups for a given seed. The same seed always yields the same plan. + * + * @param seed - Deterministic seed. + * @param count - Number of events to generate. + * @returns The event plan. + */ +export function generateVoiceEvents(seed: number, count: number): VoiceEvent[] { + const rand = mulberry32(seed); + const events: VoiceEvent[] = []; + for (let i = 0; i < count; i++) { + const group = GROUPS[Math.floor(rand() * GROUPS.length)]!; + const action = rand() < 0.6 ? "start" : "stop"; + events.push({ group, action }); + } + return events; +} diff --git a/src/mixer/__tests__/rules-loader.test.ts b/src/mixer/__tests__/rules-loader.test.ts new file mode 100644 index 00000000..6f73b738 --- /dev/null +++ b/src/mixer/__tests__/rules-loader.test.ts @@ -0,0 +1,174 @@ +/** + * Mixer Rule Config Loader Tests + * + * Tests for loadMixRules(): directory loading, built-in-default fallback when + * absent, merging of multiple rule files, and actionable validation failures. + * + * Work item: TF-0MMLC8PXU0D3O594 + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadMixRules, resolveMixRulesDir } from "../rules-loader.js"; +import { BUILT_IN_MIX_RULES, parseMixRuleSet } from "../schema.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, "..", "..", ".."); + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "toneforge-mixer-")); + tempDirs.push(dir); + return dir; +} + +function writeRuleFile(dir: string, name: string, data: unknown): string { + const file = path.join(dir, name); + fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf8"); + return file; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop()!; + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe("loadMixRules — fallback to built-in defaults", () => { + it("warns and returns built-in defaults when the directory is absent", () => { + const warn = vi.fn(); + const missing = path.join(makeTempDir(), "does-not-exist"); + const set = loadMixRules({ dir: missing, warn }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toContain("built-in defaults"); + expect(set.rules).toEqual(BUILT_IN_MIX_RULES.rules); + expect(set.groups).toEqual(BUILT_IN_MIX_RULES.groups); + }); + + it("warns and returns built-in defaults when the directory is empty", () => { + const warn = vi.fn(); + const set = loadMixRules({ dir: makeTempDir(), warn }); + expect(warn).toHaveBeenCalledTimes(1); + expect(set.rules).toEqual(BUILT_IN_MIX_RULES.rules); + }); + + it("returns a fresh clone that cannot mutate the shared fallback", () => { + const set = loadMixRules({ dir: makeTempDir(), warn: () => {} }); + set.groups.combat.maxVoices = 999; + set.rules.length = 0; + expect(BUILT_IN_MIX_RULES.groups.combat.maxVoices).toBe(4); + expect(BUILT_IN_MIX_RULES.rules.length).toBeGreaterThan(0); + }); + + it("ignores non-JSON files when deciding whether rules are present", () => { + const warn = vi.fn(); + const dir = makeTempDir(); + fs.writeFileSync(path.join(dir, "README.md"), "not rules", "utf8"); + const set = loadMixRules({ dir, warn }); + expect(warn).toHaveBeenCalledTimes(1); + expect(set.rules).toEqual(BUILT_IN_MIX_RULES.rules); + }); +}); + +describe("loadMixRules — loading valid rules", () => { + it("loads and validates a rules file from the directory", () => { + const dir = makeTempDir(); + writeRuleFile(dir, "rules.json", { + version: "1.0", + groups: { combat: { priority: 55 } }, + rules: [ + { + id: "combat-focus", + when: { state: "combat" }, + then: { duck: ["ambience"], boost: ["combat"], limit: ["ui"] }, + }, + ], + }); + + const set = loadMixRules({ dir, warn: () => {} }); + expect(set.rules).toHaveLength(1); + expect(set.groups.combat.priority).toBe(55); + expect(set.rules[0]!.then.duck[0]).toEqual({ group: "ambience", depthDb: 6 }); + }); + + it("merges multiple rule files deterministically in filename order", () => { + const dir = makeTempDir(); + writeRuleFile(dir, "b-second.json", { + version: "1.0", + defaults: { maxVoices: 9 }, + rules: [{ when: { state: "combat" }, then: { duck: [{ group: "ambience", depthDb: 9 }] } }], + }); + writeRuleFile(dir, "a-first.json", { + version: "1.0", + rules: [{ when: { state: "ui" }, then: { duck: ["footsteps"] } }], + }); + + const set = loadMixRules({ dir, warn: () => {} }); + // a-first contributes rule 0, b-second rule 1. + expect(set.rules).toHaveLength(2); + expect(set.rules[0]!.when.state).toBe("ui"); + expect(set.rules[1]!.when.state).toBe("combat"); + // Later defaults merge over earlier ones and propagate to groups. + expect(set.defaults.maxVoices).toBe(9); + expect(set.groups.ambience.maxVoices).toBe(9); + }); +}); + +describe("loadMixRules — validation failures", () => { + it("throws when a rule file contains invalid JSON", () => { + const dir = makeTempDir(); + fs.writeFileSync(path.join(dir, "broken.json"), "{ not valid json", "utf8"); + expect(() => loadMixRules({ dir, warn: () => {} })).toThrow(/Failed to parse mixer rules/); + }); + + it("throws an actionable error for an unknown mix group", () => { + const dir = makeTempDir(); + writeRuleFile(dir, "rules.json", { + version: "1.0", + rules: [{ when: { state: "combat" }, then: { duck: ["music"] } }], + }); + expect(() => loadMixRules({ dir, warn: () => {} })).toThrow(/Unknown mix group 'music'/); + }); + + it("throws rather than silently falling back when a present file is malformed", () => { + const dir = makeTempDir(); + writeRuleFile(dir, "rules.json", { rules: [] }); // missing version + expect(() => loadMixRules({ dir, warn: () => {} })).toThrow(/missing.*version|version/i); + }); + + it("reports the offending file path in validation errors", () => { + const dir = makeTempDir(); + const file = writeRuleFile(dir, "rules.json", { rules: [] }); + expect(() => loadMixRules({ dir, warn: () => {} })).toThrow(new RegExp(file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + }); +}); + +describe("resolveMixRulesDir", () => { + it("resolves to .toneforge/mixer under the given cwd", () => { + expect(resolveMixRulesDir("/repo")).toBe(path.join("/repo", ".toneforge", "mixer")); + }); +}); + +describe("committed seed rules file", () => { + it("exists, validates, and matches the built-in defaults", () => { + const seedPath = path.join(REPO_ROOT, ".toneforge", "mixer", "rules.json"); + expect(fs.existsSync(seedPath)).toBe(true); + const parsed = parseMixRuleSet(JSON.parse(fs.readFileSync(seedPath, "utf8")), seedPath); + expect(parsed).toEqual(BUILT_IN_MIX_RULES); + }); + + it("loads through the public loader from the repository directory", () => { + const set = loadMixRules({ + dir: path.join(REPO_ROOT, ".toneforge", "mixer"), + warn: () => {}, + }); + expect(set.rules.length).toBeGreaterThan(0); + }); +}); diff --git a/src/mixer/__tests__/runtime.test.ts b/src/mixer/__tests__/runtime.test.ts new file mode 100644 index 00000000..796713ce --- /dev/null +++ b/src/mixer/__tests__/runtime.test.ts @@ -0,0 +1,338 @@ +/** + * Mixer Runtime Tests + * + * Tests for the Mixer runtime: group registry, active-voice tracking, + * rule evaluation (determinism and correctness), and latency benchmarks. + * + * Work item: TF-0MMLC8B3T0Z1F7ZM + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { Mixer } from "../runtime.js"; +import { + BUILT_IN_MIX_RULES, + MIX_GROUPS, + MIXER_DEFAULTS, + parseMixRuleSet, + type MixRuleSet, +} from "../schema.js"; + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Minimal rule set for testing without side-effects from the seed file. */ +function makeRuleSet(overrides: Partial = {}): MixRuleSet { + return { ...BUILT_IN_MIX_RULES, ...overrides }; +} + +/** Build a normalised rule set from raw rule declarations. */ +function ruleSetWith(rawRules: unknown[]): MixRuleSet { + return parseMixRuleSet({ version: "1.0", rules: rawRules }, ""); +} + +// ── 1. MixGroup metadata (AC 1) ──────────────────────────────────── + +describe("MixGroup metadata", () => { + let mixer: Mixer; + + beforeEach(() => { + mixer = new Mixer({ ruleSet: makeRuleSet() }); + }); + + it("registers all six mix groups", () => { + const inspection = mixer.inspect(); + expect(inspection.groups).toHaveLength(MIX_GROUPS.length); + for (const group of MIX_GROUPS) { + expect(inspection.groups.map((g) => g.name)).toContain(group); + } + }); + + it("assigns default priorities matching the schema", () => { + const inspection = mixer.inspect(); + const priorities = new Map(inspection.groups.map((g) => [g.name, g.priority])); + expect(priorities.get("dialogue")).toBe(100); + expect(priorities.get("alerts")).toBe(90); + expect(priorities.get("ui")).toBe(80); + expect(priorities.get("combat")).toBe(60); + expect(priorities.get("footsteps")).toBe(40); + expect(priorities.get("ambience")).toBe(20); + }); + + it("sets default maxVoices to 4 for every group", () => { + const inspection = mixer.inspect(); + for (const group of inspection.groups) { + expect(group.maxVoices).toBe(4); + } + }); + + it("starts all groups with gain 1 (0 dB) and zero active voices", () => { + const inspection = mixer.inspect(); + for (const group of inspection.groups) { + expect(group.currentGain).toBe(1); + expect(group.activeVoices).toBe(0); + } + }); +}); + +// ── 2. Active-voice tracking (AC 2) ──────────────────────────────── + +describe("Active-voice tracking", () => { + let mixer: Mixer; + + beforeEach(() => { + mixer = new Mixer({ ruleSet: makeRuleSet() }); + }); + + it("admits voices up to maxVoices", () => { + const admitted: string[] = []; + for (let i = 0; i < 4; i++) { + admitted.push(mixer.startVoice("combat")); + } + expect(admitted).toEqual(["combat-voice-0", "combat-voice-1", "combat-voice-2", "combat-voice-3"]); + + // 5th voice should be rejected + const rejected = mixer.startVoice("combat"); + expect(rejected).toBeNull(); + }); + + it("rejects voices when group is at maxVoices", () => { + for (let i = 0; i < 4; i++) { + mixer.startVoice("footsteps"); + } + expect(mixer.startVoice("footsteps")).toBeNull(); + }); + + it("releases a voice slot when stopVoice is called", () => { + mixer.startVoice("combat"); + mixer.startVoice("combat"); + mixer.startVoice("combat"); + mixer.stopVoice("combat", "combat-voice-0"); + expect(mixer.startVoice("combat")).toBe("combat-voice-3"); + }); + + it("throws for an unknown group when starting a voice", () => { + expect(() => mixer.startVoice("music" as never)).toThrow(/Unknown mix group 'music'/); + }); + + it("returns false when stopping a voice that is not active", () => { + expect(mixer.stopVoice("combat", "combat-voice-99")).toBe(false); + }); + + it("tracks active voices per group independently", () => { + for (let i = 0; i < 4; i++) { + mixer.startVoice("combat"); + } + for (let i = 0; i < 3; i++) { + mixer.startVoice("dialogue"); + } + + const inspection = mixer.inspect(); + const combat = inspection.groups.find((g) => g.name === "combat")!; + const dialogue = inspection.groups.find((g) => g.name === "dialogue")!; + expect(combat.activeVoices).toBe(4); + expect(dialogue.activeVoices).toBe(3); + }); +}); + +// ── 3. Declarative rule evaluation (AC 3) ────────────────────────── + +describe("Declarative rule evaluation", () => { + let mixer: Mixer; + + beforeEach(() => { + mixer = new Mixer({ ruleSet: makeRuleSet() }); + }); + + it("evaluates rules when state changes to a triggering state", () => { + // combat-focus: when state=combat → duck ambience, boost combat, limit ui + mixer.updateState("combat"); + const inspection = mixer.inspect(); + + const ambience = inspection.groups.find((g) => g.name === "ambience")!; + expect(ambience.currentGain).toBeLessThan(1); // ducked + + const combat = inspection.groups.find((g) => g.name === "combat")!; + expect(combat.currentGain).toBeGreaterThanOrEqual(1); // boosted + + const ui = inspection.groups.find((g) => g.name === "ui")!; + // limit does not change gain directly; maxVoices should be capped + expect(ui.maxVoices).toBeLessThanOrEqual(MIXER_DEFAULTS.maxVoices); + }); + + it("applies default duck depth of 6 dB", () => { + mixer.updateState("combat"); + const inspection = mixer.inspect(); + const ambience = inspection.groups.find((g) => g.name === "ambience")!; + // 6 dB duck: gain = 10^(-6/20) ≈ 0.5 + const expected = Math.pow(10, -6 / 20); + expect(ambience.currentGain).toBeCloseTo(expected, 2); + }); + + it("applies UI duck with default duration of 200 ms", () => { + mixer.updateState("ui"); + const inspection = mixer.inspect(); + const ambience = inspection.groups.find((g) => g.name === "ambience")!; + // Check that a duration was set for the duck + expect(ambience.duckDurationMs).toBe(200); + }); + + it("does not modify groups not mentioned in rules", () => { + mixer.updateState("combat"); + const inspection = mixer.inspect(); + const dialogue = inspection.groups.find((g) => g.name === "dialogue")!; + expect(dialogue.currentGain).toBe(1); + }); + + it("emits a decision for every duck/boost/limit action", () => { + const decisions = mixer.updateState("combat"); + expect(decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ action: "duck", group: "ambience", value: 6 }), + expect.objectContaining({ action: "boost", group: "combat" }), + expect.objectContaining({ action: "limit", group: "ui", value: 4 }), + ]), + ); + }); + + it("matches context-only rules regardless of state", () => { + const ruleSet = ruleSetWith([ + { + id: "metal-attenuation", + when: { context: { surface: "metal" } }, + then: { duck: [{ group: "footsteps", depthDb: 3 }] }, + }, + ]); + const ctxMixer = new Mixer({ ruleSet }); + ctxMixer.setContext({ surface: "metal" }); + const footsteps = ctxMixer + .inspect() + .groups.find((g) => g.name === "footsteps")!; + expect(footsteps.currentGain).toBeCloseTo(Math.pow(10, -3 / 20), 3); + }); + + it("requires every declared context dimension to match", () => { + const ruleSet = ruleSetWith([ + { + id: "indoor-metal", + when: { context: { surface: "metal", space: "indoor" } }, + then: { duck: ["ambience"] }, + }, + ]); + const ctxMixer = new Mixer({ ruleSet }); + ctxMixer.setContext({ surface: "metal" }); + expect( + ctxMixer.inspect().groups.find((g) => g.name === "ambience")!.currentGain, + ).toBe(1); + ctxMixer.setContext({ space: "indoor" }); + expect( + ctxMixer.inspect().groups.find((g) => g.name === "ambience")!.currentGain, + ).toBeLessThan(1); + }); + + it("does not mutate the supplied rule set", () => { + const ruleSet = makeRuleSet(); + const before = JSON.stringify(ruleSet); + const local = new Mixer({ ruleSet }); + local.updateState("combat"); + local.startVoice("combat"); + expect(JSON.stringify(ruleSet)).toBe(before); + }); +}); + +// ── 4. Determinism (AC 4) ────────────────────────────────────────── + +describe("Rule evaluation is deterministic", () => { + it("produces identical outcomes for identical inputs", () => { + const ruleSet = makeRuleSet(); + const mixer1 = new Mixer({ ruleSet }); + const mixer2 = new Mixer({ ruleSet }); + + for (const mixer of [mixer1, mixer2]) { + mixer.updateState("combat"); + mixer.updateState("ui"); + } + + const insp1 = mixer1.inspect(); + const insp2 = mixer2.inspect(); + + for (let i = 0; i < insp1.groups.length; i++) { + const g1 = insp1.groups[i]; + const g2 = insp2.groups[i]; + expect(g1.name).toBe(g2.name); + expect(g1.currentGain).toBe(g2.currentGain); + expect(g1.activeVoices).toBe(g2.activeVoices); + } + }); + + it("produces deterministic results after voice lifecycle", () => { + const ruleSet = makeRuleSet(); + const mixer1 = new Mixer({ ruleSet }); + const mixer2 = new Mixer({ ruleSet }); + + for (const mixer of [mixer1, mixer2]) { + mixer.updateState("combat"); + for (let i = 0; i < 2; i++) { + mixer.startVoice("combat"); + mixer.startVoice("ambience"); + } + mixer.updateState("ui"); + } + + const insp1 = mixer1.inspect(); + const insp2 = mixer2.inspect(); + + for (let i = 0; i < insp1.groups.length; i++) { + expect(insp1.groups[i].currentGain).toBe(insp2.groups[i].currentGain); + expect(insp1.groups[i].activeVoices).toBe(insp2.groups[i].activeVoices); + } + }); +}); + +// ── 5. Latency benchmark (AC 5) ──────────────────────────────────── + +describe("Rule evaluation latency", () => { + it("evaluates rule set in constant time (≤ 5 ms)", () => { + const ruleSet = makeRuleSet(); + const mixer = new Mixer({ ruleSet }); + + const iterations = 1000; + const states = ["combat", "ui", "idle", "explore"] as const; + const start = performance.now(); + + for (let i = 0; i < iterations; i++) { + const state = states[i % states.length]; + mixer.updateState(state); + // applyRules is called implicitly by updateState + } + + const elapsed = performance.now() - start; + const avgMs = elapsed / iterations; + + // 1000 iterations must complete well under 5 seconds; average < 5 ms + expect(elapsed).toBeLessThan(5000); + expect(avgMs).toBeLessThan(5); + }); +}); + +// ── 6. Integration with existing rule files ───────────────────────── + +describe("Integration", () => { + it("loads the committed seed rules file successfully", () => { + const mixer = new Mixer({ + ruleSet: BUILT_IN_MIX_RULES, + }); + expect(mixer.inspect().groups).toHaveLength(6); + }); + + it("handles multiple state transitions in sequence", () => { + const mixer = new Mixer({ ruleSet: makeRuleSet() }); + mixer.updateState("combat"); + mixer.updateState("ui"); + mixer.updateState("idle"); + + const inspection = mixer.inspect(); + const ambience = inspection.groups.find((g) => g.name === "ambience")!; + // After combat→ui→idle, ambience should reflect the latest rule (idle has no rules → back to 1) + // idle state has no matching rule, so gain should be back to 1 + expect(ambience.currentGain).toBe(1); + }); +}); diff --git a/src/mixer/__tests__/schema.test.ts b/src/mixer/__tests__/schema.test.ts new file mode 100644 index 00000000..d0252b12 --- /dev/null +++ b/src/mixer/__tests__/schema.test.ts @@ -0,0 +1,248 @@ +/** + * Mixer Rule Schema Tests + * + * Tests for validateMixRuleSet() / parseMixRuleSet() covering the documented + * declarative rule schema: `when {state|context}` → `then {duck|boost|limit}`, + * the six confirmed mix groups, and the built-in defaults (maxVoices 4, + * duck depth 6 dB, UI duck duration 200 ms). + * + * Work item: TF-0MMLC8PXU0D3O594 + */ + +import { describe, it, expect } from "vitest"; +import { + MIX_GROUPS, + MIXER_DEFAULTS, + validateMixRuleSet, + parseMixRuleSet, + type RawMixRuleFile, +} from "../schema.js"; + +/** A minimal valid rule-set file for use as a test baseline. */ +function validRuleSet(): RawMixRuleFile { + return { + version: "1.0", + rules: [ + { + id: "combat-focus", + when: { state: "combat" }, + then: { duck: ["ambience"], boost: ["combat"], limit: ["ui"] }, + }, + ], + }; +} + +describe("validateMixRuleSet — valid inputs", () => { + it("accepts a minimal valid rule set", () => { + expect(validateMixRuleSet(validRuleSet(), "rules.json")).toHaveLength(0); + }); + + it("accepts an empty rule set with only a version", () => { + expect(validateMixRuleSet({ version: "1.0" }, "rules.json")).toHaveLength(0); + }); + + it("accepts object-form duck/boost/limit targets", () => { + const data: RawMixRuleFile = { + version: "1.0", + rules: [ + { + when: { state: "combat", context: { surface: "metal" } }, + then: { + duck: [{ group: "ambience", depthDb: 9, durationMs: 500 }], + boost: [{ group: "combat", gainDb: 3 }], + limit: [{ group: "ui", maxVoices: 2 }], + }, + }, + ], + }; + expect(validateMixRuleSet(data, "rules.json")).toHaveLength(0); + }); + + it("accepts group names case-insensitively (e.g. UI)", () => { + const data: RawMixRuleFile = { + version: "1.0", + rules: [{ when: { state: "combat" }, then: { duck: ["Ambience"], boost: ["UI"] } }], + }; + expect(validateMixRuleSet(data, "rules.json")).toHaveLength(0); + }); +}); + +describe("validateMixRuleSet — rejected inputs", () => { + it("rejects a non-object root", () => { + const errors = validateMixRuleSet([], "rules.json"); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.field).toBe("(root)"); + }); + + it("rejects a missing/empty version", () => { + const errors = validateMixRuleSet({ rules: [] }, "rules.json"); + expect(errors.some((e) => e.field === "version")).toBe(true); + }); + + it("rejects an unknown mix group in `groups`", () => { + const data = { version: "1.0", groups: { music: { priority: 1 } } }; + const errors = validateMixRuleSet(data, "rules.json"); + expect(errors.some((e) => e.field === "groups.music")).toBe(true); + }); + + it("rejects invalid default values", () => { + const data = { version: "1.0", defaults: { maxVoices: 0, duckDepthDb: -1 } }; + const errors = validateMixRuleSet(data, "rules.json"); + expect(errors.some((e) => e.field === "defaults.maxVoices")).toBe(true); + expect(errors.some((e) => e.field === "defaults.duckDepthDb")).toBe(true); + }); + + it("rejects a rule that is missing `when`", () => { + const errors = validateMixRuleSet( + { version: "1.0", rules: [{ then: { duck: ["ambience"] } }] }, + "rules.json", + ); + expect(errors.some((e) => e.field === "rules[0].when")).toBe(true); + }); + + it("rejects a `when` without state or context", () => { + const errors = validateMixRuleSet( + { version: "1.0", rules: [{ when: {}, then: { duck: ["ambience"] } }] }, + "rules.json", + ); + expect(errors.some((e) => e.field === "rules[0].when")).toBe(true); + }); + + it("rejects an unknown group in a duck target", () => { + const errors = validateMixRuleSet( + { version: "1.0", rules: [{ when: { state: "combat" }, then: { duck: ["music"] } }] }, + "rules.json", + ); + expect(errors.some((e) => e.field.includes("then.duck"))).toBe(true); + }); + + it("rejects a non-positive duck depth", () => { + const errors = validateMixRuleSet( + { + version: "1.0", + rules: [ + { when: { state: "combat" }, then: { duck: [{ group: "ambience", depthDb: 0 }] } }, + ], + }, + "rules.json", + ); + expect(errors.some((e) => e.field.includes("depthDb"))).toBe(true); + }); + + it("rejects a `then` without any action", () => { + const errors = validateMixRuleSet( + { version: "1.0", rules: [{ when: { state: "combat" }, then: {} }] }, + "rules.json", + ); + expect(errors.some((e) => e.field === "rules[0].then")).toBe(true); + }); + + it("rejects non-string context values", () => { + const errors = validateMixRuleSet( + { + version: "1.0", + rules: [{ when: { context: { surface: 3 } }, then: { duck: ["ambience"] } }], + }, + "rules.json", + ); + expect(errors.some((e) => e.field === "rules[0].when.context.surface")).toBe(true); + }); +}); + +describe("parseMixRuleSet — normalisation", () => { + it("fills in all six mix groups with defaults", () => { + const set = parseMixRuleSet({ version: "1.0" }, "rules.json"); + expect(Object.keys(set.groups).sort()).toEqual([...MIX_GROUPS].sort()); + for (const group of MIX_GROUPS) { + expect(set.groups[group].maxVoices).toBe(MIXER_DEFAULTS.maxVoices); + } + }); + + it("exposes the confirmed built-in defaults", () => { + const set = parseMixRuleSet({ version: "1.0" }, "rules.json"); + expect(set.defaults).toEqual({ + maxVoices: 4, + duckDepthDb: 6, + uiDuckDurationMs: 200, + }); + }); + + it("expands string duck targets using the default duck depth", () => { + const set = parseMixRuleSet(validRuleSet(), "rules.json"); + const duck = set.rules[0]!.then.duck; + expect(duck).toEqual([{ group: "ambience", depthDb: 6 }]); + }); + + it("expands string boost and limit targets", () => { + const set = parseMixRuleSet(validRuleSet(), "rules.json"); + expect(set.rules[0]!.then.boost).toEqual([{ group: "combat" }]); + expect(set.rules[0]!.then.limit).toEqual([{ group: "ui", maxVoices: 4 }]); + }); + + it("applies the UI duck duration default for UI-triggered rules", () => { + const set = parseMixRuleSet( + { + version: "1.0", + rules: [{ when: { state: "ui" }, then: { duck: ["ambience"] } }], + }, + "rules.json", + ); + expect(set.rules[0]!.then.duck[0]).toEqual({ + group: "ambience", + depthDb: 6, + durationMs: 200, + }); + }); + + it("does not apply the UI duck duration to non-UI rules", () => { + const set = parseMixRuleSet(validRuleSet(), "rules.json"); + expect(set.rules[0]!.then.duck[0]!.durationMs).toBeUndefined(); + }); + + it("honours explicit action overrides", () => { + const set = parseMixRuleSet( + { + version: "1.0", + defaults: { maxVoices: 8, duckDepthDb: 3 }, + groups: { combat: { priority: 99, maxVoices: 2 } }, + rules: [ + { + when: { state: "combat" }, + then: { + duck: [{ group: "ambience", depthDb: 12, durationMs: 50 }], + limit: ["dialogue"], + }, + }, + ], + }, + "rules.json", + ); + expect(set.groups.combat).toEqual({ priority: 99, maxVoices: 2 }); + // Unspecified groups inherit the global default maxVoices (8). + expect(set.groups.ambience.maxVoices).toBe(8); + expect(set.rules[0]!.then.duck[0]).toEqual({ + group: "ambience", + depthDb: 12, + durationMs: 50, + }); + expect(set.rules[0]!.then.limit[0]).toEqual({ group: "dialogue", maxVoices: 8 }); + }); + + it("normalises group names to lowercase", () => { + const set = parseMixRuleSet( + { + version: "1.0", + rules: [{ when: { state: "combat" }, then: { duck: ["Ambience"], boost: ["UI"] } }], + }, + "rules.json", + ); + expect(set.rules[0]!.then.duck[0]!.group).toBe("ambience"); + expect(set.rules[0]!.then.boost[0]!.group).toBe("ui"); + }); + + it("throws an actionable error when invalid", () => { + expect(() => parseMixRuleSet({ rules: [] }, "rules.json")).toThrow( + /Invalid mixer rules 'rules\.json'/, + ); + }); +}); diff --git a/src/mixer/__tests__/voice-limiting.test.ts b/src/mixer/__tests__/voice-limiting.test.ts new file mode 100644 index 00000000..78ea9797 --- /dev/null +++ b/src/mixer/__tests__/voice-limiting.test.ts @@ -0,0 +1,245 @@ +/** + * Mixer Voice-Limiting & Rule-Outcome Tests + * + * Dedicated regression suite for the safety-critical Mixer behaviour: + * per-group voice limits under seeded simulated load, fixture-verified + * duck/boost/limit outcomes for all six groups, the combat/UI default rules, + * and byte-stable determinism. + * + * The lower-level API shape is covered by `runtime.test.ts`; this suite owns + * the load/limit invariants and the six-group fixture contract. + * + * Work item: TF-0MMLC8SQ302DVYOZ + * Reference: docs/prd/MIXER_PRD.md §4.1, §6; docs/mixer-rules.md + */ + +import { describe, it, expect } from "vitest"; +import { Mixer } from "../runtime.js"; +import { MIX_GROUPS, MIXER_DEFAULTS, type MixGroupName } from "../schema.js"; +import { + ALL_GROUPS_RULE_SET, + DEFAULT_DUCK_GAIN, + GROUPS, + boostedGain, + duckedGain, + generateVoiceEvents, + type VoiceEvent, +} from "./rules-fixtures.js"; + +/** Find a group's state in an inspection by name. */ +function group(mixer: Mixer, name: MixGroupName) { + const found = mixer.inspect().groups.find((g) => g.name === name); + if (!found) throw new Error(`Group '${name}' missing from inspection`); + return found; +} + +/** + * Drive a deterministic event plan through a fresh mixer, maintaining the + * expected active-voice ledger, and assert the limit invariant after every + * single event. + */ +function runVoicePlan(seed: number, count: number): Mixer { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const events: VoiceEvent[] = generateVoiceEvents(seed, count); + const active = new Map( + GROUPS.map((g) => [g, [] as string[]]), + ); + + for (const event of events) { + if (event.action === "start") { + const id = mixer.startVoice(event.group); + if (id !== null) active.get(event.group)!.push(id); + } else { + const id = active.get(event.group)!.shift(); + if (id !== undefined) mixer.stopVoice(event.group, id); + } + + const inspection = mixer.inspect(); + for (const g of inspection.groups) { + // Safety invariant: a group never admits or reports more than its cap. + expect(g.activeVoices).toBeLessThanOrEqual(g.maxVoices); + // The mixer's ledger matches the test's independent ledger. + expect(g.activeVoices).toBe(active.get(g.name)!.length); + } + } + + return mixer; +} + +// ── 1. Default maxVoices and load invariant (AC 1) ───────────────── + +describe("Per-group voice limits", () => { + it("defaults maxVoices to 4 for every one of the six groups", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const inspection = mixer.inspect(); + expect(inspection.groups).toHaveLength(MIX_GROUPS.length); + for (const g of inspection.groups) { + expect(g.maxVoices).toBe(4); + } + }); + + it("rejects the (maxVoices + 1)-th voice for every group", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + for (const name of GROUPS) { + for (let i = 0; i < 4; i++) { + expect(mixer.startVoice(name)).not.toBeNull(); + } + expect(mixer.startVoice(name)).toBeNull(); + expect(group(mixer, name).activeVoices).toBe(4); + } + }); + + it("never exceeds the limit under a seeded simulated load", () => { + // 600 deterministic events across all six groups. + runVoicePlan(0xc0ffee, 600); + }); + + it("holds the invariant across several independent seeds", () => { + for (const seed of [1, 7, 42, 1337, 999999]) { + runVoicePlan(seed, 300); + } + }); + + it("frees capacity for a new voice after a released one", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + for (let i = 0; i < 4; i++) mixer.startVoice("combat"); + expect(mixer.startVoice("combat")).toBeNull(); + + const first = "combat-voice-0"; + expect(mixer.stopVoice("combat", first)).toBe(true); + expect(mixer.startVoice("combat")).toBe("combat-voice-4"); + expect(group(mixer, "combat").activeVoices).toBe(4); + }); +}); + +// ── 2. Six-group duck/boost/limit fixtures (AC 2) ────────────────── + +describe("Six-group rule fixtures", () => { + it("ducks all six groups by the default 6 dB", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const decisions = mixer.setContext({ fixture: "duck-all" }); + + for (const name of GROUPS) { + expect(group(mixer, name).currentGain).toBeCloseTo(DEFAULT_DUCK_GAIN, 6); + } + expect(decisions.filter((d) => d.action === "duck")).toHaveLength( + GROUPS.length, + ); + }); + + it("boosts all six groups by the fixture's explicit gainDb", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const decisions = mixer.setContext({ fixture: "boost-all" }); + + for (const name of GROUPS) { + expect(group(mixer, name).currentGain).toBeCloseTo(boostedGain(3), 6); + } + expect(decisions.filter((d) => d.action === "boost")).toHaveLength( + GROUPS.length, + ); + }); + + it("limits all six groups to the fixture's explicit cap", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const decisions = mixer.setContext({ fixture: "limit-all" }); + + for (const name of GROUPS) { + expect(group(mixer, name).maxVoices).toBe(2); + // The lowered cap is enforced at admission. + expect(mixer.startVoice(name)).not.toBeNull(); + expect(mixer.startVoice(name)).not.toBeNull(); + expect(mixer.startVoice(name)).toBeNull(); + expect(group(mixer, name).activeVoices).toBe(2); + } + expect(decisions.filter((d) => d.action === "limit")).toHaveLength( + GROUPS.length, + ); + }); + + it("resets gains and caps when the fixture context is cleared", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + mixer.setContext({ fixture: "duck-all" }); + expect(group(mixer, "combat").currentGain).toBeLessThan(1); + + // A context-only rule is dropped once its dimension no longer matches. + mixer.setContext({ fixture: "none" }); + for (const name of GROUPS) { + expect(group(mixer, name).currentGain).toBe(1); + expect(group(mixer, name).maxVoices).toBe(4); + } + }); +}); + +// ── 3. Combat and UI default rules (AC 3) ────────────────────────── + +describe("Combat and UI rules", () => { + it("ducks ambience by 6 dB and boosts combat on the combat state", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const decisions = mixer.updateState("combat"); + + expect(group(mixer, "ambience").currentGain).toBeCloseTo(duckedGain(6), 6); + expect(decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ruleId: "combat-focus", action: "duck", group: "ambience", value: 6 }), + expect.objectContaining({ ruleId: "combat-focus", action: "boost", group: "combat" }), + expect.objectContaining({ ruleId: "combat-focus", action: "limit", group: "ui" }), + ]), + ); + // The `limit: ["ui"]` default keeps the built-in cap. + expect(group(mixer, "ui").maxVoices).toBe(MIXER_DEFAULTS.maxVoices); + }); + + it("applies the UI duck at 6 dB for 200 ms", () => { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + mixer.updateState("ui"); + + for (const name of ["ambience", "footsteps"] as const) { + const g = group(mixer, name); + expect(g.currentGain).toBeCloseTo(duckedGain(MIXER_DEFAULTS.duckDepthDb), 6); + expect(g.duckDurationMs).toBe(MIXER_DEFAULTS.uiDuckDurationMs); + expect(MIXER_DEFAULTS.uiDuckDurationMs).toBe(200); + } + // Groups outside the UI rule are untouched. + expect(group(mixer, "combat").currentGain).toBe(1); + }); +}); + +// ── 4. Determinism: byte-stable outcomes (AC 4) ──────────────────── + +describe("Determinism", () => { + /** Run a fixed state/context/voice scenario and snapshot the inspection. */ + function scenario(): string { + const mixer = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const active = new Map( + GROUPS.map((g) => [g, [] as string[]]), + ); + + for (const event of generateVoiceEvents(2024, 200)) { + if (event.action === "start") { + const id = mixer.startVoice(event.group); + if (id !== null) active.get(event.group)!.push(id); + } else { + const id = active.get(event.group)!.shift(); + if (id !== undefined) mixer.stopVoice(event.group, id); + } + } + + mixer.updateState("combat"); + mixer.setContext({ fixture: "duck-all" }); + mixer.updateState("ui"); + + return JSON.stringify(mixer.inspect()); + } + + it("produces byte-identical snapshots across repeated runs", () => { + expect(scenario()).toBe(scenario()); + }); + + it("produces byte-identical decisions for identical inputs", () => { + const a = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + const b = new Mixer({ ruleSet: ALL_GROUPS_RULE_SET }); + expect(JSON.stringify(a.updateState("combat"))).toBe( + JSON.stringify(b.updateState("combat")), + ); + }); +}); diff --git a/src/mixer/index.ts b/src/mixer/index.ts new file mode 100644 index 00000000..9f30e11d --- /dev/null +++ b/src/mixer/index.ts @@ -0,0 +1,49 @@ +/** + * ToneForge Mixer Module + * + * Behaviour-aware mixing and routing layer. Provides the declarative rule + * schema, the rule config loader, and the deterministic runtime arbitration + * engine (group registry, voice ledger, rule evaluation). + * + * Reference: docs/prd/MIXER_PRD.md; docs/mixer-rules.md + */ + +export { + MIX_GROUPS, + MIXER_DEFAULTS, + DEFAULT_GROUP_PRIORITIES, + BUILT_IN_MIX_RULES, + validateMixRuleSet, + parseMixRuleSet, + formatValidationErrors, + type MixGroupName, + type MixerDefaults, + type MixGroupConfig, + type DuckAction, + type BoostAction, + type LimitAction, + type MixRuleWhen, + type MixRuleThen, + type MixRule, + type MixRuleSet, + type RawMixDefaults, + type RawMixGroupConfig, + type RawMixRule, + type RawMixRuleFile, + type ValidationError, +} from "./schema.js"; + +export { + MIX_RULES_DIR_PARTS, + loadMixRules, + resolveMixRulesDir, + type LoadMixRulesOptions, +} from "./rules-loader.js"; + +export { + Mixer, + type MixGroupState, + type MixDecision, + type MixerInspection, + type MixerOptions, +} from "./runtime.js"; diff --git a/src/mixer/rules-loader.ts b/src/mixer/rules-loader.ts new file mode 100644 index 00000000..470f2980 --- /dev/null +++ b/src/mixer/rules-loader.ts @@ -0,0 +1,152 @@ +/** + * Mixer Rule Config Loader + * + * Reads, validates and merges declarative mixer rule files from + * `.toneforge/mixer/*.json` and exposes a normalised `MixRuleSet` to the + * Mixer runtime. + * + * Behaviour mirrors the `.toneforge/config.yaml` fallback in + * `src/classify/dimensions/category.ts`: + * - when no rule files are present, the built-in defaults are used (a warning + * is emitted so the fallback is observable); + * - when a file is present but invalid, loading fails fast with actionable + * field-level errors rather than silently masking a broken rule file. + * + * Work item: TF-0MMLC8PXU0D3O594 + * Reference: docs/prd/MIXER_PRD.md §4; docs/mixer-rules.md + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + BUILT_IN_MIX_RULES, + formatValidationErrors, + parseMixRuleSet, + validateMixRuleSet, +} from "./schema.js"; +import type { MixRuleSet, RawMixRuleFile } from "./schema.js"; + +/** Repository-relative directory that holds mixer rule files. */ +export const MIX_RULES_DIR_PARTS = [".toneforge", "mixer"] as const; + +/** Options for {@link loadMixRules}. */ +export interface LoadMixRulesOptions { + /** + * Directory containing `*.json` rule files. Defaults to + * `/.toneforge/mixer`. + */ + dir?: string; + + /** + * Rule set returned when no rule files are present. Defaults to the + * built-in rule set. + */ + fallback?: MixRuleSet; + + /** + * Warning sink used when falling back to built-in defaults. Defaults to + * `console.warn`. + */ + warn?: (message: string) => void; +} + +/** + * Resolve the default mixer rules directory for a given working directory. + * + * @param cwd - Base directory (defaults to `process.cwd()`). + * @returns Absolute path to `/.toneforge/mixer`. + */ +export function resolveMixRulesDir(cwd: string = process.cwd()): string { + return path.join(cwd, ...MIX_RULES_DIR_PARTS); +} + +/** + * List the `*.json` rule files in a directory, sorted by filename for + * deterministic merge order. Missing/unreadable directories yield `[]`. + */ +function listRuleFiles(dir: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => path.join(dir, entry.name)) + .sort(); +} + +/** Read and JSON-parse a rule file, wrapping errors with context. */ +function readRuleFile(filePath: string): unknown { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read mixer rules '${filePath}': ${message}`); + } + try { + return JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse mixer rules '${filePath}': ${message}`); + } +} + +/** Merge one parsed rule file into the combined raw accumulator. */ +function mergeRuleFile(target: RawMixRuleFile, source: RawMixRuleFile): void { + if (source.version !== undefined) target.version = source.version; + + if (source.defaults !== undefined) { + target.defaults = { ...(target.defaults ?? {}), ...source.defaults }; + } + + if (source.groups !== undefined) { + const merged = { ...(target.groups ?? {}) }; + for (const [name, config] of Object.entries(source.groups)) { + merged[name] = { ...(merged[name] ?? {}), ...config }; + } + target.groups = merged; + } + + if (source.rules !== undefined) { + target.rules = [...(target.rules ?? []), ...source.rules]; + } +} + +/** + * Load and merge all mixer rule files from a directory. + * + * Files are read in filename order and merged (later files override earlier + * `defaults`/`groups`; rules concatenate). Every file is validated before + * merging; the first invalid file aborts loading with an actionable error. + * + * @param options - Loader options. + * @returns A validated, normalised `MixRuleSet`. + * @throws If the directory is present but a rule file is unreadable, invalid + * JSON, or fails schema validation. + */ +export function loadMixRules(options: LoadMixRulesOptions = {}): MixRuleSet { + const dir = options.dir ?? resolveMixRulesDir(); + const fallback = options.fallback ?? BUILT_IN_MIX_RULES; + const warn = options.warn ?? ((message: string) => console.warn(message)); + + const files = listRuleFiles(dir); + if (files.length === 0) { + warn(`[ToneForge] No mixer rules found in ${dir}; using built-in defaults.`); + return structuredClone(fallback); + } + + const combined: RawMixRuleFile = {}; + for (const file of files) { + const data = readRuleFile(file); + const errors = validateMixRuleSet(data, file); + if (errors.length > 0) { + throw new Error(formatValidationErrors(file, errors)); + } + mergeRuleFile(combined, data as RawMixRuleFile); + } + + return parseMixRuleSet(combined, files.join(", ")); +} diff --git a/src/mixer/runtime.ts b/src/mixer/runtime.ts new file mode 100644 index 00000000..ce36e5a9 --- /dev/null +++ b/src/mixer/runtime.ts @@ -0,0 +1,408 @@ +/** + * Mixer Runtime + * + * Deterministic arbitration core for the ToneForge Mixer. It maintains a + * registry of the six mix groups, tracks active voices per group, and + * evaluates the declarative rule set (`when {state|context}` → + * `then {duck|boost|limit}`) into a flat list of per-event decisions. + * + * Design goals (docs/prd/MIXER_PRD.md §3, §8, §10): + * - behaviour-aware, deterministic decisions — the same state/context and + * rule set always produce the same gains, caps and decisions; + * - low-latency, constant-time-per-rule evaluation (no allocation-heavy work + * in the hot path, no ML inference, no auto-applied suggestions); + * - bounded gains — gains only move in response to an explicit rule action. + * + * The runtime does **not** generate or route audio; it produces the + * decisions the Runtime/State layer applies. It is wired to the rule schema + * and loader delivered by TF-0MMLC8PXU0D3O594. + * + * Work item: TF-0MMLC8B3T0Z1F7ZM + * Reference: docs/prd/MIXER_PRD.md §4–7; docs/mixer-rules.md + */ + +import { MIX_GROUPS, MIXER_DEFAULTS } from "./schema.js"; +import type { + BoostAction, + DuckAction, + LimitAction, + MixGroupName, + MixRule, + MixRuleSet, +} from "./schema.js"; + +// ── Types ───────────────────────────────────────────────────────── + +/** Live state of a single mix group, as exposed by {@link Mixer.inspect}. */ +export interface MixGroupState { + /** Canonical group name. */ + name: MixGroupName; + + /** Priority level; higher dominates under contention. */ + priority: number; + + /** Effective maximum concurrent voices (a `limit` action may lower this). */ + maxVoices: number; + + /** Current linear gain (1 = unity / 0 dB). */ + currentGain: number; + + /** Number of voices currently active in this group. */ + activeVoices: number; + + /** Active duck duration in ms, if a matching rule ducked this group. */ + duckDurationMs?: number; +} + +/** A single mix decision produced by rule evaluation. */ +export interface MixDecision { + /** Stable rule identifier, when the rule declared one. */ + ruleId?: string; + + /** The action kind. */ + action: "duck" | "boost" | "limit"; + + /** The group the action targets. */ + group: MixGroupName; + + /** + * The action magnitude: duck depth in dB, boost gain in dB (0 when the rule + * omitted `gainDb`), or the voice cap for a limit action. + */ + value: number; + + /** Duck duration in ms, when the action declared one. */ + durationMs?: number; +} + +/** Immutable snapshot of the Mixer, suitable for logging/inspection. */ +export interface MixerInspection { + /** Current state name (empty string when unset). */ + state: string; + + /** Current context dimensions. */ + context: Record; + + /** Per-group state, in canonical group order. */ + groups: MixGroupState[]; + + /** Decisions produced by the most recent rule evaluation. */ + decisions: MixDecision[]; + + /** Total number of active voices across all groups. */ + voiceCount: number; +} + +/** Options for {@link Mixer}. */ +export interface MixerOptions { + /** Normalised rule set (from {@link import("./rules-loader.js").loadMixRules}). */ + ruleSet: MixRuleSet; + + /** Initial state name (default: ""). */ + initialState?: string; + + /** Initial context dimensions (default: {}). */ + initialContext?: Record; +} + +/** Internal mutable per-group record. */ +interface MutableGroupState extends MixGroupState { + /** Configured cap before any `limit` action is applied. */ + configuredMaxVoices: number; +} + +// ── Gain conversion ─────────────────────────────────────────────── + +/** + * Convert a dB magnitude to a linear gain multiplier. + * + * @param db - Decibels (positive = boost, negative = attenuation). + * @returns Linear gain factor (`10 ** (db / 20)`). + */ +function dbToGain(db: number): number { + return Math.pow(10, db / 20); +} + +// ── Mixer ───────────────────────────────────────────────────────── + +/** + * Behaviour-aware, deterministic mix arbitration runtime. + * + * Typical use: + * ```ts + * const mixer = new Mixer({ ruleSet: loadMixRules() }); + * mixer.setContext({ surface: "metal" }); + * mixer.updateState("combat"); // evaluates rules for the new state + * mixer.startVoice("combat"); // admitted only while under maxVoices + * mixer.inspect(); // gains, caps, decisions + * ``` + */ +export class Mixer { + private readonly ruleSet: MixRuleSet; + private readonly groupsByName: Map; + private readonly activeVoiceIds: Map>; + private readonly voiceCounters: Map; + private currentState: string; + private currentContext: Record; + private lastDecisions: MixDecision[] = []; + + constructor(options: MixerOptions) { + this.ruleSet = options.ruleSet; + this.currentState = options.initialState ?? ""; + this.currentContext = { ...(options.initialContext ?? {}) }; + this.groupsByName = new Map(); + this.activeVoiceIds = new Map(); + this.voiceCounters = new Map(); + + for (const name of MIX_GROUPS) { + const config = this.ruleSet.groups[name]; + this.groupsByName.set(name, { + name, + priority: config.priority, + maxVoices: config.maxVoices, + configuredMaxVoices: config.maxVoices, + currentGain: 1, + activeVoices: 0, + }); + this.activeVoiceIds.set(name, new Set()); + this.voiceCounters.set(name, 0); + } + } + + // ── State / context ───────────────────────────────────────────── + + /** + * Set the current state and re-evaluate rules. + * + * @param stateName - State name (e.g. "combat"). + * @returns The decisions produced by the evaluation. + */ + updateState(stateName: string): MixDecision[] { + this.currentState = stateName; + return this.applyRules(); + } + + /** + * Merge context dimensions and re-evaluate rules. + * + * @param updates - Context dimensions to set. + * @returns The decisions produced by the evaluation. + */ + setContext(updates: Record): MixDecision[] { + this.currentContext = { ...this.currentContext, ...updates }; + return this.applyRules(); + } + + /** The current state name. */ + state(): string { + return this.currentState; + } + + /** A copy of the current context dimensions. */ + context(): Record { + return { ...this.currentContext }; + } + + // ── Rule evaluation ───────────────────────────────────────────── + + /** + * Evaluate every rule against the current state/context and recompute the + * group gains and voice caps. Evaluation is a pure function of the rule set + * and the current inputs: gains reset to unity and caps to their configured + * values before each pass, so repeated calls with unchanged inputs are + * idempotent. + * + * @returns The ordered list of decisions from the pass. + */ + applyRules(): MixDecision[] { + const decisions: MixDecision[] = []; + + for (const name of MIX_GROUPS) { + const group = this.groupsByName.get(name)!; + group.currentGain = 1; + group.maxVoices = group.configuredMaxVoices; + delete group.duckDurationMs; + } + + for (const rule of this.ruleSet.rules) { + if (!this.ruleMatches(rule)) continue; + for (const action of rule.then.duck) { + this.applyDuck(rule, action, decisions); + } + for (const action of rule.then.boost) { + this.applyBoost(rule, action, decisions); + } + for (const action of rule.then.limit) { + this.applyLimit(rule, action, decisions); + } + } + + // Never report more active voices than the cap: a cap lowered beneath the + // current active count cannot retroactively stop live voices, so the + // effective cap is raised to the active count until they end. + for (const name of MIX_GROUPS) { + const group = this.groupsByName.get(name)!; + if (group.maxVoices < group.activeVoices) { + group.maxVoices = group.activeVoices; + } + } + + this.lastDecisions = decisions; + return decisions; + } + + private ruleMatches(rule: MixRule): boolean { + const when = rule.when; + if (when.state !== undefined && when.state !== this.currentState) { + return false; + } + if (when.context !== undefined) { + for (const [key, value] of Object.entries(when.context)) { + if (this.currentContext[key] !== value) return false; + } + } + return true; + } + + private applyDuck( + rule: MixRule, + action: DuckAction, + decisions: MixDecision[], + ): void { + const group = this.groupsByName.get(action.group); + if (!group) return; + group.currentGain *= dbToGain(-action.depthDb); + if (action.durationMs !== undefined) { + group.duckDurationMs = action.durationMs; + } + const decision: MixDecision = { + action: "duck", + group: action.group, + value: action.depthDb, + }; + if (rule.id !== undefined) decision.ruleId = rule.id; + if (action.durationMs !== undefined) decision.durationMs = action.durationMs; + decisions.push(decision); + } + + private applyBoost( + rule: MixRule, + action: BoostAction, + decisions: MixDecision[], + ): void { + const group = this.groupsByName.get(action.group); + if (!group) return; + if (action.gainDb !== undefined) { + group.currentGain *= dbToGain(action.gainDb); + } + const decision: MixDecision = { + action: "boost", + group: action.group, + value: action.gainDb ?? 0, + }; + if (rule.id !== undefined) decision.ruleId = rule.id; + decisions.push(decision); + } + + private applyLimit( + rule: MixRule, + action: LimitAction, + decisions: MixDecision[], + ): void { + const group = this.groupsByName.get(action.group); + if (!group) return; + group.maxVoices = Math.min(group.maxVoices, action.maxVoices); + const decision: MixDecision = { + action: "limit", + group: action.group, + value: action.maxVoices, + }; + if (rule.id !== undefined) decision.ruleId = rule.id; + decisions.push(decision); + } + + // ── Voice ledger ──────────────────────────────────────────────── + + /** + * Attempt to admit a voice into a group. + * + * @param group - Target mix group. + * @returns A deterministic voice ID when admitted, or `null` when the group + * is already at its effective `maxVoices` cap. + */ + startVoice(group: MixGroupName): string | null { + const state = this.groupsByName.get(group); + if (!state) { + throw new Error(`Unknown mix group '${group}'.`); + } + if (state.activeVoices >= state.maxVoices) { + return null; + } + + const counter = this.voiceCounters.get(group) ?? 0; + const voiceId = `${group}-voice-${counter}`; + this.voiceCounters.set(group, counter + 1); + this.activeVoiceIds.get(group)!.add(voiceId); + state.activeVoices += 1; + return voiceId; + } + + /** + * Release a previously admitted voice. + * + * @param group - Group the voice belongs to. + * @param voiceId - Voice ID returned by {@link startVoice}. + * @returns True when the voice was active and has been released. + */ + stopVoice(group: MixGroupName, voiceId: string): boolean { + const state = this.groupsByName.get(group); + if (!state) { + throw new Error(`Unknown mix group '${group}'.`); + } + const voices = this.activeVoiceIds.get(group)!; + if (!voices.delete(voiceId)) { + return false; + } + state.activeVoices -= 1; + return true; + } + + // ── Inspection ────────────────────────────────────────────────── + + /** + * Snapshot the current group state and the most recent decisions. + * + * @returns A plain, mutable inspection object (safe to serialise as JSON). + */ + inspect(): MixerInspection { + const groups: MixGroupState[] = []; + let voiceCount = 0; + + for (const name of MIX_GROUPS) { + const group = this.groupsByName.get(name)!; + const snapshot: MixGroupState = { + name: group.name, + priority: group.priority, + maxVoices: group.maxVoices, + currentGain: group.currentGain, + activeVoices: group.activeVoices, + }; + if (group.duckDurationMs !== undefined) { + snapshot.duckDurationMs = group.duckDurationMs; + } + groups.push(snapshot); + voiceCount += group.activeVoices; + } + + return { + state: this.currentState, + context: { ...this.currentContext }, + groups, + decisions: this.lastDecisions.slice(), + voiceCount, + }; + } +} + +/** The Mixer defaults re-exported for callers that build custom runtimes. */ +export { MIXER_DEFAULTS }; diff --git a/src/mixer/schema.ts b/src/mixer/schema.ts new file mode 100644 index 00000000..3b6714ed --- /dev/null +++ b/src/mixer/schema.ts @@ -0,0 +1,727 @@ +/** + * Mixer Rule Schema + * + * Defines the declarative JSON schema for Mixer mix rules and provides + * validation plus normalisation. Rules map a state/context trigger + * (`when`) to duck/boost/limit actions (`then`), matching the shape in + * `docs/prd/MIXER_PRD.md` §4.3: + * + * ```json + * { + * "when": { "state": "combat" }, + * "then": { + * "duck": ["ambience"], + * "boost": ["combat"], + * "limit": ["ui"] + * } + * } + * ``` + * + * The schema is intentionally declarative (JSON only, no code execution) so + * rule files stay auditable, version-controlled and deterministic. Built-in + * defaults mirror the committed seed file `.toneforge/mixer/rules.json`: + * `maxVoices` 4, duck depth 6 dB, UI duck duration 200 ms. + * + * Reference: docs/prd/MIXER_PRD.md §4; docs/mixer-rules.md + */ + +// ── Mix groups ──────────────────────────────────────────────────── + +/** + * The six confirmed mix groups (parent epic Appendix Q2.1). Groups are + * intent-based semantic categories, not channel strips. + */ +export const MIX_GROUPS = [ + "footsteps", + "ui", + "combat", + "ambience", + "dialogue", + "alerts", +] as const; + +/** Canonical (lowercase) mix group name. */ +export type MixGroupName = (typeof MIX_GROUPS)[number]; + +// ── Defaults ────────────────────────────────────────────────────── + +/** Mixer-wide default values applied when a rule omits an explicit value. */ +export interface MixerDefaults { + /** Default maximum concurrent voices per group (default: 4). */ + maxVoices: number; + + /** Default duck depth in dB, as a positive attenuation magnitude (default: 6). */ + duckDepthDb: number; + + /** Default duck duration in ms for UI-triggered rules (default: 200). */ + uiDuckDurationMs: number; +} + +/** The confirmed built-in mixer defaults. */ +export const MIXER_DEFAULTS: Readonly = { + maxVoices: 4, + duckDepthDb: 6, + uiDuckDurationMs: 200, +}; + +/** Per-group configuration exposed to the Mixer runtime. */ +export interface MixGroupConfig { + /** Priority level; higher dominates under contention. */ + priority: number; + + /** Maximum concurrent voices admitted for this group. */ + maxVoices: number; +} + +/** + * Built-in group priorities. Ordering follows `docs/prd/MIXER_PRD.md` §4.2 + * (dialogue > UI > combat > ambience) with alerts placed above UI. + */ +export const DEFAULT_GROUP_PRIORITIES: Readonly> = { + dialogue: 100, + alerts: 90, + ui: 80, + combat: 60, + footsteps: 40, + ambience: 20, +}; + +// ── Normalised rule types ───────────────────────────────────────── + +/** A duck action: attenuate `group` by `depthDb`, optionally for `durationMs`. */ +export interface DuckAction { + group: MixGroupName; + + /** Positive attenuation magnitude in dB (e.g. 6 means −6 dB). */ + depthDb: number; + + /** Optional duck duration in ms (UI-triggered rules default this to 200). */ + durationMs?: number; +} + +/** A boost action: raise the priority/gain of `group`. */ +export interface BoostAction { + group: MixGroupName; + + /** Optional explicit gain increase in dB. */ + gainDb?: number; +} + +/** A limit action: cap `group` at `maxVoices` concurrent voices. */ +export interface LimitAction { + group: MixGroupName; + maxVoices: number; +} + +/** The trigger for a mix rule. At least one of `state` / `context` is required. */ +export interface MixRuleWhen { + /** State name that activates the rule (e.g. "combat"). */ + state?: string; + + /** Context dimensions that must match (all key/value pairs) to activate. */ + context?: Record; +} + +/** The actions a mix rule applies when its trigger matches. */ +export interface MixRuleThen { + duck: DuckAction[]; + boost: BoostAction[]; + limit: LimitAction[]; +} + +/** A single declarative mix rule. */ +export interface MixRule { + /** Optional stable identifier for inspection/debugging. */ + id?: string; + + when: MixRuleWhen; + + then: MixRuleThen; +} + +/** A fully validated, normalised rule set ready for the Mixer runtime. */ +export interface MixRuleSet { + version: string; + defaults: MixerDefaults; + groups: Record; + rules: MixRule[]; +} + +// ── Raw (on-disk) rule types ────────────────────────────────────── + +/** Raw `defaults` object as it may appear in a rule file. */ +export interface RawMixDefaults { + maxVoices?: number; + duckDepthDb?: number; + uiDuckDurationMs?: number; +} + +/** Raw per-group config as it may appear in a rule file. */ +export interface RawMixGroupConfig { + priority?: number; + maxVoices?: number; +} + +/** Raw rule entry; action arrays are validated at parse time. */ +export type RawMixRule = Record; + +/** Raw rule-set file shape (each `.json` file in `.toneforge/mixer/`). */ +export interface RawMixRuleFile { + version?: string; + defaults?: RawMixDefaults; + groups?: Record; + rules?: RawMixRule[]; +} + +// ── Validation errors ───────────────────────────────────────────── + +/** A structured validation error with field-level detail. */ +export interface ValidationError { + /** Path to the invalid field (e.g. "rules[2].then.duck[0].group"). */ + field: string; + + /** Human-readable, actionable error message. */ + message: string; +} + +// ── Validation helpers ──────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPositiveInteger(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function isFiniteNumber(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value); +} + +/** Normalise a group name to its canonical lowercase form. */ +function canonicalGroup(name: string): MixGroupName { + return name.trim().toLowerCase() as MixGroupName; +} + +/** Whether *name* (case-insensitive) is one of the six confirmed mix groups. */ +function isKnownGroup(name: string): boolean { + return (MIX_GROUPS as readonly string[]).includes(name.trim().toLowerCase()); +} + +/** + * Validate a group name field, returning whether it is valid. + * + * @param value - The raw value to validate. + * @param prefix - Field path for error reporting. + * @param errors - Error accumulator. + * @returns True when *value* is a known mix group. + */ +function validateGroupName( + value: unknown, + prefix: string, + errors: ValidationError[], +): boolean { + if (typeof value !== "string" || value.trim() === "") { + errors.push({ + field: prefix, + message: `Missing or invalid group name (expected one of: ${MIX_GROUPS.join(", ")}).`, + }); + return false; + } + if (!isKnownGroup(value)) { + errors.push({ + field: prefix, + message: `Unknown mix group '${value}' (expected one of: ${MIX_GROUPS.join(", ")}).`, + }); + return false; + } + return true; +} + +function validateDefaults( + defaults: Record, + errors: ValidationError[], +): void { + if (defaults["maxVoices"] !== undefined && !isPositiveInteger(defaults["maxVoices"])) { + errors.push({ + field: "defaults.maxVoices", + message: "Invalid 'maxVoices' (expected positive integer).", + }); + } + if (defaults["duckDepthDb"] !== undefined) { + const depth = defaults["duckDepthDb"]; + if (!isFiniteNumber(depth) || (depth as number) < 0) { + errors.push({ + field: "defaults.duckDepthDb", + message: "Invalid 'duckDepthDb' (expected non-negative number).", + }); + } + } + if (defaults["uiDuckDurationMs"] !== undefined) { + const duration = defaults["uiDuckDurationMs"]; + if (!isFiniteNumber(duration) || (duration as number) < 0) { + errors.push({ + field: "defaults.uiDuckDurationMs", + message: "Invalid 'uiDuckDurationMs' (expected non-negative number).", + }); + } + } +} + +function validateDuck( + value: unknown, + prefix: string, + errors: ValidationError[], +): void { + if (!Array.isArray(value)) { + errors.push({ field: prefix, message: "Invalid 'duck' (expected array of targets)." }); + return; + } + value.forEach((target, i) => { + const p = `${prefix}[${i}]`; + if (typeof target === "string") { + validateGroupName(target, p, errors); + return; + } + if (!isPlainObject(target)) { + errors.push({ + field: p, + message: "Duck target must be a group name string or an object.", + }); + return; + } + if (!validateGroupName(target["group"], `${p}.group`, errors)) return; + if (target["depthDb"] !== undefined) { + const depth = target["depthDb"]; + if (!isFiniteNumber(depth) || (depth as number) <= 0) { + errors.push({ + field: `${p}.depthDb`, + message: "Invalid 'depthDb' (expected positive number).", + }); + } + } + if (target["durationMs"] !== undefined) { + const duration = target["durationMs"]; + if (!isFiniteNumber(duration) || (duration as number) < 0) { + errors.push({ + field: `${p}.durationMs`, + message: "Invalid 'durationMs' (expected non-negative number).", + }); + } + } + }); +} + +function validateBoost( + value: unknown, + prefix: string, + errors: ValidationError[], +): void { + if (!Array.isArray(value)) { + errors.push({ field: prefix, message: "Invalid 'boost' (expected array of targets)." }); + return; + } + value.forEach((target, i) => { + const p = `${prefix}[${i}]`; + if (typeof target === "string") { + validateGroupName(target, p, errors); + return; + } + if (!isPlainObject(target)) { + errors.push({ + field: p, + message: "Boost target must be a group name string or an object.", + }); + return; + } + if (!validateGroupName(target["group"], `${p}.group`, errors)) return; + if (target["gainDb"] !== undefined && !isFiniteNumber(target["gainDb"])) { + errors.push({ + field: `${p}.gainDb`, + message: "Invalid 'gainDb' (expected finite number).", + }); + } + }); +} + +function validateLimit( + value: unknown, + prefix: string, + errors: ValidationError[], +): void { + if (!Array.isArray(value)) { + errors.push({ field: prefix, message: "Invalid 'limit' (expected array of targets)." }); + return; + } + value.forEach((target, i) => { + const p = `${prefix}[${i}]`; + if (typeof target === "string") { + validateGroupName(target, p, errors); + return; + } + if (!isPlainObject(target)) { + errors.push({ + field: p, + message: "Limit target must be a group name string or an object.", + }); + return; + } + if (!validateGroupName(target["group"], `${p}.group`, errors)) return; + if (target["maxVoices"] !== undefined && !isPositiveInteger(target["maxVoices"])) { + errors.push({ + field: `${p}.maxVoices`, + message: "Invalid 'maxVoices' (expected positive integer).", + }); + } + }); +} + +function validateRule(rule: unknown, prefix: string, errors: ValidationError[]): void { + if (!isPlainObject(rule)) { + errors.push({ field: prefix, message: "Rule must be an object." }); + return; + } + + if (rule["id"] !== undefined && typeof rule["id"] !== "string") { + errors.push({ field: `${prefix}.id`, message: "Invalid 'id' (expected string)." }); + } + + const when = rule["when"]; + if (!isPlainObject(when)) { + errors.push({ + field: `${prefix}.when`, + message: "Missing or invalid 'when' (expected object with 'state' and/or 'context').", + }); + } else { + const hasState = when["state"] !== undefined; + const hasContext = when["context"] !== undefined; + if (!hasState && !hasContext) { + errors.push({ + field: `${prefix}.when`, + message: "'when' must define 'state' and/or 'context'.", + }); + } + if (hasState && (typeof when["state"] !== "string" || (when["state"] as string).trim() === "")) { + errors.push({ + field: `${prefix}.when.state`, + message: "Invalid 'state' (expected non-empty string).", + }); + } + if (hasContext) { + const context = when["context"]; + if (!isPlainObject(context)) { + errors.push({ + field: `${prefix}.when.context`, + message: "Invalid 'context' (expected object of string values).", + }); + } else { + for (const [key, value] of Object.entries(context)) { + if (typeof value !== "string") { + errors.push({ + field: `${prefix}.when.context.${key}`, + message: "Context values must be strings.", + }); + } + } + } + } + } + + const then = rule["then"]; + if (!isPlainObject(then)) { + errors.push({ + field: `${prefix}.then`, + message: "Missing or invalid 'then' (expected object with 'duck', 'boost' and/or 'limit').", + }); + return; + } + const hasDuck = then["duck"] !== undefined; + const hasBoost = then["boost"] !== undefined; + const hasLimit = then["limit"] !== undefined; + if (!hasDuck && !hasBoost && !hasLimit) { + errors.push({ + field: `${prefix}.then`, + message: "'then' must define at least one of 'duck', 'boost', 'limit'.", + }); + return; + } + if (hasDuck) validateDuck(then["duck"], `${prefix}.then.duck`, errors); + if (hasBoost) validateBoost(then["boost"], `${prefix}.then.boost`, errors); + if (hasLimit) validateLimit(then["limit"], `${prefix}.then.limit`, errors); +} + +/** + * Validate a parsed JSON object against the mixer rule schema. + * + * @param data - The parsed JSON data. + * @param source - File path / label used in error messages. + * @returns Array of validation errors (empty = valid). + */ +export function validateMixRuleSet(data: unknown, source: string): ValidationError[] { + const errors: ValidationError[] = []; + + if (!isPlainObject(data)) { + errors.push({ + field: "(root)", + message: `Invalid mixer rules '${source}': expected a JSON object.`, + }); + return errors; + } + + if (typeof data["version"] !== "string" || (data["version"] as string).trim() === "") { + errors.push({ + field: "version", + message: `Missing or invalid 'version' (expected non-empty string, e.g. "1.0").`, + }); + } + + const defaults = data["defaults"]; + if (defaults !== undefined) { + if (!isPlainObject(defaults)) { + errors.push({ field: "defaults", message: "Invalid 'defaults' (expected object)." }); + } else { + validateDefaults(defaults, errors); + } + } + + const groups = data["groups"]; + if (groups !== undefined) { + if (!isPlainObject(groups)) { + errors.push({ field: "groups", message: "Invalid 'groups' (expected object)." }); + } else { + for (const [key, value] of Object.entries(groups)) { + if (!isKnownGroup(key)) { + errors.push({ + field: `groups.${key}`, + message: `Unknown mix group '${key}' (expected one of: ${MIX_GROUPS.join(", ")}).`, + }); + continue; + } + if (!isPlainObject(value)) { + errors.push({ + field: `groups.${key}`, + message: "Invalid group config (expected object).", + }); + continue; + } + if (value["priority"] !== undefined && !isFiniteNumber(value["priority"])) { + errors.push({ + field: `groups.${key}.priority`, + message: "Invalid 'priority' (expected finite number).", + }); + } + if (value["maxVoices"] !== undefined && !isPositiveInteger(value["maxVoices"])) { + errors.push({ + field: `groups.${key}.maxVoices`, + message: "Invalid 'maxVoices' (expected positive integer).", + }); + } + } + } + } + + const rules = data["rules"]; + if (rules !== undefined) { + if (!Array.isArray(rules)) { + errors.push({ field: "rules", message: "Invalid 'rules' (expected array)." }); + } else { + rules.forEach((rule, i) => validateRule(rule, `rules[${i}]`, errors)); + } + } + + return errors; +} + +/** + * Format validation errors into a single actionable message. + * + * @param source - File path / label the errors relate to. + * @param errors - Validation errors. + * @returns Multi-line error message. + */ +export function formatValidationErrors( + source: string, + errors: ValidationError[], +): string { + const details = errors.map((e) => ` ${e.field}: ${e.message}`).join("\n"); + return `Invalid mixer rules '${source}':\n${details}`; +} + +// ── Normalisation ───────────────────────────────────────────────── + +function mergeDefaults(raw: unknown): MixerDefaults { + const defaults: MixerDefaults = { ...MIXER_DEFAULTS }; + if (isPlainObject(raw)) { + if (isPositiveInteger(raw["maxVoices"])) defaults.maxVoices = raw["maxVoices"] as number; + if (isFiniteNumber(raw["duckDepthDb"])) defaults.duckDepthDb = raw["duckDepthDb"] as number; + if (isFiniteNumber(raw["uiDuckDurationMs"])) { + defaults.uiDuckDurationMs = raw["uiDuckDurationMs"] as number; + } + } + return defaults; +} + +function buildGroups( + raw: unknown, + defaults: MixerDefaults, +): Record { + const rawGroups = isPlainObject(raw) ? raw : {}; + const groups = {} as Record; + for (const name of MIX_GROUPS) { + const rawGroup = rawGroups[name]; + const group = isPlainObject(rawGroup) ? rawGroup : {}; + groups[name] = { + priority: isFiniteNumber(group["priority"]) + ? (group["priority"] as number) + : DEFAULT_GROUP_PRIORITIES[name], + maxVoices: isPositiveInteger(group["maxVoices"]) + ? (group["maxVoices"] as number) + : defaults.maxVoices, + }; + } + return groups; +} + +function buildDuck( + raw: unknown, + defaults: MixerDefaults, + when: MixRuleWhen, +): DuckAction[] { + if (!Array.isArray(raw)) return []; + const uiTriggered = when.state === "ui"; + return raw.map((target) => { + if (typeof target === "string") { + const action: DuckAction = { + group: canonicalGroup(target), + depthDb: defaults.duckDepthDb, + }; + if (uiTriggered) action.durationMs = defaults.uiDuckDurationMs; + return action; + } + const obj = target as Record; + const action: DuckAction = { + group: canonicalGroup(obj["group"] as string), + depthDb: isFiniteNumber(obj["depthDb"]) + ? (obj["depthDb"] as number) + : defaults.duckDepthDb, + }; + const duration = isFiniteNumber(obj["durationMs"]) + ? (obj["durationMs"] as number) + : uiTriggered + ? defaults.uiDuckDurationMs + : undefined; + if (duration !== undefined) action.durationMs = duration; + return action; + }); +} + +function buildBoost(raw: unknown): BoostAction[] { + if (!Array.isArray(raw)) return []; + return raw.map((target) => { + if (typeof target === "string") { + return { group: canonicalGroup(target) }; + } + const obj = target as Record; + const action: BoostAction = { group: canonicalGroup(obj["group"] as string) }; + if (isFiniteNumber(obj["gainDb"])) action.gainDb = obj["gainDb"] as number; + return action; + }); +} + +function buildLimit(raw: unknown, defaults: MixerDefaults): LimitAction[] { + if (!Array.isArray(raw)) return []; + return raw.map((target) => { + if (typeof target === "string") { + return { group: canonicalGroup(target), maxVoices: defaults.maxVoices }; + } + const obj = target as Record; + return { + group: canonicalGroup(obj["group"] as string), + maxVoices: isPositiveInteger(obj["maxVoices"]) + ? (obj["maxVoices"] as number) + : defaults.maxVoices, + }; + }); +} + +function buildRules(raw: unknown, defaults: MixerDefaults): MixRule[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const rule = entry as Record; + const whenRaw = rule["when"] as Record; + const when: MixRuleWhen = {}; + if (typeof whenRaw["state"] === "string") when.state = whenRaw["state"]; + if (isPlainObject(whenRaw["context"])) { + const context: Record = {}; + for (const [key, value] of Object.entries(whenRaw["context"])) { + context[key] = String(value); + } + when.context = context; + } + + const thenRaw = rule["then"] as Record; + const then: MixRuleThen = { + duck: buildDuck(thenRaw["duck"], defaults, when), + boost: buildBoost(thenRaw["boost"]), + limit: buildLimit(thenRaw["limit"], defaults), + }; + + const parsed: MixRule = { when, then }; + if (typeof rule["id"] === "string") parsed.id = rule["id"]; + return parsed; + }); +} + +/** + * Validate and normalise a parsed rule-set object into a `MixRuleSet`. + * + * String action targets are expanded using the resolved defaults; all six mix + * groups are always present in the result. Throws with actionable field-level + * errors when the input is invalid. + * + * @param data - The parsed JSON data. + * @param source - File path / label used in error messages. + * @returns A validated, normalised rule set. + * @throws If validation fails. + */ +export function parseMixRuleSet(data: unknown, source: string): MixRuleSet { + const errors = validateMixRuleSet(data, source); + if (errors.length > 0) { + throw new Error(formatValidationErrors(source, errors)); + } + + const obj = data as Record; + const defaults = mergeDefaults(obj["defaults"]); + return { + version: obj["version"] as string, + defaults, + groups: buildGroups(obj["groups"], defaults), + rules: buildRules(obj["rules"], defaults), + }; +} + +// ── Built-in rule set ───────────────────────────────────────────── + +/** + * The built-in rule set used when no rules file is present (mirrors the + * committed seed file `.toneforge/mixer/rules.json`). + */ +export const BUILT_IN_MIX_RULES: MixRuleSet = parseMixRuleSet( + { + version: "1.0", + defaults: { ...MIXER_DEFAULTS }, + rules: [ + { + id: "combat-focus", + when: { state: "combat" }, + then: { duck: ["ambience"], boost: ["combat"], limit: ["ui"] }, + }, + { + id: "ui-clarity", + when: { state: "ui" }, + then: { duck: ["ambience", "footsteps"] }, + }, + ], + }, + "", +); diff --git a/src/recipes/ambient-wind-gust.test.ts b/src/recipes/ambient-wind-gust.test.ts index 56112c88..5f2c7a86 100644 --- a/src/recipes/ambient-wind-gust.test.ts +++ b/src/recipes/ambient-wind-gust.test.ts @@ -1,7 +1,15 @@ +import { describe, it, expect } from "vitest"; import { getAmbientWindGustParams } from "./ambient-wind-gust-params.js"; -import { describeRecipe } from "../test-utils/recipe-test-helper.js"; +import { + assertOverrideChangesOutput, + describeRecipe, +} from "../test-utils/recipe-test-helper.js"; +import { renderPreset, renderRecipe } from "../core/renderer.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; import type { Rng } from "../core/rng.js"; +const OVERRIDE_SEED = 4321; + describeRecipe( "ambient-wind-gust", (rng: Rng) => getAmbientWindGustParams(rng) as unknown as Record, @@ -15,4 +23,54 @@ describeRecipe( { name: "release", min: 0.2, max: 0.8 }, { name: "level", min: 0.3, max: 0.8 }, ], + () => { + // One explicit assertion per parameter that the declarative override + // mappings in ambient-wind-gust.yaml now honour (parent AC6). + it("honours the filterFreq override", async () => { + await assertOverrideChangesOutput("ambient-wind-gust", "filterFreq", 1500, OVERRIDE_SEED); + }); + + it("honours the lfoRate override", async () => { + await assertOverrideChangesOutput("ambient-wind-gust", "lfoRate", 4, OVERRIDE_SEED); + }); + + it("honours the lfoDepth override", async () => { + await assertOverrideChangesOutput("ambient-wind-gust", "lfoDepth", 100, OVERRIDE_SEED); + }); + + it("honours the release override and fades to silence", async () => { + // The envelope release must lie inside the render window, so overriding + // `release` changes the tail and reaches silence by the buffer end. + await assertOverrideChangesOutput("ambient-wind-gust", "release", 0.8, OVERRIDE_SEED); + + const rendered = await renderPreset({ + recipe: "ambient-wind-gust", + seed: OVERRIDE_SEED, + overrides: { release: 0.8 }, + }); + + expect(Math.abs(rendered.samples[rendered.samples.length - 1]!)).toBeLessThan(0.05); + }); + + it("applies the lfoDepth mapping as half the declared depth", async () => { + // The YAML automation `depth` is `lfoDepth / 2`; verify the rendered + // output changes and stays deterministic and non-silent. + const overridden = await renderPreset({ + recipe: "ambient-wind-gust", + seed: OVERRIDE_SEED, + overrides: { lfoDepth: 800 }, + }); + const repeat = await renderPreset({ + recipe: "ambient-wind-gust", + seed: OVERRIDE_SEED, + overrides: { lfoDepth: 800 }, + }); + + expect(compareBuffers(overridden.samples, repeat.samples).identical).toBe(true); + expect(overridden.samples.some((sample) => sample !== 0)).toBe(true); + + const base = await renderRecipe("ambient-wind-gust", OVERRIDE_SEED); + expect(compareBuffers(base.samples, overridden.samples).identical).toBe(false); + }); + }, ); diff --git a/src/recipes/browser-compat.test.ts b/src/recipes/browser-compat.test.ts new file mode 100644 index 00000000..0e143fab --- /dev/null +++ b/src/recipes/browser-compat.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { OfflineAudioContext as NodeOfflineAudioContext } from "node-web-audio-api"; +import { createRng } from "../core/rng.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +/** + * Built-in synthesised recipes exercise the full recipe graph builder against + * the browser-resolved OfflineAudioContext. File-backed recipes are skipped + * here because their discovery is a Node-only path. + */ +const BUILT_IN_RECIPES = ["footstep-stone", "impact-crack"] as const; + +describe("built-in recipes in a browser-like runtime", () => { + it("build and render offline graphs on the browser-native OfflineAudioContext", async () => { + let constructed = 0; + const BrowserOfflineAudioContext = new Proxy(NodeOfflineAudioContext, { + construct(target, args) { + constructed += 1; + return Reflect.construct(target, args); + }, + }); + + const originalNodeVersion = process.versions.node; + Reflect.deleteProperty(process.versions, "node"); + vi.stubGlobal("OfflineAudioContext", BrowserOfflineAudioContext); + vi.resetModules(); + + try { + const { OfflineAudioContext } = await import("../audio/web-audio.js"); + const { registry } = await import("./index.js"); + + for (const name of BUILT_IN_RECIPES) { + const registration = registry.getRegistration(name); + expect(registration, `missing built-in recipe ${name}`).toBeDefined(); + + const seed = 7; + const duration = registration!.getDuration(createRng(seed)); + const sampleRate = 44100; + const length = Math.ceil(sampleRate * duration); + + const ctx = new OfflineAudioContext(1, length, sampleRate); + await registration!.buildOfflineGraph(createRng(seed), ctx, duration); + const rendered = await ctx.startRendering(); + + expect(rendered.numberOfChannels).toBe(1); + expect(rendered.length).toBe(length); + + const samples = rendered.getChannelData(0); + const hasSignal = Array.from(samples).some((sample) => sample !== 0); + expect(hasSignal, `${name} rendered silence`).toBe(true); + } + + // Every context came from the browser-native global, never a static + // node-web-audio-api import. + expect(constructed).toBe(BUILT_IN_RECIPES.length); + } finally { + (process.versions as unknown as Record).node = + originalNodeVersion; + } + }); +}); diff --git a/src/recipes/card-round-complete.test.ts b/src/recipes/card-round-complete.test.ts index 4d9c69c4..d75b2041 100644 --- a/src/recipes/card-round-complete.test.ts +++ b/src/recipes/card-round-complete.test.ts @@ -1,6 +1,26 @@ +import { describe, it, expect } from "vitest"; import { getCardRoundCompleteParams } from "./card-round-complete-params.js"; import { describeRecipe } from "../test-utils/recipe-test-helper.js"; +import { createRng } from "../core/rng.js"; import type { Rng } from "../core/rng.js"; +import { renderPreset, renderRecipe } from "../core/renderer.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; + +const OVERRIDE_SEED = 4321; +const SAMPLE_RATE = 44100; + +/** Root-mean-square amplitude over a half-open sample range. */ +function rms(samples: Float32Array, startSample: number, endSample: number): number { + const start = Math.max(0, startSample); + const end = Math.min(samples.length, endSample); + let sum = 0; + let count = 0; + for (let i = start; i < end; i++) { + sum += samples[i] * samples[i]; + count++; + } + return count === 0 ? 0 : Math.sqrt(sum / count); +} describeRecipe( "card-round-complete", @@ -13,4 +33,67 @@ describeRecipe( { name: "filterCutoff", min: 1500, max: 3500 }, { name: "level", min: 0.5, max: 0.85 }, ], + () => { + it("honours the sustain override (changes output)", async () => { + const base = await renderRecipe("card-round-complete", OVERRIDE_SEED); + const overridden = await renderPreset({ + recipe: "card-round-complete", + seed: OVERRIDE_SEED, + overrides: { sustain: 0.6 }, + }); + + expect(compareBuffers(base.samples, overridden.samples).identical).toBe(false); + }); + + it("keeps duration consistent with the ADSR envelope", async () => { + const params = getCardRoundCompleteParams(createRng(OVERRIDE_SEED)); + const result = await renderPreset({ + recipe: "card-round-complete", + seed: OVERRIDE_SEED, + }); + + // attack -> decay to sustain -> sustain plateau (decay) -> release (decay) + expect(result.duration).toBeCloseTo(params.attack + params.decay * 3, 6); + }); + + it("holds the sustain level between decay and release", async () => { + const params = getCardRoundCompleteParams(createRng(OVERRIDE_SEED)); + const decayEnd = params.attack + params.decay; + const plateauEnd = decayEnd + params.decay; + const startSample = Math.floor(decayEnd * SAMPLE_RATE); + const endSample = Math.ceil(plateauEnd * SAMPLE_RATE); + + const low = await renderPreset({ + recipe: "card-round-complete", + seed: OVERRIDE_SEED, + overrides: { sustain: 0.3 }, + }); + const high = await renderPreset({ + recipe: "card-round-complete", + seed: OVERRIDE_SEED, + overrides: { sustain: 0.6 }, + }); + + const lowRms = rms(low.samples, startSample, endSample); + const highRms = rms(high.samples, startSample, endSample); + + // The plateau amplitude must follow the declared sustain level. + expect(lowRms).toBeGreaterThan(0); + expect(highRms).toBeGreaterThan(lowRms); + }); + + it("renders non-silent audio at both sustain extremes", async () => { + for (const sustain of [0.3, 0.6]) { + const result = await renderPreset({ + recipe: "card-round-complete", + seed: OVERRIDE_SEED, + overrides: { sustain }, + }); + expect( + result.samples.some((sample) => sample !== 0), + `sustain=${sustain} should render non-silent audio`, + ).toBe(true); + } + }); + }, ); diff --git a/src/recipes/card-transform.test.ts b/src/recipes/card-transform.test.ts index 3840588e..7852ccfb 100644 --- a/src/recipes/card-transform.test.ts +++ b/src/recipes/card-transform.test.ts @@ -1,7 +1,15 @@ +import { describe, it, expect } from "vitest"; import { getCardTransformParams } from "./card-transform-params.js"; -import { describeRecipe } from "../test-utils/recipe-test-helper.js"; +import { + assertOverrideChangesOutput, + describeRecipe, +} from "../test-utils/recipe-test-helper.js"; +import { renderPreset } from "../core/renderer.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; import type { Rng } from "../core/rng.js"; +const OVERRIDE_SEED = 4321; + describeRecipe( "card-transform", (rng: Rng) => getCardTransformParams(rng) as unknown as Record, @@ -15,4 +23,42 @@ describeRecipe( { name: "release", min: 0.1, max: 0.3 }, { name: "level", min: 0.5, max: 0.9 }, ], + () => { + // One explicit assertion per parameter honoured by the declarative + // override mappings in card-transform.yaml (parent AC6). + it("honours the carrierFreq override", async () => { + await assertOverrideChangesOutput("card-transform", "carrierFreq", 700, OVERRIDE_SEED); + }); + + it("honours the modRatio override", async () => { + await assertOverrideChangesOutput("card-transform", "modRatio", 4, OVERRIDE_SEED); + }); + + it("honours the modDepthStart override", async () => { + await assertOverrideChangesOutput("card-transform", "modDepthStart", 200, OVERRIDE_SEED); + }); + + it("honours the modDepthEnd override", async () => { + await assertOverrideChangesOutput("card-transform", "modDepthEnd", 800, OVERRIDE_SEED); + }); + + it("keeps a coherent FM ratio when only carrierFreq is overridden", async () => { + // The mapping computes `modulator.frequency = carrier.frequency * + // modRatio` from the resolved (overridden) carrier frequency, so the + // output must change and remain deterministic. + const overridden = await renderPreset({ + recipe: "card-transform", + seed: OVERRIDE_SEED, + overrides: { carrierFreq: 700 }, + }); + const repeat = await renderPreset({ + recipe: "card-transform", + seed: OVERRIDE_SEED, + overrides: { carrierFreq: 700 }, + }); + + expect(compareBuffers(overridden.samples, repeat.samples).identical).toBe(true); + expect(overridden.samples.some((sample) => sample !== 0)).toBe(true); + }); + }, ); diff --git a/src/recipes/impact-crack.test.ts b/src/recipes/impact-crack.test.ts index d4ae555a..c7f0cb47 100644 --- a/src/recipes/impact-crack.test.ts +++ b/src/recipes/impact-crack.test.ts @@ -1,6 +1,11 @@ +import { describe, it, expect } from "vitest"; import { getImpactCrackParams } from "./impact-crack-params.js"; import { describeRecipe } from "../test-utils/recipe-test-helper.js"; import type { Rng } from "../core/rng.js"; +import { renderPreset, renderRecipe } from "../core/renderer.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; + +const OVERRIDE_SEED = 4321; describeRecipe( "impact-crack", @@ -13,4 +18,30 @@ describeRecipe( { name: "level", min: 0.7, max: 1.0 }, { name: "noiseColorMix", min: 0.0, max: 1.0 }, ], + () => { + it("honours the noiseColorMix override (white -> pink changes output)", async () => { + const base = await renderRecipe("impact-crack", OVERRIDE_SEED); + const pink = await renderPreset({ + recipe: "impact-crack", + seed: OVERRIDE_SEED, + overrides: { noiseColorMix: 1 }, + }); + + expect(compareBuffers(base.samples, pink.samples).identical).toBe(false); + }); + + it("renders non-silent audio at both noiseColorMix extremes", async () => { + for (const noiseColorMix of [0, 1]) { + const result = await renderPreset({ + recipe: "impact-crack", + seed: OVERRIDE_SEED, + overrides: { noiseColorMix }, + }); + expect( + result.samples.some((sample) => sample !== 0), + `noiseColorMix=${noiseColorMix} should render non-silent audio`, + ).toBe(true); + } + }); + }, ); diff --git a/src/recipes/index.test.ts b/src/recipes/index.test.ts new file mode 100644 index 00000000..0ade7767 --- /dev/null +++ b/src/recipes/index.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for the recipe registry initialization entry point. + * + * File-backed ToneGraph recipes live on disk and are discovered + * asynchronously. `initializeRecipeRegistry()` is the explicit, idempotent + * entry point Node callers use to load them without a top-level await + * (which browser build targets reject). + */ +import { describe, it, expect, vi } from "vitest"; + +const MIGRATED = [ + "ui-scifi-confirm", + "weapon-laser-zap", + "footstep-gravel", + "ambient-wind-gust", + "card-transform", + "frequency-sweep-demo", +] as const; + +type MigratedName = (typeof MIGRATED)[number]; + +describe("initializeRecipeRegistry", () => { + it("loads file-backed recipes on demand and registers them", async () => { + vi.resetModules(); + const { registry, initializeRecipeRegistry } = await import("./index.js"); + + // Migrated recipes are file-backed: absent until initialization runs. + expect(registry.getRegistration("ui-scifi-confirm")).toBeUndefined(); + + const discovered = await initializeRecipeRegistry(); + + for (const name of MIGRATED) { + expect(discovered).toContain(name); + expect( + registry.getRegistration(name), + `${name} should be registered`, + ).toBeDefined(); + } + }); + + it("is idempotent across repeated calls", async () => { + vi.resetModules(); + const { registry, initializeRecipeRegistry } = await import("./index.js"); + + const first = await initializeRecipeRegistry(); + const second = await initializeRecipeRegistry(); + + expect(second).toEqual(first); + // Registration is not duplicated: each migrated recipe appears once. + const migratedInRegistry = registry + .list() + .filter((name): name is MigratedName => + (MIGRATED as readonly string[]).includes(name), + ); + expect(migratedInRegistry.sort()).toEqual([...MIGRATED].sort()); + }); + + it("exposes parameters and duration for file-backed recipes", async () => { + vi.resetModules(); + const { registry, initializeRecipeRegistry } = await import("./index.js"); + await initializeRecipeRegistry(); + + const registration = registry.getRegistration("ui-scifi-confirm"); + expect(registration).toBeDefined(); + expect(registration!.params.length).toBeGreaterThan(0); + expect(typeof registration!.getParams).toBe("function"); + expect(typeof registration!.getDuration).toBe("function"); + }); +}); diff --git a/src/recipes/index.ts b/src/recipes/index.ts index a48cd34f..a1e2c982 100644 --- a/src/recipes/index.ts +++ b/src/recipes/index.ts @@ -5,8 +5,13 @@ * Each recipe provides deterministic duration and an offline graph builder. */ -import type { OfflineAudioContext } from "node-web-audio-api"; -import { RecipeRegistry, discoverFileBackedRecipes } from "../core/recipe.js"; +import type { OfflineAudioContext } from "../audio/web-audio.js"; +import { + RecipeRegistry, + applyOverrides, + discoverFileBackedRecipes, + resolveExternalRecipeDirectory, +} from "../core/recipe.js"; import type { Rng } from "../core/rng.js"; import { getFootstepStoneParams } from "./footstep-stone-params.js"; import { getUiNotificationChimeParams } from "./ui-notification-chime-params.js"; @@ -57,15 +62,67 @@ import { getCardTableAmbienceParams } from "./card-table-ambience-params.js"; import { getCardDeckPresenceParams } from "./card-deck-presence-params.js"; import { getCardTimerTickParams } from "./card-timer-tick-params.js"; import { getCardTimerWarningParams } from "./card-timer-warning-params.js"; +import { createStackWrapperRecipe } from "./stack-wrapper.js"; +import type { StackDefinition } from "../stack/renderer.js"; /** The global recipe registry instance with all built-in recipes registered. */ export const registry = new RecipeRegistry(); -await discoverFileBackedRecipes(registry); + +/** + * Cached promise for the one-time file-backed recipe discovery pass. + * + * Kept module-scoped so repeated calls to {@link initializeRecipeRegistry} + * reuse the same discovery result (idempotent, no duplicate registration). + */ +let fileBackedRecipesPromise: Promise | undefined; + +/** + * Discover the baked-in and externally registered recipes. + * + * The external directory is resolved from `TONEFORGE_RECIPE_DIR` (or the + * `~/.toneforge/recipes/` default). Resolution is best-effort: a non-Node + * runtime or an unresolvable home directory simply skips external discovery. + */ +async function discoverWithExternalRecipes(): Promise { + let additionalRecipeDirectories: string[] = []; + try { + additionalRecipeDirectories = [await resolveExternalRecipeDirectory()]; + } catch { + additionalRecipeDirectories = []; + } + return discoverFileBackedRecipes(registry, { additionalRecipeDirectories }); +} + +/** + * Register file-backed ToneGraph recipes into the shared registry. + * + * Built-in recipes are registered synchronously when this module is + * evaluated. File-backed recipes live on disk and are discovered + * asynchronously, so they cannot be registered with a top-level `await` — + * browser build targets (Vite/esbuild) reject top-level await, which + * previously broke `npm run build --prefix web`. + * + * Node callers that need file-backed recipes (the CLI, the offline renderer, + * and Node test setup) `await` this function before using the registry. + * Discovery is a no-op in non-Node runtimes, where the promise resolves to an + * empty array — the browser sees only the synchronously-registered built-ins. + * + * The result is cached, so calling this multiple times performs discovery only + * once and returns the same discovered recipe names. + * + * @returns The names of the file-backed recipes that were discovered. + */ +export function initializeRecipeRegistry(): Promise { + if (fileBackedRecipesPromise === undefined) { + fileBackedRecipesPromise = discoverWithExternalRecipes(); + } + return fileBackedRecipesPromise; +} // ── footstep-stone ──────────────────────────────────────────────── -function footstepStoneDuration(rng: Rng): number { - const params = getFootstepStoneParams(rng); +function footstepStoneDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getFootstepStoneParams(rng), overrides); return params.transientAttack + Math.max(params.bodyDecay, params.tailDecay); } @@ -73,8 +130,9 @@ function footstepStoneOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getFootstepStoneParams(rng); + const params = applyOverrides(getFootstepStoneParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -175,8 +233,8 @@ registry.register("footstep-stone", { // ── creature-vocal (sample-hybrid) ──────────────────────────────── -function creatureVocalDuration(rng: Rng): number { - const params = getCreatureVocalParams(rng); +function creatureVocalDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCreatureVocalParams(rng), overrides); return params.attack + params.decay; } @@ -184,8 +242,9 @@ async function creatureVocalOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): Promise { - const params = getCreatureVocalParams(rng); + const params = applyOverrides(getCreatureVocalParams(rng), overrides); // Load the CC0 growl sample const sampleBuffer = await loadSample("creature-vocal/growl.wav", ctx); @@ -273,8 +332,8 @@ registry.register("creature-vocal", { // ── vehicle-engine (sample-hybrid) ──────────────────────────────── -function vehicleEngineDuration(rng: Rng): number { - const params = getVehicleEngineParams(rng); +function vehicleEngineDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getVehicleEngineParams(rng), overrides); return params.attack + 0.4 + params.release; } @@ -282,8 +341,9 @@ async function vehicleEngineOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): Promise { - const params = getVehicleEngineParams(rng); + const params = applyOverrides(getVehicleEngineParams(rng), overrides); // Load the CC0 engine loop sample const sampleBuffer = await loadSample("vehicle-engine/loop.wav", ctx); @@ -376,8 +436,8 @@ registry.register("vehicle-engine", { // ── ui-notification-chime ───────────────────────────────────────── -function uiNotificationChimeDuration(rng: Rng): number { - const params = getUiNotificationChimeParams(rng); +function uiNotificationChimeDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getUiNotificationChimeParams(rng), overrides); return params.attack + params.decay + params.release; } @@ -385,8 +445,9 @@ function uiNotificationChimeOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getUiNotificationChimeParams(rng); + const params = applyOverrides(getUiNotificationChimeParams(rng), overrides); // Create harmonics: fundamental + overtones as separate oscillators for (let h = 0; h < params.harmonicCount; h++) { @@ -451,7 +512,7 @@ registry.register("ui-notification-chime", { // ── character-jump-step1 (oscillator only) ──────────────────────── -function characterJumpStep1Duration(_rng: Rng): number { +function characterJumpStep1Duration(_rng: Rng, overrides: Record = {}): number { // Fixed duration -- no envelope params to derive it from return 0.2; } @@ -460,8 +521,9 @@ function characterJumpStep1OfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCharacterJumpStep1Params(rng); + const params = applyOverrides(getCharacterJumpStep1Params(rng), overrides); // Sine oscillator at constant volume -- no envelope const osc = ctx.createOscillator(); @@ -497,8 +559,8 @@ registry.register("character-jump-step1", { // ── character-jump-step2 (oscillator + envelope) ────────────────── -function characterJumpStep2Duration(rng: Rng): number { - const params = getCharacterJumpStep2Params(rng); +function characterJumpStep2Duration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCharacterJumpStep2Params(rng), overrides); return params.attack + params.decay; } @@ -506,8 +568,9 @@ function characterJumpStep2OfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCharacterJumpStep2Params(rng); + const params = applyOverrides(getCharacterJumpStep2Params(rng), overrides); // Sine oscillator const osc = ctx.createOscillator(); @@ -547,8 +610,8 @@ registry.register("character-jump-step2", { // ── character-jump-step3 (oscillator + envelope + pitch sweep) ──── -function characterJumpStep3Duration(rng: Rng): number { - const params = getCharacterJumpStep3Params(rng); +function characterJumpStep3Duration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCharacterJumpStep3Params(rng), overrides); return params.attack + params.decay; } @@ -556,8 +619,9 @@ function characterJumpStep3OfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCharacterJumpStep3Params(rng); + const params = applyOverrides(getCharacterJumpStep3Params(rng), overrides); // Sine oscillator with rising pitch sweep const osc = ctx.createOscillator(); @@ -606,8 +670,8 @@ registry.register("character-jump-step3", { // ── character-jump-step4 (osc + envelope + sweep + noise) ───────── -function characterJumpStep4Duration(rng: Rng): number { - const params = getCharacterJumpStep4Params(rng); +function characterJumpStep4Duration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCharacterJumpStep4Params(rng), overrides); return params.attack + params.decay; } @@ -615,8 +679,9 @@ function characterJumpStep4OfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCharacterJumpStep4Params(rng); + const params = applyOverrides(getCharacterJumpStep4Params(rng), overrides); // Sine oscillator with rising pitch sweep const osc = ctx.createOscillator(); @@ -697,8 +762,8 @@ registry.register("character-jump-step4", { // ── character-jump ──────────────────────────────────────────────── -function characterJumpDuration(rng: Rng): number { - const params = getCharacterJumpParams(rng); +function characterJumpDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCharacterJumpParams(rng), overrides); return params.attack + params.decay; } @@ -706,8 +771,9 @@ function characterJumpOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCharacterJumpParams(rng); + const params = applyOverrides(getCharacterJumpParams(rng), overrides); // Sine oscillator with rising pitch sweep const osc = ctx.createOscillator(); @@ -798,8 +864,8 @@ registry.register("character-jump", { // ── impact-crack ────────────────────────────────────────────────── -function impactCrackDuration(rng: Rng): number { - const params = getImpactCrackParams(rng); +function impactCrackDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getImpactCrackParams(rng), overrides); return params.attack + params.decay; } @@ -807,16 +873,38 @@ function impactCrackOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getImpactCrackParams(rng); + const params = applyOverrides(getImpactCrackParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); - // White noise source + // Noise source with a white/pink colour blend controlled by + // `noiseColorMix` (0 = white, 1 = pink). The pink component is generated + // with Paul Kellet's refined filter, which approximates a -3 dB/octave + // spectral tilt. Exactly one RNG draw per sample is made, so the underlying + // white sequence is unchanged by the blend amount. const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const noiseData = noiseBuffer.getChannelData(0); + let b0 = 0; + let b1 = 0; + let b2 = 0; + let b3 = 0; + let b4 = 0; + let b5 = 0; + let b6 = 0; + const mix = params.noiseColorMix; for (let i = 0; i < noiseData.length; i++) { - noiseData[i] = rng() * 2 - 1; + const white = rng() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.96900 * b2 + white * 0.1538520; + b3 = 0.86650 * b3 + white * 0.3104856; + b4 = 0.55000 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.0168980; + const pink = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11; + b6 = white * 0.115926; + noiseData[i] = white * (1 - mix) + pink * mix; } const noiseSrc = ctx.createBufferSource(); @@ -876,8 +964,8 @@ registry.register("impact-crack", { // ── rumble-body ─────────────────────────────────────────────────── -function rumbleBodyDuration(rng: Rng): number { - const params = getRumbleBodyParams(rng); +function rumbleBodyDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getRumbleBodyParams(rng), overrides); return params.attack + params.sustainDecay + params.tailDecay; } @@ -885,8 +973,9 @@ function rumbleBodyOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getRumbleBodyParams(rng); + const params = applyOverrides(getRumbleBodyParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -979,8 +1068,8 @@ registry.register("rumble-body", { // ── debris-tail ────────────────────────────────────────────────── -function debrisTailDuration(rng: Rng): number { - const params = getDebrisTailParams(rng); +function debrisTailDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getDebrisTailParams(rng), overrides); return params.durationEnvelope; } @@ -988,8 +1077,9 @@ function debrisTailOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getDebrisTailParams(rng); + const params = applyOverrides(getDebrisTailParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1071,8 +1161,8 @@ registry.register("debris-tail", { // ── slam-transient ─────────────────────────────────────────────── -function slamTransientDuration(rng: Rng): number { - const params = getSlamTransientParams(rng); +function slamTransientDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getSlamTransientParams(rng), overrides); return params.attack + params.decay; } @@ -1080,8 +1170,9 @@ function slamTransientOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getSlamTransientParams(rng); + const params = applyOverrides(getSlamTransientParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1176,8 +1267,8 @@ registry.register("slam-transient", { // ── resonance-body ─────────────────────────────────────────────── -function resonanceBodyDuration(rng: Rng): number { - const params = getResonanceBodyParams(rng); +function resonanceBodyDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getResonanceBodyParams(rng), overrides); return params.attack + Math.max(params.fundamentalDecay, params.overtoneDecay); } @@ -1185,8 +1276,9 @@ function resonanceBodyOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getResonanceBodyParams(rng); + const params = applyOverrides(getResonanceBodyParams(rng), overrides); // Fundamental sine oscillator const fundOsc = ctx.createOscillator(); @@ -1258,8 +1350,8 @@ registry.register("resonance-body", { // ── rattle-decay ───────────────────────────────────────────────── -function rattleDecayDuration(rng: Rng): number { - const params = getRattleDecayParams(rng); +function rattleDecayDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getRattleDecayParams(rng), overrides); return params.duration; } @@ -1267,8 +1359,9 @@ function rattleDecayOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getRattleDecayParams(rng); + const params = applyOverrides(getRattleDecayParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1348,8 +1441,8 @@ registry.register("rattle-decay", { // ── card-flip ───────────────────────────────────────────────────── -function cardFlipDuration(rng: Rng): number { - const params = getCardFlipParams(rng); +function cardFlipDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardFlipParams(rng), overrides); return params.attack + params.decay; } @@ -1357,8 +1450,9 @@ function cardFlipOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardFlipParams(rng); + const params = applyOverrides(getCardFlipParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1439,8 +1533,8 @@ registry.register("card-flip", { // ── card-slide ──────────────────────────────────────────────────── -function cardSlideDuration(rng: Rng): number { - const params = getCardSlideParams(rng); +function cardSlideDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardSlideParams(rng), overrides); return params.attack + params.decay; } @@ -1448,8 +1542,9 @@ function cardSlideOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardSlideParams(rng); + const params = applyOverrides(getCardSlideParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1538,8 +1633,8 @@ registry.register("card-slide", { // ── card-place ──────────────────────────────────────────────────── -function cardPlaceDuration(rng: Rng): number { - const params = getCardPlaceParams(rng); +function cardPlaceDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardPlaceParams(rng), overrides); return params.attack + params.bodyDecay; } @@ -1547,8 +1642,9 @@ function cardPlaceOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardPlaceParams(rng); + const params = applyOverrides(getCardPlaceParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1629,8 +1725,8 @@ registry.register("card-place", { // ── card-draw ───────────────────────────────────────────────────── -function cardDrawDuration(rng: Rng): number { - const params = getCardDrawParams(rng); +function cardDrawDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardDrawParams(rng), overrides); return params.attack + params.decay; } @@ -1638,8 +1734,9 @@ function cardDrawOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardDrawParams(rng); + const params = applyOverrides(getCardDrawParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1726,8 +1823,8 @@ registry.register("card-draw", { // ── card-shuffle ────────────────────────────────────────────────── -function cardShuffleDuration(rng: Rng): number { - const params = getCardShuffleParams(rng); +function cardShuffleDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardShuffleParams(rng), overrides); return params.attack + params.decay; } @@ -1735,8 +1832,9 @@ function cardShuffleOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardShuffleParams(rng); + const params = applyOverrides(getCardShuffleParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1826,8 +1924,8 @@ registry.register("card-shuffle", { // ── card-fan ────────────────────────────────────────────────────── -function cardFanDuration(rng: Rng): number { - const params = getCardFanParams(rng); +function cardFanDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardFanParams(rng), overrides); return params.attack + params.decay; } @@ -1835,8 +1933,9 @@ function cardFanOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardFanParams(rng); + const params = applyOverrides(getCardFanParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -1927,8 +2026,8 @@ registry.register("card-fan", { // ── card-success ────────────────────────────────────────────────── -function cardSuccessDuration(rng: Rng): number { - const params = getCardSuccessParams(rng); +function cardSuccessDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardSuccessParams(rng), overrides); return params.attack + params.decay; } @@ -1936,8 +2035,9 @@ function cardSuccessOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardSuccessParams(rng); + const params = applyOverrides(getCardSuccessParams(rng), overrides); // Primary tone const osc1 = ctx.createOscillator(); @@ -2007,8 +2107,8 @@ registry.register("card-success", { // ── card-failure ────────────────────────────────────────────────── -function cardFailureDuration(rng: Rng): number { - const params = getCardFailureParams(rng); +function cardFailureDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardFailureParams(rng), overrides); return params.attack + params.decay; } @@ -2016,8 +2116,9 @@ function cardFailureOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardFailureParams(rng); + const params = applyOverrides(getCardFailureParams(rng), overrides); // Primary descending oscillator const osc1 = ctx.createOscillator(); @@ -2097,8 +2198,8 @@ registry.register("card-failure", { // ── card-victory-fanfare ────────────────────────────────────────── -function cardVictoryFanfareDuration(rng: Rng): number { - const params = getCardVictoryFanfareParams(rng); +function cardVictoryFanfareDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardVictoryFanfareParams(rng), overrides); return params.noteCount * params.noteDuration + params.tailDecay; } @@ -2106,8 +2207,9 @@ function cardVictoryFanfareOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardVictoryFanfareParams(rng); + const params = applyOverrides(getCardVictoryFanfareParams(rng), overrides); // Primary sine oscillator for arpeggio const osc = ctx.createOscillator(); @@ -2186,8 +2288,8 @@ registry.register("card-victory-fanfare", { // ── card-defeat-sting ───────────────────────────────────────────── -function cardDefeatStingDuration(rng: Rng): number { - const params = getCardDefeatStingParams(rng); +function cardDefeatStingDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardDefeatStingParams(rng), overrides); return params.noteDuration * 2 + params.tailDecay; } @@ -2195,8 +2297,9 @@ function cardDefeatStingOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardDefeatStingParams(rng); + const params = applyOverrides(getCardDefeatStingParams(rng), overrides); // Sine oscillator with descending step const osc = ctx.createOscillator(); @@ -2265,17 +2368,21 @@ registry.register("card-defeat-sting", { // ── card-round-complete ─────────────────────────────────────────── -function cardRoundCompleteDuration(rng: Rng): number { - const params = getCardRoundCompleteParams(rng); - return params.attack + params.decay; +function cardRoundCompleteDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardRoundCompleteParams(rng), overrides); + // ADSR envelope: attack -> decay to sustain -> sustain plateau -> release. + // The recipe declares no separate release or hold time, so the decay time is + // reused for both the sustain plateau and the release ramp. + return params.attack + params.decay * 3; } function cardRoundCompleteOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardRoundCompleteParams(rng); + const params = applyOverrides(getCardRoundCompleteParams(rng), overrides); // Sine oscillator const osc = ctx.createOscillator(); @@ -2291,16 +2398,24 @@ function cardRoundCompleteOfflineGraph( const gain = ctx.createGain(); gain.gain.value = params.level; - // Amplitude envelope - const env = ctx.createGain(); - env.gain.setValueAtTime(0, 0); - env.gain.linearRampToValueAtTime(1, params.attack); - env.gain.linearRampToValueAtTime(0, params.attack + params.decay); + // ADSR amplitude envelope: 0 -> 1 (attack) -> sustain (decay) -> sustain + // plateau -> 0 (release). The sustain level is held between the decay and + // release phases; the decay parameter doubles as the plateau and release + // duration (see `cardRoundCompleteDuration`). + const envelope = ctx.createGain(); + const decayEnd = params.attack + params.decay; + const releaseStart = decayEnd + params.decay; + const releaseEnd = releaseStart + params.decay; + envelope.gain.setValueAtTime(0, 0); + envelope.gain.linearRampToValueAtTime(1, params.attack); + envelope.gain.linearRampToValueAtTime(params.sustain, decayEnd); + envelope.gain.setValueAtTime(params.sustain, releaseStart); + envelope.gain.linearRampToValueAtTime(0, releaseEnd); osc.connect(filter); filter.connect(gain); - gain.connect(env); - env.connect(ctx.destination); + gain.connect(envelope); + envelope.connect(ctx.destination); // Schedule osc.start(0); @@ -2334,8 +2449,8 @@ registry.register("card-round-complete", { // ── card-coin-collect ───────────────────────────────────────────── -function cardCoinCollectDuration(rng: Rng): number { - const params = getCardCoinCollectParams(rng); +function cardCoinCollectDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardCoinCollectParams(rng), overrides); return params.attack + params.decay; } @@ -2343,8 +2458,9 @@ function cardCoinCollectOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardCoinCollectParams(rng); + const params = applyOverrides(getCardCoinCollectParams(rng), overrides); // Primary tone with pitch sweep (start high, settle to baseFreq) const osc1 = ctx.createOscillator(); @@ -2447,8 +2563,8 @@ registry.register("card-coin-collect", { // ── card-coin-collect-hybrid (sample-hybrid) ────────────────────── -function cardCoinCollectHybridDuration(rng: Rng): number { - const params = getCardCoinCollectHybridParams(rng); +function cardCoinCollectHybridDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardCoinCollectHybridParams(rng), overrides); return params.attack + params.decay; } @@ -2456,8 +2572,9 @@ async function cardCoinCollectHybridOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): Promise { - const params = getCardCoinCollectHybridParams(rng); + const params = applyOverrides(getCardCoinCollectHybridParams(rng), overrides); // Load the CC0 metallic coin sample const sampleBuffer = await loadSample("card-coin-collect/clink.wav", ctx); @@ -2555,8 +2672,8 @@ registry.register("card-coin-collect-hybrid", { // ── card-coin-spend ─────────────────────────────────────────────── -function cardCoinSpendDuration(rng: Rng): number { - const params = getCardCoinSpendParams(rng); +function cardCoinSpendDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardCoinSpendParams(rng), overrides); return params.attack + params.decay; } @@ -2564,8 +2681,9 @@ function cardCoinSpendOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardCoinSpendParams(rng); + const params = applyOverrides(getCardCoinSpendParams(rng), overrides); // Primary descending tone const osc = ctx.createOscillator(); @@ -2660,8 +2778,8 @@ registry.register("card-coin-spend", { // ── card-chip-stack ─────────────────────────────────────────────── -function cardChipStackDuration(rng: Rng): number { - const params = getCardChipStackParams(rng); +function cardChipStackDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardChipStackParams(rng), overrides); return params.attack + Math.max(params.clickDecay, params.ringDecay); } @@ -2669,8 +2787,9 @@ function cardChipStackOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardChipStackParams(rng); + const params = applyOverrides(getCardChipStackParams(rng), overrides); // Percussive click: bandpass-filtered noise const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -2755,8 +2874,8 @@ registry.register("card-chip-stack", { // ── card-token-earn ─────────────────────────────────────────────── -function cardTokenEarnDuration(rng: Rng): number { - const params = getCardTokenEarnParams(rng); +function cardTokenEarnDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardTokenEarnParams(rng), overrides); return params.attack + params.decay; } @@ -2764,8 +2883,9 @@ function cardTokenEarnOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardTokenEarnParams(rng); + const params = applyOverrides(getCardTokenEarnParams(rng), overrides); // Fundamental const osc1 = ctx.createOscillator(); @@ -2857,8 +2977,8 @@ registry.register("card-token-earn", { // ── card-treasure-reveal ────────────────────────────────────────── -function cardTreasureRevealDuration(rng: Rng): number { - const params = getCardTreasureRevealParams(rng); +function cardTreasureRevealDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardTreasureRevealParams(rng), overrides); return Math.max( 0.005 + params.shimmerDecay, params.toneAttack + params.toneDecay, @@ -2869,8 +2989,9 @@ function cardTreasureRevealOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardTreasureRevealParams(rng); + const params = applyOverrides(getCardTreasureRevealParams(rng), overrides); // Shimmer layer: highpass-filtered white noise const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -2973,8 +3094,8 @@ registry.register("card-treasure-reveal", { // ── card-discard ───────────────────────────────────────────────── -function cardDiscardDuration(rng: Rng): number { - const params = getCardDiscardParams(rng); +function cardDiscardDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardDiscardParams(rng), overrides); return params.attack + params.decay; } @@ -2982,8 +3103,9 @@ function cardDiscardOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardDiscardParams(rng); + const params = applyOverrides(getCardDiscardParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -3068,8 +3190,8 @@ registry.register("card-discard", { // ── card-burn ──────────────────────────────────────────────────── -function cardBurnDuration(rng: Rng): number { - const params = getCardBurnParams(rng); +function cardBurnDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardBurnParams(rng), overrides); return params.attack + params.decay; } @@ -3077,8 +3199,9 @@ function cardBurnOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardBurnParams(rng); + const params = applyOverrides(getCardBurnParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -3193,8 +3316,8 @@ registry.register("card-burn", { // ── card-return-to-deck ────────────────────────────────────────── -function cardReturnToDeckDuration(rng: Rng): number { - const params = getCardReturnToDeckParams(rng); +function cardReturnToDeckDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardReturnToDeckParams(rng), overrides); return params.attack + params.decay; } @@ -3202,8 +3325,9 @@ function cardReturnToDeckOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardReturnToDeckParams(rng); + const params = applyOverrides(getCardReturnToDeckParams(rng), overrides); const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -3290,8 +3414,8 @@ registry.register("card-return-to-deck", { // ── card-power-up ──────────────────────────────────────────────── -function cardPowerUpDuration(rng: Rng): number { - const params = getCardPowerUpParams(rng); +function cardPowerUpDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardPowerUpParams(rng), overrides); return params.attack + params.decay; } @@ -3299,8 +3423,9 @@ function cardPowerUpOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardPowerUpParams(rng); + const params = applyOverrides(getCardPowerUpParams(rng), overrides); // Fundamental: ascending sine sweep const osc = ctx.createOscillator(); @@ -3373,8 +3498,8 @@ registry.register("card-power-up", { // ── card-power-down ────────────────────────────────────────────── -function cardPowerDownDuration(rng: Rng): number { - const params = getCardPowerDownParams(rng); +function cardPowerDownDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardPowerDownParams(rng), overrides); return params.attack + params.decay; } @@ -3382,8 +3507,9 @@ function cardPowerDownOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardPowerDownParams(rng); + const params = applyOverrides(getCardPowerDownParams(rng), overrides); // Main oscillator: descending pitch with lowpass filter const osc = ctx.createOscillator(); @@ -3472,8 +3598,8 @@ registry.register("card-power-down", { // ── card-lock ──────────────────────────────────────────────────── -function cardLockDuration(rng: Rng): number { - const params = getCardLockParams(rng); +function cardLockDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardLockParams(rng), overrides); return params.attack + params.decay; } @@ -3481,8 +3607,9 @@ function cardLockOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardLockParams(rng); + const params = applyOverrides(getCardLockParams(rng), overrides); // Click transient: square wave burst const click = ctx.createOscillator(); @@ -3560,8 +3687,8 @@ registry.register("card-lock", { // ── card-unlock ────────────────────────────────────────────────── -function cardUnlockDuration(rng: Rng): number { - const params = getCardUnlockParams(rng); +function cardUnlockDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardUnlockParams(rng), overrides); return params.attack + params.decay; } @@ -3569,8 +3696,9 @@ function cardUnlockOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardUnlockParams(rng); + const params = applyOverrides(getCardUnlockParams(rng), overrides); // Click transient: square wave burst const click = ctx.createOscillator(); @@ -3648,8 +3776,8 @@ registry.register("card-unlock", { // ── card-glow ──────────────────────────────────────────────────── -function cardGlowDuration(rng: Rng): number { - const params = getCardGlowParams(rng); +function cardGlowDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardGlowParams(rng), overrides); return params.attack + params.sustain + params.release; } @@ -3657,8 +3785,9 @@ function cardGlowOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardGlowParams(rng); + const params = applyOverrides(getCardGlowParams(rng), overrides); // Base oscillator with LFO vibrato (simulated via frequency automation) const osc = ctx.createOscillator(); @@ -3729,8 +3858,8 @@ registry.register("card-glow", { // ── card-combo-hit ──────────────────────────────────────────────── -function cardComboHitDuration(rng: Rng): number { - const params = getCardComboHitParams(rng); +function cardComboHitDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardComboHitParams(rng), overrides); return params.attack + params.decay; } @@ -3738,8 +3867,9 @@ function cardComboHitOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardComboHitParams(rng); + const params = applyOverrides(getCardComboHitParams(rng), overrides); // Fundamental sine transient const osc = ctx.createOscillator(); @@ -3842,8 +3972,8 @@ registry.register("card-combo-hit", { // ── card-combo-break ────────────────────────────────────────────── -function cardComboBreakDuration(rng: Rng): number { - const params = getCardComboBreakParams(rng); +function cardComboBreakDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardComboBreakParams(rng), overrides); return params.attack + params.decay; } @@ -3851,8 +3981,9 @@ function cardComboBreakOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardComboBreakParams(rng); + const params = applyOverrides(getCardComboBreakParams(rng), overrides); // Main descending sawtooth const osc = ctx.createOscillator(); @@ -3952,8 +4083,8 @@ registry.register("card-combo-break", { // ── card-multiplier-up ──────────────────────────────────────────── -function cardMultiplierUpDuration(rng: Rng): number { - const params = getCardMultiplierUpParams(rng); +function cardMultiplierUpDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardMultiplierUpParams(rng), overrides); return params.noteCount * params.noteDuration + params.attack; } @@ -3961,8 +4092,9 @@ function cardMultiplierUpOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardMultiplierUpParams(rng); + const params = applyOverrides(getCardMultiplierUpParams(rng), overrides); // Single oscillator with frequency steps for ascending arpeggio const osc = ctx.createOscillator(); @@ -4024,8 +4156,8 @@ registry.register("card-multiplier-up", { // ── card-match ──────────────────────────────────────────────────── -function cardMatchDuration(rng: Rng): number { - const params = getCardMatchParams(rng); +function cardMatchDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardMatchParams(rng), overrides); return params.tone2Delay + params.attack + params.decay; } @@ -4033,8 +4165,9 @@ function cardMatchOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardMatchParams(rng); + const params = applyOverrides(getCardMatchParams(rng), overrides); // First tone const osc1 = ctx.createOscillator(); @@ -4105,8 +4238,8 @@ registry.register("card-match", { // ── card-table-ambience ─────────────────────────────────────────── -function cardTableAmbienceDuration(rng: Rng): number { - const params = getCardTableAmbienceParams(rng); +function cardTableAmbienceDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardTableAmbienceParams(rng), overrides); return params.attack + params.sustain + params.release; } @@ -4114,8 +4247,9 @@ function cardTableAmbienceOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardTableAmbienceParams(rng); + const params = applyOverrides(getCardTableAmbienceParams(rng), overrides); // Pink noise buffer (deterministic) const bufferSize = Math.ceil(ctx.sampleRate * duration); @@ -4205,8 +4339,8 @@ registry.register("card-table-ambience", { // ── card-deck-presence ──────────────────────────────────────────── -function cardDeckPresenceDuration(rng: Rng): number { - const params = getCardDeckPresenceParams(rng); +function cardDeckPresenceDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardDeckPresenceParams(rng), overrides); return params.attack + params.sustain + params.release; } @@ -4214,8 +4348,9 @@ function cardDeckPresenceOfflineGraph( rng: Rng, ctx: OfflineAudioContext, _duration: number, + overrides: Record = {}, ): void { - const params = getCardDeckPresenceParams(rng); + const params = applyOverrides(getCardDeckPresenceParams(rng), overrides); const duration = params.attack + params.sustain + params.release; // Fundamental hum @@ -4297,8 +4432,8 @@ registry.register("card-deck-presence", { // ── card-timer-tick ─────────────────────────────────────────────── -function cardTimerTickDuration(rng: Rng): number { - const params = getCardTimerTickParams(rng); +function cardTimerTickDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardTimerTickParams(rng), overrides); return params.attack + params.decay; } @@ -4306,8 +4441,9 @@ function cardTimerTickOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardTimerTickParams(rng); + const params = applyOverrides(getCardTimerTickParams(rng), overrides); // Tonal click const osc = ctx.createOscillator(); @@ -4388,8 +4524,8 @@ registry.register("card-timer-tick", { // ── card-timer-warning ──────────────────────────────────────────── -function cardTimerWarningDuration(rng: Rng): number { - const params = getCardTimerWarningParams(rng); +function cardTimerWarningDuration(rng: Rng, overrides: Record = {}): number { + const params = applyOverrides(getCardTimerWarningParams(rng), overrides); return params.attack + params.decay; } @@ -4397,8 +4533,9 @@ function cardTimerWarningOfflineGraph( rng: Rng, ctx: OfflineAudioContext, duration: number, + overrides: Record = {}, ): void { - const params = getCardTimerWarningParams(rng); + const params = applyOverrides(getCardTimerWarningParams(rng), overrides); // Primary tone with vibrato const osc = ctx.createOscillator(); @@ -4480,3 +4617,32 @@ registry.register("card-timer-warning", { }; }, }); + +// ── stack-card-play-landing (stack wrapper) ─────────────────────── + +/** + * In-memory stack definition for the card_play_landing stack preset. + * + * This mirrors `presets/stacks/card_play_landing.json` so the wrapper + * can be registered without any disk I/O (important for the browser build). + */ +const cardPlayLandingStack: StackDefinition = { + name: "card_play_landing", + layers: [ + { recipe: "card-slide", startTime: 0, gain: 1.0 }, + { recipe: "card-place", startTime: 0.08, gain: 0.9 }, + { recipe: "card-glow", startTime: 0.18, gain: 0.55 }, + ], +}; + +registry.register("stack-card-play-landing", createStackWrapperRecipe({ + name: "stack-card-play-landing", + stack: cardPlayLandingStack, + description: "Wraps the card_play_landing stack preset as a recipe so it can be used as a single event inside a sequence preset.", + category: "Card Game", + tags: ["card", "stack", "wrapper", "landing", "card-game"], + signalChain: "Stack(card_play_landing): card-slide -> card-place -> card-glow", + params: [ + { name: "gain", min: 0.2, max: 1.0, unit: "amplitude" }, + ], +})); diff --git a/src/recipes/stack-wrapper.test.ts b/src/recipes/stack-wrapper.test.ts new file mode 100644 index 00000000..d85c28a2 --- /dev/null +++ b/src/recipes/stack-wrapper.test.ts @@ -0,0 +1,564 @@ +/** + * Stack Wrapper Tests + * + * Tests for createStackWrapperRecipe() — the factory that produces a + * RecipeRegistration wrapping an existing stack preset. Covers seed + * derivation, sample injection, duration agreement, the wrapper's + * registration in the registry, and a full end-to-end sequence render. + * + * Work item: TF-0MMSYF0G20PY7GMB + */ + +import { describe, it, expect } from "vitest"; +import { createStackWrapperRecipe } from "./stack-wrapper.js"; +import { registry } from "./index.js"; +import { renderRecipe, renderPreset } from "../core/renderer.js"; +import { simulate } from "../sequence/simulator.js"; +import { renderSequence } from "../sequence/renderer.js"; +import { renderStack } from "../stack/renderer.js"; +import type { StackDefinition } from "../stack/renderer.js"; +import type { ParamDescriptor } from "../core/recipe.js"; +import type { Rng } from "../core/rng.js"; +import { createRng } from "../core/rng.js"; +import { loadSequencePreset } from "../sequence/preset-loader.js"; + +// ── Test helpers ────────────────────────────────────────────────── + +/** A minimal stack definition for testing — cheap to render. */ +function simpleTestStack(): StackDefinition { + return { + name: "test-stack", + layers: [ + { recipe: "card-slide", startTime: 0, gain: 1.0 }, + { recipe: "card-glow", startTime: 0.1, gain: 0.5 }, + ], + }; +} + +/** A deterministic RNG that advances a linear congruential generator. */ +function makeTestRng(seed: number): Rng { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; +} + +/** + * Create a wrapper recipe and register it with a unique name. + * The caller is responsible for unregistering if needed. + */ +function registerWrapper( + recipeName: string, + stack: StackDefinition, + { + description = "Test stack wrapper", + category = "Test", + params = [], + }: { + description?: string; + category?: string; + params?: ParamDescriptor[]; + } = {}, +): ReturnType { + const wrapper = createStackWrapperRecipe({ + name: recipeName, + stack, + description, + category, + signalChain: `Stack(${stack.name}): ${stack.layers.map((l) => l.recipe).join(", ")}`, + params, + tags: ["stack-wrapper", "test"], + }); + registry.register(recipeName, wrapper); + return wrapper; +} + +// ── Factory & registration ──────────────────────────────────────── + +describe("createStackWrapperRecipe — factory", () => { + it("returns an object with all RecipeRegistration fields", () => { + const wrapper = createStackWrapperRecipe({ + name: "test-wrapper", + stack: simpleTestStack(), + description: "test", + category: "Test", + signalChain: "test", + params: [], + tags: ["test"], + }); + + expect(typeof wrapper.getDuration).toBe("function"); + expect(typeof wrapper.buildOfflineGraph).toBe("function"); + expect(wrapper.description).toBe("test"); + expect(wrapper.category).toBe("Test"); + expect(wrapper.tags).toEqual(["test"]); + expect(wrapper.signalChain).toBe("test"); + expect(wrapper.params).toEqual([]); + expect(typeof wrapper.getParams).toBe("function"); + }); + + it("registers in the recipe registry and is discoverable", () => { + const name = "reg-test-wrapper-" + Date.now(); + registerWrapper(name, simpleTestStack()); + + const registration = registry.getRegistration(name); + expect(registration).toBeDefined(); + expect(registration!.description).toBe("Test stack wrapper"); + expect(registration!.category).toBe("Test"); + }); +}); + +// ── Seed derivation ─────────────────────────────────────────────── + +describe("stack wrapper — seed derivation", () => { + it("derives a deterministic numeric seed from rng()", () => { + const name = "seed-derive-" + Date.now(); + const wrapper = registerWrapper(name, simpleTestStack()); + + // First call to rng() should produce a seed between 0 and 1 (unit interval) + // We verify by checking that getDuration works without throwing + const rng = makeTestRng(42); + const duration = wrapper.getDuration(rng); + + // Duration must be positive (the stack has layers that produce audio) + expect(duration).toBeGreaterThan(0); + }); + + it("produces the same duration for the same rng seed", () => { + const name = "seed-determinism-" + Date.now(); + const wrapper = registerWrapper(name, simpleTestStack()); + + const rngA = makeTestRng(123); + const rngB = makeTestRng(123); + + const durA = wrapper.getDuration(rngA); + const durB = wrapper.getDuration(rngB); + + expect(durA).toBe(durB); + }); + + it("produces different durations for different rng seeds", () => { + const name = "seed-variation-" + Date.now(); + const wrapper = registerWrapper(name, simpleTestStack()); + + const dur1 = wrapper.getDuration(makeTestRng(1)); + const dur2 = wrapper.getDuration(makeTestRng(2)); + // The card-glow duration is the bottleneck; it should vary + // at least slightly across seeds. If the stack is too fast, + // we still expect the test to pass as long as the durations + // are valid (positive). + expect(dur1).toBeGreaterThan(0); + expect(dur2).toBeGreaterThan(0); + }); +}); + +// ── Duration agreement ──────────────────────────────────────────── + +describe("stack wrapper — duration agreement", () => { + it("getDuration() returns >= renderStack duration", async () => { + // Use a fixed seed that we pass directly to both getDuration and renderStack. + // This avoids the rng()-consuming issue where getDuration() and renderStack() + // would otherwise use different seeds. + const testSeed = 12345; + + // Compute the expected duration analytically (same as getDuration does) + const wrapperDuration = computeAnalyticalDuration(simpleTestStack(), testSeed); + + // Render the stack with the same seed + const stackResult = await renderStack(simpleTestStack(), testSeed); + + // Wrapper duration must be >= the actual renderStack duration + // so renderRecipe() doesn't truncate the stack output. + expect(wrapperDuration).toBeGreaterThanOrEqual(stackResult.duration); + }); + + // Helper: re-implement the analytical duration computation so the test + // can verify getDuration against renderStack with the same seed. + function computeAnalyticalDuration( + stackDef: StackDefinition, + seed: number, + ): number { + let totalDuration = 0; + for (let i = 0; i < stackDef.layers.length; i++) { + const layer = stackDef.layers[i]!; + const layerSeed = seed + i; + let layerDuration: number; + if (layer.duration !== undefined) { + layerDuration = layer.duration; + } else { + const reg = registry.getRegistration(layer.recipe); + if (reg) { + layerDuration = reg.getDuration(makeTestRng(layerSeed)); + } else { + layerDuration = 1.0; + } + } + const layerEnd = layer.startTime + layerDuration; + if (layerEnd > totalDuration) { + totalDuration = layerEnd; + } + } + return totalDuration > 0 ? totalDuration : 1.0; + } + + it("wrapper duration is at least the stack's total duration", async () => { + const name = "duration-min-" + Date.now(); + const wrapper = registerWrapper(name, simpleTestStack()); + + const rng = makeTestRng(99); + const wrapperDuration = wrapper.getDuration(rng); + + // The card-slide has decay up to 0.2s, card-glow starts at 0.1s + // so total duration is > 0.1s. + expect(wrapperDuration).toBeGreaterThan(0.1); + }); +}); + +// ── Sample injection ────────────────────────────────────────────── + +describe("stack wrapper — sample injection", () => { + it("renders non-silent audio via the recipe path", async () => { + const name = "inject-sample-" + Date.now(); + registerWrapper(name, simpleTestStack()); + + const result = await renderRecipe(name, 42); + + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + expect(result.sampleRate).toBe(44100); + + // Verify the output is not all zeros + const nonZero = result.samples.filter((s) => s !== 0).length; + expect(nonZero).toBeGreaterThan(0); + }); + + it("renders byte-identical audio for the same seed", async () => { + const name = "inject-determinism-" + Date.now(); + registerWrapper(name, simpleTestStack()); + + const r1 = await renderRecipe(name, 42); + const r2 = await renderRecipe(name, 42); + + expect(r1.samples.length).toBe(r2.samples.length); + for (let i = 0; i < r1.samples.length; i++) { + expect(r1.samples[i]).toBe(r2.samples[i]); + } + }); + + it("renders different audio for different seeds", async () => { + const name = "inject-variation-" + Date.now(); + registerWrapper(name, simpleTestStack()); + + const r1 = await renderRecipe(name, 1); + const r2 = await renderRecipe(name, 2); + + // Different seeds may produce different durations (stack layers vary), + // so compare only up to the shorter length. + const minLength = Math.min(r1.samples.length, r2.samples.length); + let diffCount = 0; + for (let i = 0; i < minLength; i++) { + if (r1.samples[i] !== r2.samples[i]) diffCount++; + } + expect(diffCount).toBeGreaterThan(0); + }); + + it("clamps output to [-1, 1]", async () => { + const name = "inject-clamp-" + Date.now(); + registerWrapper(name, simpleTestStack()); + + const result = await renderRecipe(name, 42); + + for (let i = 0; i < result.samples.length; i++) { + const v = result.samples[i]!; + expect(v).toBeGreaterThanOrEqual(-1); + expect(v).toBeLessThanOrEqual(1); + } + }); +}); + +// ── Integration: full sequence render ───────────────────────────── + +describe("stack wrapper — sequence integration", () => { + it("renders a composed sequence end-to-end (simulate + renderSequence)", async () => { + const wrapperName = "seq-integration-wrapper-" + Date.now(); + const wrapper = registerWrapper(wrapperName, simpleTestStack()); + + const sequenceDef = { + name: "test-seq-with-stack", + events: [ + { + time: 0, + time_ms: 0, + event: wrapperName, + seedOffset: 0, + probability: 1.0, + gain: 1.0, + }, + ], + }; + + const sim = simulate(sequenceDef, 42); + const result = await renderSequence(sim); + + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + expect(result.sampleRate).toBe(44100); + expect(result.duration).toBeGreaterThan(0); + expect(result.numberOfChannels).toBe(1); + + // Duration must be at least the wrapped stack's duration + const nonZero = result.samples.filter((s) => s !== 0).length; + expect(nonZero).toBeGreaterThan(0); + + // Verify determinism + const sim2 = simulate(sequenceDef, 42); + const result2 = await renderSequence(sim2); + expect(result2.samples.length).toBe(result.samples.length); + for (let i = 0; i < result.samples.length; i++) { + expect(result.samples[i]).toBe(result2.samples[i]); + } + }); + + it("handles a multi-event sequence mixing stack-wrapper with plain recipes", async () => { + const wrapperName = "multi-event-wrapper-" + Date.now(); + registerWrapper(wrapperName, simpleTestStack()); + + const sequenceDef = { + name: "multi-event-seq", + events: [ + { + time: 0, + time_ms: 0, + event: "impact-crack", + seedOffset: 0, + probability: 1.0, + gain: 0.5, + }, + { + time: 0.1, + time_ms: 100, + event: wrapperName, + seedOffset: 1, + probability: 1.0, + gain: 1.0, + }, + { + time: 0.3, + time_ms: 300, + event: "card-glow", + seedOffset: 2, + probability: 1.0, + gain: 0.5, + }, + ], + }; + + const sim = simulate(sequenceDef, 42); + const result = await renderSequence(sim); + + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + expect(result.duration).toBeGreaterThan(0.3); // at least 300ms + + // Determinism check + const sim2 = simulate(sequenceDef, 42); + const result2 = await renderSequence(sim2); + expect(result.samples.length).toBe(result2.samples.length); + for (let i = 0; i < result.samples.length; i++) { + expect(result.samples[i]).toBe(result2.samples[i]); + } + }); +}); + +// ── Gain override ───────────────────────────────────────────────── + +describe("stack wrapper — gain override", () => { + it("scales the injected samples by the override value", async () => { + const name = "gain-override-" + Date.now(); + registerWrapper(name, simpleTestStack(), { + params: [{ name: "gain", min: 0.2, max: 1.0, unit: "amplitude" }], + }); + + const full = await renderPreset({ recipe: name, seed: 42, overrides: { gain: 1.0 } }); + const quiet = await renderPreset({ recipe: name, seed: 42, overrides: { gain: 0.3 } }); + + expect(quiet.samples.length).toBe(full.samples.length); + + // Peak amplitude should scale by ~0.3 (the stack clamps to [-1, 1] + // before the wrapper applies gain, so scaling is linear). + let fullPeak = 0; + let quietPeak = 0; + for (let i = 0; i < full.samples.length; i++) { + fullPeak = Math.max(fullPeak, Math.abs(full.samples[i]!)); + quietPeak = Math.max(quietPeak, Math.abs(quiet.samples[i]!)); + } + expect(fullPeak).toBeGreaterThan(0); + expect(quietPeak).toBeGreaterThan(0); + expect(quietPeak).toBeCloseTo(fullPeak * 0.3, 5); + }); +}); + +// ── getParams ───────────────────────────────────────────────────── + +describe("stack wrapper — getParams", () => { + it("returns the params array (empty when no params declared)", () => { + const wrapper = createStackWrapperRecipe({ + name: "params-test", + stack: simpleTestStack(), + description: "test", + category: "Test", + signalChain: "test", + params: [], + tags: ["test"], + }); + + const params = wrapper.getParams(makeTestRng(42)); + expect(params).toEqual({}); + }); + + it("returns params when declared", () => { + const wrapper = createStackWrapperRecipe({ + name: "params-with-decl", + stack: simpleTestStack(), + description: "test", + category: "Test", + signalChain: "test", + params: [ + { name: "layerGain", min: 0, max: 1, unit: "amplitude" }, + ], + tags: ["test"], + }); + + const params = wrapper.getParams(makeTestRng(42)); + expect(params).toHaveProperty("layerGain"); + expect(params.layerGain).toBeGreaterThanOrEqual(0); + expect(params.layerGain).toBeLessThanOrEqual(1); + }); +}); + +// ── Shipped preset file ─────────────────────────────────────────── + +const PRESET_PATH = "presets/sequences/tableau_play_card_with_landing.json"; + +describe("shipped preset — tableau_play_card_with_landing", () => { + it("loads and validates through the existing sequence preset loader", async () => { + const def = await loadSequencePreset(PRESET_PATH); + expect(def.name).toBe("tableau_play_card_with_landing"); + expect(def.events.length).toBeGreaterThanOrEqual(1); + }); + + it("contains an event referencing the stack-card-play-landing wrapper recipe", async () => { + const def = await loadSequencePreset(PRESET_PATH); + const wrapperEvents = def.events.filter( + (e) => e.event === "stack-card-play-landing", + ); + expect(wrapperEvents.length).toBeGreaterThanOrEqual(1); + + // The wrapper recipe must be registered. + expect(registry.getRegistration(wrapperEvents[0]!.event)).toBeDefined(); + }); + + it("simulates and renders end-to-end with a non-empty, finite buffer", async () => { + const def = await loadSequencePreset(PRESET_PATH); + const sim = simulate(def, 42); + expect(sim.events.length).toBeGreaterThan(0); + + const result = await renderSequence(sim); + expect(result.samples).toBeInstanceOf(Float32Array); + expect(result.samples.length).toBeGreaterThan(0); + + // All samples finite and within [-1, 1]. + let nonZero = 0; + for (let i = 0; i < result.samples.length; i++) { + const v = result.samples[i]!; + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(-1); + expect(v).toBeLessThanOrEqual(1); + if (v !== 0) nonZero++; + } + expect(nonZero).toBeGreaterThan(0); + }); + + it("produces byte-identical output across two seeded runs", async () => { + const def = await loadSequencePreset(PRESET_PATH); + + const result1 = await renderSequence(simulate(def, 42)); + const result2 = await renderSequence(simulate(def, 42)); + + expect(result1.samples.length).toBe(result2.samples.length); + for (let i = 0; i < result1.samples.length; i++) { + expect(result1.samples[i]).toBe(result2.samples[i]); + } + }); + + it("has a duration at least as long as the wrapped stack event", async () => { + const def = await loadSequencePreset(PRESET_PATH); + const sim = simulate(def, 42); + const result = await renderSequence(sim); + + // Find the wrapper event and render the wrapped stack with the SAME + // seed the wrapper derives internally (first rng() call scaled to an + // integer). + const wrapperEvent = sim.events.find( + (e) => e.event === "stack-card-play-landing", + )!; + const derivedSeed = Math.floor( + createRng(wrapperEvent.eventSeed)() * Number.MAX_SAFE_INTEGER, + ); + const stackResult = await renderStack( + { + name: "card_play_landing", + layers: [ + { recipe: "card-slide", startTime: 0, gain: 1.0 }, + { recipe: "card-place", startTime: 0.08, gain: 0.9 }, + { recipe: "card-glow", startTime: 0.18, gain: 0.55 }, + ], + }, + derivedSeed, + ); + + const wrapperEnd = wrapperEvent.time_ms / 1000 + stackResult.duration; + expect(result.duration).toBeGreaterThanOrEqual(wrapperEnd - 1e-6); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── + +describe("stack wrapper — error handling", () => { + it("throws if the wrapped stack has no layers", async () => { + const name = "empty-stack-" + Date.now(); + const wrapper = createStackWrapperRecipe({ + name, + stack: { name: "empty", layers: [] }, + description: "empty", + category: "Test", + signalChain: "empty", + params: [], + tags: [], + }); + registry.register(name, wrapper); + + // buildOfflineGraph with an empty stack should propagate the error + await expect(renderRecipe(name, 42)).rejects.toThrow(/at least one layer/i); + }); + + it("throws if the wrapped stack references an unknown recipe", async () => { + const name = "bad-layer-" + Date.now(); + const wrapper = createStackWrapperRecipe({ + name, + stack: { + name: "bad", + layers: [{ recipe: "nonexistent-recipe-xyz", startTime: 0, gain: 1.0 }], + }, + description: "bad", + category: "Test", + signalChain: "bad", + params: [], + tags: [], + }); + registry.register(name, wrapper); + + await expect(renderRecipe(name, 42)).rejects.toThrow(/nonexistent-recipe-xyz/); + }); +}); diff --git a/src/recipes/stack-wrapper.ts b/src/recipes/stack-wrapper.ts new file mode 100644 index 00000000..72eaa426 --- /dev/null +++ b/src/recipes/stack-wrapper.ts @@ -0,0 +1,195 @@ +/** + * Stack Wrapper Recipe Factory + * + * Produces a `RecipeRegistration` that wraps an existing `StackDefinition`, + * enabling a stack preset to be used as a single event inside a sequence + * preset. The wrapper derives a deterministic numeric seed from the recipe's + * `Rng` function, calls `renderStack()` to produce the mixed audio buffer, + * then injects those samples into the caller's `OfflineAudioContext` as a + * buffer source so the normal `renderRecipe()` path can render it. + * + * This pattern solves the problem that sequence events resolve recipes only + * (`renderSequence()` calls `registry.getRegistration(evt.event)` and then + * `renderRecipe()`), while stack presets are rendered by the separate + * `renderStack()` path. + * + * Reference: docs/guides/gamedev-workflow-example.md (stack-in-sequence section) + * Work item: TF-0MMSYF0G20PY7GMB + */ + +import type { OfflineAudioContext } from "../audio/web-audio.js"; +import type { ParamDescriptor, RecipeRegistration } from "../core/recipe.js"; +import { applyOverrides } from "../core/recipe.js"; +import type { Rng } from "../core/rng.js"; +import { createRng } from "../core/rng.js"; +import type { StackDefinition } from "../stack/renderer.js"; +import { renderStack } from "../stack/renderer.js"; +import { registry } from "./index.js"; + +/** + * Options for creating a stack-wrapper recipe registration. + */ +export interface StackWrapperOptions { + /** The registered recipe name this wrapper will use. */ + name: string; + /** The stack definition to wrap. */ + stack: StackDefinition; + /** Human-readable description. */ + description: string; + /** Category string for grouping/filtering. */ + category: string; + /** Optional tags for search. */ + tags?: string[]; + /** Signal chain description for tooling display. */ + signalChain: string; + /** Parameter descriptors (e.g. an output `gain`). */ + params: ParamDescriptor[]; +} + +/** Values derived from a single recipe `Rng` when rendering the wrapper. */ +interface DerivedWrapperInputs { + /** Numeric seed passed to `renderStack()`. */ + seed: number; + /** Seed-derived parameter values (overrides already applied). */ + params: Record; +} + +/** + * Compute the duration of a stack analytically without rendering. + * + * Iterates over all layers, resolves each layer's duration (explicit or + * via registry lookup), and returns the maximum `layer.startTime + layerDuration`. + * + * This mirrors `renderStack()`'s duration calculation so the wrapper's + * `getDuration()` never under-reports (which would cause `renderRecipe()` + * to truncate the wrapped stack). + */ +function computeStackDuration( + stackDef: StackDefinition, + seed: number, +): number { + let totalDuration = 0; + + for (let i = 0; i < stackDef.layers.length; i++) { + const layer = stackDef.layers[i]!; + const layerSeed = seed + i; + + let layerDuration: number; + if (layer.duration !== undefined) { + layerDuration = layer.duration; + } else { + const reg = registry.getRegistration(layer.recipe); + if (reg) { + layerDuration = reg.getDuration(createRng(layerSeed)); + } else { + layerDuration = 1.0; // fallback if recipe not found + } + } + + const layerEnd = layer.startTime + layerDuration; + if (layerEnd > totalDuration) { + totalDuration = layerEnd; + } + } + + return totalDuration > 0 ? totalDuration : 1.0; +} + +/** + * Derive the wrapper's runtime inputs from a single `Rng`. + * + * The stack seed is taken from the **first** `rng()` call so + * `getDuration()` and `buildOfflineGraph()` agree for the same event seed. + * Each declared parameter is then derived from subsequent calls and + * overrides are applied on top (following the `applyOverrides` convention). + */ +function deriveInputs( + rng: Rng, + paramDecls: ParamDescriptor[], + overrides?: Record, +): DerivedWrapperInputs { + const raw = rng(); + const seed = Math.floor(raw * Number.MAX_SAFE_INTEGER); + + const params: Record = {}; + for (const p of paramDecls) { + const value = p.min + rng() * (p.max - p.min); + params[p.name] = value; + } + + return { seed, params: applyOverrides(params, overrides) }; +} + +/** + * Create a `RecipeRegistration` that wraps the given stack definition. + * + * The returned registration conforms to the `RecipeRegistration` interface: + * + * - `getDuration()` derives the stack seed from `rng()` and returns the + * stack's analytical duration (>= the actual `renderStack()` duration), + * so `renderRecipe()` allocates a buffer large enough for the whole stack. + * - `buildOfflineGraph()` derives the same seed, calls `renderStack()` to + * produce the mixed audio, applies the declared output-gain parameter, and + * injects the samples into the caller's `OfflineAudioContext` as a + * `BufferSource` scheduled at time 0. + * + * @param options - Configuration for the wrapper recipe. + * @returns A `RecipeRegistration` ready for registry registration. + */ +export function createStackWrapperRecipe( + options: StackWrapperOptions, +): RecipeRegistration { + const stack = options.stack; + const params = options.params; + + return { + getDuration(rng: Rng, overrides?: Record): number { + const { seed } = deriveInputs(rng, params, overrides); + return computeStackDuration(stack, seed); + }, + + async buildOfflineGraph( + rng: Rng, + ctx: OfflineAudioContext, + duration: number, + overrides?: Record, + ): Promise { + const { seed, params: derived } = deriveInputs(rng, params, overrides); + const gain = derived["gain"] ?? 1.0; + + // Render the wrapped stack. `renderStack()` creates its own + // OfflineAudioContext, mixes all layers, and returns the result. + const result = await renderStack(stack, seed); + + // Copy the mixed samples into a buffer in the caller's context, + // applying the output gain. + const audioCtx = ctx as unknown as BaseAudioContext; + const audioBuffer = audioCtx.createBuffer( + result.numberOfChannels, + result.samples.length, + result.sampleRate, + ); + + for (let ch = 0; ch < result.numberOfChannels; ch++) { + const channelData = audioBuffer.getChannelData(ch); + for (let i = 0; i < result.samples.length; i++) { + channelData[i] = result.samples[i]! * gain; + } + } + + // Schedule the wrapped stack at the start of the event. + const source = audioCtx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioCtx.destination); + source.start(0); + source.stop(duration); + }, + + description: options.description, + category: options.category, + tags: options.tags, + signalChain: options.signalChain, + params: options.params, + getParams: (rng: Rng) => deriveInputs(rng, params).params, + }; +} diff --git a/src/recipes/tonegraph-migration.test.ts b/src/recipes/tonegraph-migration.test.ts index 4d878173..3b8d3859 100644 --- a/src/recipes/tonegraph-migration.test.ts +++ b/src/recipes/tonegraph-migration.test.ts @@ -14,6 +14,7 @@ const MIGRATED = [ "footstep-gravel", "ambient-wind-gust", "card-transform", + "frequency-sweep-demo", ] as const; interface RenderResult { diff --git a/src/recipes/weapon-laser-zap.test.ts b/src/recipes/weapon-laser-zap.test.ts index b51d5481..60e7d585 100644 --- a/src/recipes/weapon-laser-zap.test.ts +++ b/src/recipes/weapon-laser-zap.test.ts @@ -1,7 +1,15 @@ +import { describe, it, expect } from "vitest"; import { getWeaponLaserZapParams } from "./weapon-laser-zap-params.js"; -import { describeRecipe } from "../test-utils/recipe-test-helper.js"; +import { + assertOverrideChangesOutput, + describeRecipe, +} from "../test-utils/recipe-test-helper.js"; +import { renderPreset } from "../core/renderer.js"; +import { compareBuffers } from "../test-utils/buffer-compare.js"; import type { Rng } from "../core/rng.js"; +const OVERRIDE_SEED = 4321; + describeRecipe( "weapon-laser-zap", (rng: Rng) => getWeaponLaserZapParams(rng) as unknown as Record, @@ -13,4 +21,37 @@ describeRecipe( { name: "attack", min: 0.001, max: 0.005 }, { name: "decay", min: 0.03, max: 0.25 }, ], + () => { + // One explicit assertion per parameter honoured by the declarative + // override mappings in weapon-laser-zap.yaml (parent AC6). + it("honours the carrierFreq override", async () => { + await assertOverrideChangesOutput("weapon-laser-zap", "carrierFreq", 2000, OVERRIDE_SEED); + }); + + it("honours the modulatorFreq override", async () => { + await assertOverrideChangesOutput("weapon-laser-zap", "modulatorFreq", 500, OVERRIDE_SEED); + }); + + it("honours the modIndex override", async () => { + await assertOverrideChangesOutput("weapon-laser-zap", "modIndex", 10, OVERRIDE_SEED); + }); + + it("keeps the modIndex mapping deterministic and non-silent", async () => { + // `modDepth.gain = modIndex * modulator.frequency`; verify the computed + // mapping renders deterministically. + const overridden = await renderPreset({ + recipe: "weapon-laser-zap", + seed: OVERRIDE_SEED, + overrides: { modIndex: 10 }, + }); + const repeat = await renderPreset({ + recipe: "weapon-laser-zap", + seed: OVERRIDE_SEED, + overrides: { modIndex: 10 }, + }); + + expect(compareBuffers(overridden.samples, repeat.samples).identical).toBe(true); + expect(overridden.samples.some((sample) => sample !== 0)).toBe(true); + }); + }, ); diff --git a/src/test-utils/fixtures/golden-sequences/tableau_play_card_with_landing.golden.json b/src/test-utils/fixtures/golden-sequences/tableau_play_card_with_landing.golden.json new file mode 100644 index 00000000..47fef4b8 --- /dev/null +++ b/src/test-utils/fixtures/golden-sequences/tableau_play_card_with_landing.golden.json @@ -0,0 +1,27 @@ +{ + "name": "tableau_play_card_with_landing", + "seed": 42, + "sampleRate": 44100, + "totalDuration": 0.15, + "totalDuration_ms": 150, + "events": [ + { + "time_ms": 0, + "sampleOffset": 0, + "event": "card-slide", + "seedOffset": 0, + "eventSeed": 42, + "gain": 0.7, + "repetition": 0 + }, + { + "time_ms": 150, + "sampleOffset": 6615, + "event": "stack-card-play-landing", + "seedOffset": 1, + "eventSeed": 43, + "gain": 1, + "repetition": 0 + } + ] +} diff --git a/src/test-utils/recipe-test-helper.ts b/src/test-utils/recipe-test-helper.ts index be7a89cc..3e149f91 100644 --- a/src/test-utils/recipe-test-helper.ts +++ b/src/test-utils/recipe-test-helper.ts @@ -15,7 +15,7 @@ import { describe, it, expect } from "vitest"; import { createRng } from "../core/rng.js"; import type { Rng } from "../core/rng.js"; import { registry } from "../recipes/index.js"; -import { renderRecipe } from "../core/renderer.js"; +import { renderPreset, renderRecipe } from "../core/renderer.js"; import { compareBuffers, formatCompareResult, @@ -34,6 +34,36 @@ export interface ParamSpec { max: number; } +/** + * Assert that overriding a single declared parameter changes the rendered + * output of a recipe. Throws (via the returned rejection) when the override + * has no audible effect. + * + * @param recipeName - Registered recipe name. + * @param param - Declared parameter to override. + * @param overrideValue - Override value (usually an extreme of the declared range). + * @param seed - Deterministic seed (default `4321`). + */ +export async function assertOverrideChangesOutput( + recipeName: string, + param: string, + overrideValue: number, + seed = 4321, +): Promise { + const base = await renderRecipe(recipeName, seed); + const overridden = await renderPreset({ + recipe: recipeName, + seed, + overrides: { [param]: overrideValue }, + }); + + const comparison = compareBuffers(base.samples, overridden.samples); + expect( + comparison.identical, + `Override of "${param}" on recipe "${recipeName}" had no effect.\n${formatCompareResult(comparison)}`, + ).toBe(false); +} + /** * Run the standard recipe test battery. * diff --git a/src/tui/__tests__/integration.test.ts b/src/tui/__tests__/integration.test.ts index 8c9fc8cc..de5762ff 100644 --- a/src/tui/__tests__/integration.test.ts +++ b/src/tui/__tests__/integration.test.ts @@ -87,6 +87,7 @@ vi.mock("node:fs/promises", () => ({ import { launchWizard } from "../index.js"; import { setTtyOverride } from "../../output.js"; +import { InMemorySessionStore } from "../session-store.js"; import { select, input, confirm } from "@inquirer/prompts"; import { sweep } from "../../explore/sweep.js"; import { renderRecipe } from "../../core/renderer.js"; @@ -148,6 +149,9 @@ function makeCandidate(recipe: string, seed: number): ExploreCandidate { let selectQueue: Array = []; let inputQueue: Array = []; let confirmQueue: Array = []; +// Hermetic persistence store: the wizard writes/reads session state here +// instead of the developer's on-disk session file (no VITEST/NODE_ENV guard). +let sessionStore: InMemorySessionStore; function setupPromptMocks() { (select as Mock).mockImplementation(async () => { @@ -205,6 +209,7 @@ describe("tui wizard end-to-end", () => { beforeEach(() => { vi.clearAllMocks(); setTtyOverride(true); + sessionStore = new InMemorySessionStore(); // Full pipeline prompt sequence: // @@ -260,6 +265,7 @@ describe("tui wizard end-to-end", () => { true, // review confirm true, // export byCategory true, // export proceed + true, // delete session file (file exists because auto-save persisted it) ]; setupPromptMocks(); @@ -272,13 +278,13 @@ describe("tui wizard end-to-end", () => { }); it("completes a two-recipe palette and writes a manifest", async () => { - const exitCode = await launchWizard(); + const exitCode = await launchWizard({ store: sessionStore }); expect(exitCode).toBe(0); // Verify prompt call counts expect(select as Mock).toHaveBeenCalledTimes(12); expect(input as Mock).toHaveBeenCalledTimes(1); - expect(confirm as Mock).toHaveBeenCalledTimes(4); + expect(confirm as Mock).toHaveBeenCalledTimes(5); // Verify export pipeline was invoked for both recipes expect((addEntry as Mock).mock.calls).toHaveLength(2); @@ -344,11 +350,12 @@ describe("tui wizard end-to-end", () => { true, // review confirm true, // export byCategory true, // export proceed + true, // delete session file ]; setupPromptMocks(); - const exitCode = await launchWizard(); + const exitCode = await launchWizard({ store: sessionStore }); expect(exitCode).toBe(0); // Both recipes should be exported @@ -386,7 +393,7 @@ describe("tui wizard end-to-end", () => { return true; }); - const exitCode = await launchWizard(); + const exitCode = await launchWizard({ store: sessionStore }); expect(exitCode).toBe(0); // Verify the error message names both recipe and seed diff --git a/src/tui/__tests__/session-persistence.test.ts b/src/tui/__tests__/session-persistence.test.ts index 3dd7885e..ee50ff99 100644 --- a/src/tui/__tests__/session-persistence.test.ts +++ b/src/tui/__tests__/session-persistence.test.ts @@ -1,63 +1,178 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import * as fs from "node:fs"; -import { detectSessionFile, DEFAULT_SESSION_FILE } from "../session-persistence.js"; +import { describe, it, expect } from "vitest"; +import { + saveSession, + loadSession, + detectSessionFile, + deleteSessionFile, + listBackups, + SESSION_SCHEMA_VERSION, + DEFAULT_SESSION_FILE, + SessionVersionMismatchError, + SessionCorruptedError, +} from "../session-persistence.js"; +import { InMemorySessionStore } from "../session-store.js"; +import type { WizardSessionData, CandidateSelection } from "../types.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// +// Tests inject an in-memory store, so nothing here touches the developer's +// on-disk session files and no test relies on VITEST/NODE_ENV environment +// checks (removed by TF-0MN1QCTMC07ATYMS). +// --------------------------------------------------------------------------- + +const SESSION_DIR = "/virtual/toneforge-sessions"; +const SESSION_PATH = `${SESSION_DIR}/${DEFAULT_SESSION_FILE}`; + +function makeSelection(recipe: string): CandidateSelection { + return { + recipe, + // Persistence only serialises these values; minimal fixtures keep the + // test focused on the store boundary rather than candidate synthesis. + candidate: { + id: `${recipe}_seed-00000`, + recipe, + seed: 0, + } as unknown as CandidateSelection["candidate"], + classification: { + source: `${recipe}_seed-0`, + } as unknown as CandidateSelection["classification"], + }; +} + +function makeSessionData( + overrides: Partial = {}, +): WizardSessionData { + return { + currentStage: "explore", + manifest: { + entries: [ + { + recipe: "card-flip", + description: "A flicked card impact", + category: "card-game", + tags: ["card", "ui"], + }, + ], + }, + selections: new Map([["card-flip", makeSelection("card-flip")]]), + sweepCache: new Map(), + exportDir: "./out", + exportByCategory: true, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- describe("session-persistence: detectSessionFile", () => { - let origVitest: string | undefined; - let origNodeEnv: string | undefined; + it("reports absence through the injected store", async () => { + const store = new InMemorySessionStore(); + expect(await detectSessionFile(SESSION_PATH, store)).toBe(false); + }); - beforeEach(() => { - // Save original env values and avoid mutating global env permanently - origVitest = process.env.VITEST; - origNodeEnv = process.env.NODE_ENV; - vi.restoreAllMocks(); + it("reports presence through the injected store", async () => { + const store = new InMemorySessionStore(); + await store.writeFile(SESSION_PATH, "{}"); + expect(await detectSessionFile(SESSION_PATH, store)).toBe(true); }); +}); - afterEach(() => { - // Restore original environment values - if (origVitest === undefined) delete process.env.VITEST; - else process.env.VITEST = origVitest; +describe("session-persistence: saveSession / loadSession", () => { + it("round-trips session data and drops the sweep cache", async () => { + const store = new InMemorySessionStore(); + const data = makeSessionData({ + sweepCache: new Map([ + ["card-flip", { recipe: "card-flip", candidates: [] }], + ]), + }); - if (origNodeEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = origNodeEnv; + await saveSession(data, SESSION_PATH, store); + const loaded = await loadSession(SESSION_PATH, store); - vi.restoreAllMocks(); + expect(loaded.currentStage).toBe("explore"); + expect(loaded.manifest).toEqual(data.manifest); + expect(loaded.selections.size).toBe(1); + expect(loaded.selections.get("card-flip")).toEqual( + data.selections.get("card-flip"), + ); + expect(loaded.exportDir).toBe("./out"); + expect(loaded.exportByCategory).toBe(true); + // Sweep cache is deliberately not persisted; it re-runs on resume. + expect(loaded.sweepCache.size).toBe(0); }); - it("returns false when running under VITEST env", () => { - process.env.VITEST = "true"; - // Create a session file on disk to ensure existsSync would return true - fs.writeFileSync(DEFAULT_SESSION_FILE, "{}", "utf-8"); + it("throws SessionVersionMismatchError for an unsupported schema version", async () => { + const store = new InMemorySessionStore(); + await store.writeFile( + SESSION_PATH, + JSON.stringify({ + schemaVersion: SESSION_SCHEMA_VERSION + 1, + currentStage: "define", + manifest: { entries: [] }, + selections: {}, + }), + ); - try { - expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(false); - } finally { - fs.unlinkSync(DEFAULT_SESSION_FILE); - } + await expect(loadSession(SESSION_PATH, store)).rejects.toBeInstanceOf( + SessionVersionMismatchError, + ); }); - it("returns false when NODE_ENV is test", () => { - process.env.NODE_ENV = "test"; - fs.writeFileSync(DEFAULT_SESSION_FILE, "{}", "utf-8"); + it("throws SessionCorruptedError for unparseable content", async () => { + const store = new InMemorySessionStore(); + await store.writeFile(SESSION_PATH, "{ not-json"); - try { - expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(false); - } finally { - fs.unlinkSync(DEFAULT_SESSION_FILE); - } + await expect(loadSession(SESSION_PATH, store)).rejects.toBeInstanceOf( + SessionCorruptedError, + ); }); - it("defers to fs.existsSync when not in a test env", () => { - // Ensure env is not indicating a test - delete process.env.VITEST; - delete process.env.NODE_ENV; + it("creates a timestamped backup of the previous file on save", async () => { + const store = new InMemorySessionStore(); + await store.writeFile(SESSION_PATH, "previous"); - fs.writeFileSync(DEFAULT_SESSION_FILE, "{}", "utf-8"); + await saveSession(makeSessionData(), SESSION_PATH, store); - try { - expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(true); - } finally { - fs.unlinkSync(DEFAULT_SESSION_FILE); + const backups = await listBackups(SESSION_PATH, store); + expect(backups).toHaveLength(1); + expect(await store.readFile(backups[0])).toBe("previous"); + }); + + it("prunes backups beyond the retention limit", async () => { + const store = new InMemorySessionStore(); + const base = `${SESSION_DIR}/.toneforge-session`; + await store.writeFile(SESSION_PATH, "main"); + for (const ts of [ + "20000101T000000", + "20000102T000000", + "20000103T000000", + "20000104T000000", + ]) { + await store.writeFile(`${base}.${ts}.json`, ts); } + + await saveSession(makeSessionData(), SESSION_PATH, store); + + const backups = await listBackups(SESSION_PATH, store); + expect(backups).toHaveLength(3); + // The two oldest backups are pruned, older than the seeded newest. + expect(backups.some((p) => p.endsWith("20000104T000000.json"))).toBe(true); + expect(backups.some((p) => p.endsWith("20000101T000000.json"))).toBe(false); + }); +}); + +describe("session-persistence: deleteSessionFile", () => { + it("removes the session file and every backup", async () => { + const store = new InMemorySessionStore(); + const backup = `${SESSION_DIR}/.toneforge-session.20000101T000000.json`; + await store.writeFile(SESSION_PATH, "main"); + await store.writeFile(backup, "backup"); + + await deleteSessionFile(SESSION_PATH, store); + + expect(await store.exists(SESSION_PATH)).toBe(false); + expect(await store.exists(backup)).toBe(false); + expect(await listBackups(SESSION_PATH, store)).toEqual([]); }); }); diff --git a/src/tui/__tests__/session-store.test.ts b/src/tui/__tests__/session-store.test.ts new file mode 100644 index 00000000..ad9e899a --- /dev/null +++ b/src/tui/__tests__/session-store.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + InMemorySessionStore, + NodeFsSessionStore, + type SessionStore, +} from "../session-store.js"; + +interface StoreContext { + store: SessionStore; + dir: string; +} + +/** + * Both adapters must satisfy the same SessionStore contract so tests can + * swap the in-memory double for the real node:fs-backed adapter without + * changing persistence behaviour. + */ +const adapters: Array<{ + name: string; + create: () => Promise; + cleanup?: (ctx: StoreContext) => Promise; +}> = [ + { + name: "NodeFsSessionStore", + create: async () => ({ + store: new NodeFsSessionStore(), + dir: await mkdtemp(join(tmpdir(), "toneforge-session-store-")), + }), + cleanup: async (ctx) => { + await rm(ctx.dir, { recursive: true, force: true }); + }, + }, + { + name: "InMemorySessionStore", + create: async () => ({ + store: new InMemorySessionStore(), + dir: "/virtual/toneforge-sessions", + }), + }, +]; + +for (const { name, create, cleanup } of adapters) { + describe(`${name} implements the SessionStore contract`, () => { + let ctx: StoreContext; + + beforeEach(async () => { + ctx = await create(); + }); + + afterEach(async () => { + if (cleanup) await cleanup(ctx); + }); + + it("writes and reads back UTF-8 content", async () => { + const file = join(ctx.dir, "session.json"); + await ctx.store.writeFile(file, '{"hello":"world"}'); + expect(await ctx.store.readFile(file)).toBe('{"hello":"world"}'); + }); + + it("reports existence and removes files", async () => { + const file = join(ctx.dir, "gone.json"); + expect(await ctx.store.exists(file)).toBe(false); + await ctx.store.writeFile(file, "x"); + expect(await ctx.store.exists(file)).toBe(true); + await ctx.store.unlink(file); + expect(await ctx.store.exists(file)).toBe(false); + }); + + it("rejects readFile for a missing file", async () => { + await expect( + ctx.store.readFile(join(ctx.dir, "missing.json")), + ).rejects.toThrow(); + }); + + it("rejects unlink for a missing file", async () => { + await expect( + ctx.store.unlink(join(ctx.dir, "missing.json")), + ).rejects.toThrow(); + }); + + it("copies a file to a new destination", async () => { + const src = join(ctx.dir, "src.json"); + const dest = join(ctx.dir, "dest.json"); + await ctx.store.writeFile(src, "payload"); + await ctx.store.copyFile(src, dest); + expect(await ctx.store.readFile(dest)).toBe("payload"); + }); + + it("lists directory entries", async () => { + await ctx.store.writeFile(join(ctx.dir, "a.json"), "a"); + await ctx.store.writeFile(join(ctx.dir, "b.json"), "b"); + expect((await ctx.store.readdir(ctx.dir)).sort()).toEqual([ + "a.json", + "b.json", + ]); + }); + }); +} diff --git a/src/tui/index.ts b/src/tui/index.ts index 7f81f4fc..40c5d106 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -32,9 +32,11 @@ import { detectSessionFile, deleteSessionFile, DEFAULT_SESSION_FILE, + defaultSessionStore, SessionVersionMismatchError, SessionCorruptedError, } from "./session-persistence.js"; +import type { SessionStore } from "./session-store.js"; /** Stage display names for user-facing output. */ const STAGE_NAMES: Record = { @@ -53,6 +55,12 @@ export interface LaunchWizardOptions { /** Custom path for the auto-save session file. */ sessionFile?: string; + + /** + * Injectable persistence store. Defaults to the `node:fs/promises`-backed + * `NodeFsSessionStore`; tests inject an in-memory store for hermetic runs. + */ + store?: SessionStore; } /** @@ -81,21 +89,22 @@ export async function launchWizard( // Install Ctrl+C cleanup handler installCleanupHandler(); - // Determine session file path + // Determine session file path and persistence store const sessionFilePath = options.resume ?? options.sessionFile ?? DEFAULT_SESSION_FILE; + const store = options.store ?? defaultSessionStore; // Session resume or fresh start let session: WizardSession; if (options.resume) { // Explicit --resume flag: load from specified path - const resumeResult = await tryResumeSession(options.resume); + const resumeResult = await tryResumeSession(options.resume, store); if (resumeResult.exitCode !== undefined) return resumeResult.exitCode; session = resumeResult.session!; outputInfo(`\nResumed session from ${options.resume}\n`); - } else if (detectSessionFile(sessionFilePath)) { + } else if (await detectSessionFile(sessionFilePath, store)) { // Auto-detect existing session file - const resumeResult = await handleExistingSession(sessionFilePath); + const resumeResult = await handleExistingSession(sessionFilePath, store); if (resumeResult.exitCode !== undefined) return resumeResult.exitCode; session = resumeResult.session!; } else { @@ -140,7 +149,7 @@ export async function launchWizard( return 0; } session.advance(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } @@ -148,11 +157,11 @@ export async function launchWizard( const exploreResult = await runExploreStage(session); if (exploreResult === "back") { session.goBack(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } session.advance(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } @@ -160,11 +169,11 @@ export async function launchWizard( const reviewResult = await runReviewStage(session); if (reviewResult === "back") { session.goBack(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } session.advance(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } @@ -172,11 +181,11 @@ export async function launchWizard( const exportResult = await runExportStage(session); if (exportResult === "back") { session.goBack(); - await autoSave(session, sessionFilePath); + await autoSave(session, sessionFilePath, store); break; } // Export complete -- prompt to delete session file - await promptDeleteSession(sessionFilePath); + await promptDeleteSession(sessionFilePath, store); outputInfo("\nThank you for using ToneForge Sound Palette Builder!\n"); return 0; } @@ -209,14 +218,17 @@ interface ResumeResult { * Attempt to load a session from a file path. * Returns the restored session or an exit code on failure. */ -async function tryResumeSession(filePath: string): Promise { - if (!detectSessionFile(filePath)) { +async function tryResumeSession( + filePath: string, + store: SessionStore, +): Promise { + if (!(await detectSessionFile(filePath, store))) { outputError(`Session file not found: ${filePath}\n`); return { exitCode: 1 }; } try { - const data = await loadSession(filePath); + const data = await loadSession(filePath, store); const session = WizardSession.fromData(data); return { session }; } catch (err) { @@ -240,12 +252,13 @@ async function tryResumeSession(filePath: string): Promise { */ async function handleExistingSession( filePath: string, + store: SessionStore, ): Promise { outputInfo(`\nExisting session file detected: ${filePath}\n`); let data; try { - data = await loadSession(filePath); + data = await loadSession(filePath, store); } catch (err) { if (err instanceof SessionVersionMismatchError) { outputWarning(`\n${err.message}\n`); @@ -284,7 +297,7 @@ async function handleExistingSession( // Starting fresh -- back up the old file via saveSession (which creates a backup) outputInfo("Archiving existing session and starting fresh.\n"); const freshSession = new WizardSession(); - await saveSession(freshSession.toData(), filePath); + await saveSession(freshSession.toData(), filePath, store); return { session: freshSession }; } @@ -299,9 +312,10 @@ async function handleExistingSession( async function autoSave( session: WizardSession, filePath: string, + store: SessionStore, ): Promise { try { - await saveSession(session.toData(), filePath); + await saveSession(session.toData(), filePath, store); } catch (err) { const message = err instanceof Error ? err.message : String(err); outputWarning(`Warning: Failed to auto-save session: ${message}\n`); @@ -311,8 +325,11 @@ async function autoSave( /** * After successful export, prompt the user to delete the session file. */ -async function promptDeleteSession(filePath: string): Promise { - if (!detectSessionFile(filePath)) return; +async function promptDeleteSession( + filePath: string, + store: SessionStore, +): Promise { + if (!(await detectSessionFile(filePath, store))) return; try { const shouldDelete = await confirm({ @@ -321,7 +338,7 @@ async function promptDeleteSession(filePath: string): Promise { }); if (shouldDelete) { - await deleteSessionFile(filePath); + await deleteSessionFile(filePath, store); outputInfo("Session file and backups deleted.\n"); } else { outputInfo("Session file retained.\n"); diff --git a/src/tui/session-persistence.ts b/src/tui/session-persistence.ts index 59e95a9b..0bac37ee 100644 --- a/src/tui/session-persistence.ts +++ b/src/tui/session-persistence.ts @@ -15,10 +15,9 @@ * Reference: TF-0MM8S3BDR1QMN6TZ (Session Save/Resume) */ -import { writeFile, readFile, copyFile, readdir, unlink } from "node:fs/promises"; -import { existsSync } from "node:fs"; import { dirname, basename, resolve } from "node:path"; import type { WizardSessionData, CandidateSelection } from "./types.js"; +import { NodeFsSessionStore, type SessionStore } from "./session-store.js"; /** Current schema version for session files. */ export const SESSION_SCHEMA_VERSION = 1; @@ -26,6 +25,14 @@ export const SESSION_SCHEMA_VERSION = 1; /** Default session file name. */ export const DEFAULT_SESSION_FILE = ".toneforge-session.json"; +/** + * Default file store used when no store is injected. + * + * Production callers rely on this `node:fs/promises`-backed implementation; + * tests inject an in-memory store instead for hermetic runs. + */ +export const defaultSessionStore: SessionStore = new NodeFsSessionStore(); + /** Maximum number of backup files to retain. */ const MAX_BACKUPS = 3; @@ -148,19 +155,22 @@ function deserializeSession(envelope: SessionFileEnvelope): WizardSessionData { * * @param data - The session data to persist. * @param filePath - Path to the session file. Defaults to `.toneforge-session.json` in cwd. + * @param store - Persistence store to write through. Defaults to the + * `node:fs/promises`-backed store. */ export async function saveSession( data: WizardSessionData, filePath: string = DEFAULT_SESSION_FILE, + store: SessionStore = defaultSessionStore, ): Promise { // Back up existing file before overwriting - if (existsSync(filePath)) { - await createBackup(filePath); + if (await store.exists(filePath)) { + await createBackup(filePath, store); } const envelope = serializeSession(data); const content = JSON.stringify(envelope, null, 2); - await writeFile(filePath, content, "utf-8"); + await store.writeFile(filePath, content); } /** @@ -169,16 +179,19 @@ export async function saveSession( * Validates the schema version and handles corrupted files gracefully. * * @param filePath - Path to the session file. + * @param store - Persistence store to read through. Defaults to the + * `node:fs/promises`-backed store. * @returns The deserialized WizardSessionData. * @throws SessionVersionMismatchError if the schema version does not match. * @throws SessionCorruptedError if the file cannot be parsed. */ export async function loadSession( filePath: string = DEFAULT_SESSION_FILE, + store: SessionStore = defaultSessionStore, ): Promise { let content: string; try { - content = await readFile(filePath, "utf-8"); + content = await store.readFile(filePath); } catch (err) { throw new SessionCorruptedError( filePath, @@ -224,39 +237,37 @@ export async function loadSession( * Check if a session file exists at the given path. * * @param filePath - Path to check. Defaults to `.toneforge-session.json` in cwd. + * @param store - Persistence store to query. Defaults to the + * `node:fs/promises`-backed store. * @returns true if the file exists. */ -export function detectSessionFile( +export async function detectSessionFile( filePath: string = DEFAULT_SESSION_FILE, -): boolean { - // During test runs (Vitest/NODE_ENV=test) we avoid interacting with a - // developer's on-disk session file to keep tests hermetic and deterministic. - // Tests set `VITEST=true` in the environment; respect that and treat the - // session file as absent so the TUI does not prompt to resume or delete it. - if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") { - return false; - } - - return existsSync(filePath); + store: SessionStore = defaultSessionStore, +): Promise { + return store.exists(filePath); } /** * Delete the session file and all its backups. * * @param filePath - Path to the session file. Defaults to `.toneforge-session.json` in cwd. + * @param store - Persistence store to delete through. Defaults to the + * `node:fs/promises`-backed store. */ export async function deleteSessionFile( filePath: string = DEFAULT_SESSION_FILE, + store: SessionStore = defaultSessionStore, ): Promise { // Delete the main file - if (existsSync(filePath)) { - await unlink(filePath); + if (await store.exists(filePath)) { + await store.unlink(filePath); } // Delete all backups - const backups = await listBackups(filePath); + const backups = await listBackups(filePath, store); for (const backup of backups) { - await unlink(backup); + await store.unlink(backup); } } @@ -285,13 +296,16 @@ function backupPath(filePath: string): string { * * Backup files match the pattern: `..json` */ -export async function listBackups(filePath: string): Promise { +export async function listBackups( + filePath: string, + store: SessionStore = defaultSessionStore, +): Promise { const dir = dirname(resolve(filePath)); const base = basename(filePath, ".json"); - if (!existsSync(dir)) return []; + if (!(await store.exists(dir))) return []; - const entries = await readdir(dir); + const entries = await store.readdir(dir); // Match pattern: ..json (but not the main file) const mainName = basename(filePath); @@ -308,22 +322,28 @@ export async function listBackups(filePath: string): Promise { /** * Create a timestamped backup of the session file and prune old backups. */ -async function createBackup(filePath: string): Promise { +async function createBackup( + filePath: string, + store: SessionStore, +): Promise { const dest = backupPath(filePath); - await copyFile(filePath, dest); - await pruneBackups(filePath); + await store.copyFile(filePath, dest); + await pruneBackups(filePath, store); } /** * Remove old backups beyond MAX_BACKUPS, keeping the newest ones. */ -async function pruneBackups(filePath: string): Promise { - const backups = await listBackups(filePath); +async function pruneBackups( + filePath: string, + store: SessionStore, +): Promise { + const backups = await listBackups(filePath, store); // backups is sorted oldest first; remove the oldest ones while (backups.length > MAX_BACKUPS) { const oldest = backups.shift()!; - await unlink(oldest); + await store.unlink(oldest); } } diff --git a/src/tui/session-store.ts b/src/tui/session-store.ts new file mode 100644 index 00000000..9ffb1c78 --- /dev/null +++ b/src/tui/session-store.ts @@ -0,0 +1,159 @@ +/** + * Session persistence store abstraction. + * + * Decouples TUI session persistence from `node:fs` so callers and tests can + * swap implementations — for example an in-memory store for hermetic tests or + * a future remote store — without environment sniffing or touching disk. + * + * Reference: TF-0MN1QCTMC07ATYMS (Introduce SessionStore DI for TUI session + * persistence). + */ + +import { + copyFile, + readdir, + readFile, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { resolve } from "node:path"; + +/** + * Minimal asynchronous file operations required by session persistence. + * + * Every method is asynchronous so the adapter can be backed by + * `node:fs/promises`, an in-memory map, or a remote service. + */ +export interface SessionStore { + /** Read a file as UTF-8 text. Rejects when the file does not exist. */ + readFile(filePath: string): Promise; + + /** Write UTF-8 text to a file, creating or truncating it. */ + writeFile(filePath: string, content: string): Promise; + + /** Resolve to true when the path exists, false otherwise. */ + exists(filePath: string): Promise; + + /** Remove a file. Rejects when the file does not exist. */ + unlink(filePath: string): Promise; + + /** Copy a file from `source` to `destination`. */ + copyFile(source: string, destination: string): Promise; + + /** List the entry names directly contained in a directory. */ + readdir(dirPath: string): Promise; +} + +/** + * Default SessionStore backed by `node:fs/promises`. + * + * Used in production by the TUI wizard; tests inject an in-memory + * implementation instead so they never read or write a developer's session + * file. + */ +export class NodeFsSessionStore implements SessionStore { + async readFile(filePath: string): Promise { + return readFile(filePath, "utf-8"); + } + + async writeFile(filePath: string, content: string): Promise { + await writeFile(filePath, content, "utf-8"); + } + + async exists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } + } + + async unlink(filePath: string): Promise { + await unlink(filePath); + } + + async copyFile(source: string, destination: string): Promise { + await copyFile(source, destination); + } + + async readdir(dirPath: string): Promise { + return readdir(dirPath); + } +} + +/** + * In-memory SessionStore for hermetic tests. + * + * Keeps every path (normalised to absolute) and its UTF-8 content in a Map so + * tests exercise the real persistence logic without touching disk. Directory + * existence and listing are derived from the stored file paths. + */ +export class InMemorySessionStore implements SessionStore { + private readonly files = new Map(); + + private static normalise(filePath: string): string { + return resolve(filePath); + } + + private static enoent(operation: string, filePath: string): NodeJS.ErrnoException { + const err = new Error( + `ENOENT: no such file or directory, ${operation} '${filePath}'`, + ) as NodeJS.ErrnoException; + err.code = "ENOENT"; + return err; + } + + async readFile(filePath: string): Promise { + const key = InMemorySessionStore.normalise(filePath); + const content = this.files.get(key); + if (content === undefined) { + throw InMemorySessionStore.enoent("open", filePath); + } + return content; + } + + async writeFile(filePath: string, content: string): Promise { + this.files.set(InMemorySessionStore.normalise(filePath), content); + } + + async exists(filePath: string): Promise { + const key = InMemorySessionStore.normalise(filePath); + if (this.files.has(key)) return true; + // Treat a path as an existing directory when it is a prefix of any stored + // file, mirroring `stat()` on a real directory. + const prefix = key.endsWith("/") ? key : `${key}/`; + for (const stored of this.files.keys()) { + if (stored.startsWith(prefix)) return true; + } + return false; + } + + async unlink(filePath: string): Promise { + const key = InMemorySessionStore.normalise(filePath); + if (!this.files.delete(key)) { + throw InMemorySessionStore.enoent("unlink", filePath); + } + } + + async copyFile(source: string, destination: string): Promise { + const content = await this.readFile(source); + this.files.set(InMemorySessionStore.normalise(destination), content); + } + + async readdir(dirPath: string): Promise { + const key = InMemorySessionStore.normalise(dirPath); + const prefix = key.endsWith("/") ? key : `${key}/`; + const names = new Set(); + for (const stored of this.files.keys()) { + if (!stored.startsWith(prefix)) continue; + const name = stored.slice(prefix.length).split("/")[0]; + if (name) names.add(name); + } + if (names.size === 0 && !(await this.exists(key))) { + throw InMemorySessionStore.enoent("scandir", dirPath); + } + return [...names]; + } +} diff --git a/test/repo-hygiene.test.ts b/test/repo-hygiene.test.ts new file mode 100644 index 00000000..947e62d7 --- /dev/null +++ b/test/repo-hygiene.test.ts @@ -0,0 +1,70 @@ +/** + * Repository hygiene guards (not part of the shipped TypeScript build). + * + * These tests protect against accidentally committing local development + * artefacts that break every fresh checkout. They run in the root Vitest + * project (Node) and are excluded from `tsc` because `tsconfig.json` only + * includes `src/**`. + * + * See the incident behind TF-0MUUEZE48002CLBX: a self-referential + * `node_modules` symlink was committed to `dev` because the `.gitignore` + * rule `node_modules/` does not match a *symlink* named `node_modules`. + */ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, readlinkSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(__dirname, ".."); + +function gitTrackedPaths(): string[] { + const output = execFileSync("git", ["ls-files"], { + cwd: repoRoot, + encoding: "utf-8", + }); + return output.split("\n").filter((line) => line.length > 0); +} + +describe("repository hygiene", () => { + it("never tracks a node_modules path", () => { + const tracked = gitTrackedPaths(); + const offenders = tracked.filter( + (path) => path === "node_modules" || path.includes("node_modules/"), + ); + expect( + offenders, + `Tracked node_modules paths break fresh checkouts: ${offenders.join(", ")}`, + ).toEqual([]); + }); + + it("ignores node_modules even when it is a symlink", () => { + // `node_modules/` only matches a real directory. A symlink named + // `node_modules` must also be ignored, otherwise cleanup tooling can + // commit it. `git check-ignore` exits 0 when the path is ignored. + const result = execFileSync( + "git", + ["check-ignore", "--no-index", "node_modules"], + { cwd: repoRoot, encoding: "utf-8" }, + ); + expect(result.trim()).toContain("node_modules"); + }); + + it("does not track the implement-session state file", () => { + const tracked = gitTrackedPaths(); + expect(tracked).not.toContain(".implement_state.json"); + }); + + it("keeps node_modules out of the working tree index as a symlink loop", () => { + // If a node_modules symlink exists on disk it must not point at itself. + const path = resolve(repoRoot, "node_modules"); + if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { + return; // no symlink present (fresh clone, real dir, or absent) + } + const target = readlinkSync(path); + const resolved = resolve(repoRoot, target.startsWith("/") ? target : resolve(repoRoot, "..", target)); + expect( + resolved, + "node_modules must not be a self-referential symlink", + ).not.toBe(path); + }); +}); diff --git a/test/setup-init-recipes.ts b/test/setup-init-recipes.ts new file mode 100644 index 00000000..453c95c7 --- /dev/null +++ b/test/setup-init-recipes.ts @@ -0,0 +1,12 @@ +/** + * Vitest setup: initialize the recipe registry with file-backed recipes. + * + * The shared registry no longer performs file-backed discovery via a + * top-level `await` (browser build targets reject top-level await). Node test + * suites that assert against the shared registry must therefore await + * `initializeRecipeRegistry()` so file-backed ToneGraph recipes are registered, + * matching production CLI behaviour. + */ +import { initializeRecipeRegistry } from "../src/recipes/index.js"; + +await initializeRecipeRegistry(); diff --git a/vitest.config.ts b/vitest.config.ts index 13ebe9cb..76cdd202 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,6 @@ export default defineConfig({ ], // Vitest resolves setupFiles relative to the project root — use a plain // repository-relative path so resolution works in all environments. - setupFiles: ["test/setup-reset-globals.ts"], + setupFiles: ["test/setup-init-recipes.ts", "test/setup-reset-globals.ts"], }, }); diff --git a/web/README.md b/web/README.md index 23c97756..e07b645c 100644 --- a/web/README.md +++ b/web/README.md @@ -81,6 +81,34 @@ podman run -p 3000:3000 -e ALLOWED_ORIGINS="mydomain.com,localhost" toneforge-we | `ALLOWED_ORIGINS` | `localhost,127.0.0.1` | Comma-separated list of allowed Origin hostnames | | `VITE_PORT` | `5173` | Vite dev server port (development only) | +## Testing + +Web tests run with Vitest (`npm test --prefix web`). The default environment is +Node, which is what the existing server/wizard/audio tests expect. SoundEditor +component tests live under `src/components/SoundEditor/**` and opt into a DOM +environment per file with the directive: + +```ts +// @vitest-environment happy-dom +``` + +Shared SoundEditor scaffolding lives under `test/`: + +- `test/fixtures/editor-fixtures.ts` — representative `{ recipe, seed, overrides, + descriptors }` cases (oscillator, noise/filter and file-backed ToneGraph), + with parameter descriptors read from the shared recipe registry. +- `test/helpers/determinism.ts` — renders a preset-shaped input twice through + the existing offline render path and reports/asserts byte-identical + `Float32Array` output (`assertDeterministic`, `renderPresetTwice`, + `compareSamples`). + +> The offline render path does not yet accept parameter overrides (tracked by +> TF-0MUV11RXS003Y6JI); the determinism helper threads `overrides` through a +> forward-compatible seam that becomes effective when that work lands. + +Because the server tests serve the built SPA, run `npm run build --prefix web` +once in a fresh checkout/worktree before `npm test --prefix web`. + ## Architecture ``` diff --git a/web/e2e/fixtures/toneforge-browser-harness.ts b/web/e2e/fixtures/toneforge-browser-harness.ts new file mode 100644 index 00000000..45124c2f --- /dev/null +++ b/web/e2e/fixtures/toneforge-browser-harness.ts @@ -0,0 +1,234 @@ +/** + * Browser e2e harness for ToneForge Runtime and recipes. + * + * This module is NOT shipped in the web app build. The Playwright spec + * (`web/e2e/runtime-recipes.spec.ts`) bundles it with esbuild and injects the + * resulting IIFE into the real browser page, where it exposes + * `globalThis.__tfHarness`. Each scenario returns plain, serialisable data so + * assertions run in Node. + * + * Work item: TF-0MUUPJ95Z001OHEN + */ + +import { createRuntime } from "../../../src/runtime/index.js"; +import { createStateMachine } from "../../../src/state/state.js"; +import type { StateMachineDefinition } from "../../../src/state/state.js"; +import { createContext } from "../../../src/context/context.js"; +import { parseSequencePreset } from "../../../src/sequence/schema.js"; +import type { SequenceDefinition } from "../../../src/sequence/schema.js"; +import { renderRecipe } from "../../../src/core/renderer.js"; + +// ── Fixtures ────────────────────────────────────────────────────── + +function makeClock(startMs = 1000): { now: () => number; advance: (ms: number) => void } { + let time = startMs; + return { + now: () => time, + advance: (ms: number) => { + time += ms; + }, + }; +} + +function movementDef(): StateMachineDefinition { + return { + name: "movement", + states: [ + { name: "idle" }, + { name: "walk", sequencer: "footsteps_walk" }, + { name: "run", sequencer: "footsteps_run" }, + ], + initial: "idle", + transitions: [ + { from: "idle", to: "walk" }, + { from: "walk", to: "idle" }, + { from: "walk", to: "run" }, + { from: "run", to: "walk" }, + ], + }; +} + +function sequence(name: string, eventCount: number): SequenceDefinition { + return parseSequencePreset( + { + version: "1.0", + name, + events: Array.from({ length: eventCount }, (_, i) => ({ + time: i * 0.3, + event: "footstep", + seedOffset: i, + gain: 0.7, + })), + }, + name, + ); +} + +/** FNV-1a checksum over quantised samples — cheap and stable in JS. */ +function checksum(samples: Float32Array): number { + let hash = 2166136261; + for (let i = 0; i < samples.length; i += 1) { + const quantised = Math.round((samples[i] ?? 0) * 32767); + hash ^= quantised & 0xffff; + hash = Math.imul(hash, 16777619) >>> 0; + } + return hash >>> 0; +} + +function hasSignal(samples: Float32Array): boolean { + for (let i = 0; i < samples.length; i += 1) { + if (samples[i] !== 0) return true; + } + return false; +} + +// ── Scenarios ───────────────────────────────────────────────────── + +function lifecycle() { + const runtime = createRuntime({ seed: 42, clock: makeClock().now }); + const notified: string[] = []; + runtime.onEvent((entry) => notified.push(entry.event.type)); + + const sessionId = runtime.start(); + const runningAfterStart = runtime.isRunning(); + runtime.stop(); + + return { + sessionId, + runningAfterStart, + runningAfterStop: runtime.isRunning(), + loggedTypes: runtime.log().map((entry) => entry.event.type), + notified, + }; +} + +function stateMachineScenario() { + const clock = makeClock(); + const runtime = createRuntime({ + seed: 7, + clock: clock.now, + stateMachine: createStateMachine(movementDef()), + sequences: { + footsteps_walk: sequence("footsteps_walk", 2), + footsteps_run: sequence("footsteps_run", 3), + }, + }); + + runtime.start(); + clock.advance(100); + const transition = runtime.setState("walk"); + clock.advance(200); + runtime.setState("run"); + + const inspection = runtime.inspect(); + const log = runtime.log().map((entry) => entry.event); + + runtime.stop(); + + return { + from: transition.from, + to: transition.to, + currentState: inspection.state?.currentState, + transitionCount: inspection.state?.transitions.length, + activeSequences: inspection.activeSequences, + eventTypes: log.map((event) => event.type), + startedSequences: log + .filter((event) => event.type === "sequence_start") + .map((event) => event.detail.sequence), + resolvedRecipes: log + .filter((event) => event.type === "event_fire") + .map((event) => event.detail.resolvedRecipe), + }; +} + +function contextScenario() { + const runtime = createRuntime({ + seed: 1, + context: createContext({ + dimensions: { surface: ["stone", "gravel"] }, + initial: { surface: "stone" }, + }), + }); + + runtime.start(); + const changes = runtime.setContext({ surface: "gravel" }); + const snapshot = runtime.inspect().context; + const eventTypes = runtime.log().map((entry) => entry.event.type); + runtime.stop(); + + return { + changeCount: changes.length, + snapshot, + eventTypes, + }; +} + +function determinismScenario() { + const run = () => { + const clock = makeClock(); + const runtime = createRuntime({ + seed: 99, + clock: clock.now, + stateMachine: createStateMachine(movementDef(), { clock: clock.now }), + sequences: { + footsteps_walk: sequence("footsteps_walk", 2), + footsteps_run: sequence("footsteps_run", 3), + }, + }); + runtime.start(); + clock.advance(50); + runtime.setState("walk"); + clock.advance(120); + runtime.setState("run"); + runtime.stop(); + return runtime.log().map((entry) => ({ + type: entry.event.type, + id: entry.event.id, + timestamp: entry.event.timestamp, + detail: entry.event.detail, + })); + }; + + const first = run(); + const second = run(); + return { first, second, equal: JSON.stringify(first) === JSON.stringify(second) }; +} + +async function renderRecipesScenario() { + const recipes = ["footstep-stone", "impact-crack"] as const; + const results: Array> = []; + + for (const name of recipes) { + const first = await renderRecipe(name, 42); + const second = await renderRecipe(name, 42); + const other = await renderRecipe(name, 43); + + results.push({ + name, + length: first.samples.length, + sampleRate: first.sampleRate, + nonSilent: hasSignal(first.samples), + deterministic: checksum(first.samples) === checksum(second.samples), + seedVaries: checksum(first.samples) !== checksum(other.samples), + }); + } + + return { results }; +} + +const scenarios: Record unknown | Promise> = { + lifecycle, + stateMachine: stateMachineScenario, + context: contextScenario, + determinism: determinismScenario, + renderRecipes: renderRecipesScenario, +}; + +(globalThis as unknown as { __tfHarness: unknown }).__tfHarness = { + scenarios: Object.keys(scenarios), + run(name: string) { + const scenario = scenarios[name]; + if (!scenario) throw new Error(`Unknown harness scenario: ${name}`); + return scenario(); + }, +}; diff --git a/web/e2e/helpers/diagnostics.ts b/web/e2e/helpers/diagnostics.ts new file mode 100644 index 00000000..846cdf5c --- /dev/null +++ b/web/e2e/helpers/diagnostics.ts @@ -0,0 +1,341 @@ +/** + * Shared Playwright e2e diagnostics. + * + * The failure helpers in this module attach the evidence needed to name the + * *true* root cause of a timeout: + * + * - browser console messages, so a missing recipe surfaces the + * `Unknown recipe ""` warning emitted by `web/src/audio.ts`; and + * - shell errors from the PTY buffer, so a wait that ends in + * `toneforge: command not found` reports that error instead of a generic + * "Timed out waiting for terminal text". + * + * The pure helpers (no Playwright dependency) are unit-tested in + * `web/test/diagnostics.test.ts`; the `waitFor*` helpers inject the Playwright + * `Page` so the module stays import-safe outside a Playwright run. + * + * Work item: TF-0MUVK0K230013UC3. + */ +import type { Page } from "@playwright/test"; + +export interface ConsoleRecord { + /** Playwright console message type, e.g. `log`, `warning`, `error`. */ + type: string; + /** Rendered message text. */ + text: string; +} + +export interface ConsoleCapture { + /** Messages collected so far, in arrival order. */ + records: ConsoleRecord[]; + /** Forget everything collected so far (used to scope diagnostics to a step). */ + clear(): void; +} + +/** + * Shell error signatures that mean an expected command never ran. + * + * Kept deliberately specific to avoid matching ordinary output: a bare + * "not found" only counts when it follows the shell's `command:` prefix + * (bash) or appears as `sh: 1: : not found` (dash). + */ +const SHELL_ERROR_PATTERNS: RegExp[] = [ + /\bcommand not found\b/i, + /(?:^|:\s+)[^\s:]+:\s+not found\b/i, + /\bno such file or directory\b/i, + /\bpermission denied\b/i, + /\bcannot execute\b/i, +]; + +/** `Unknown recipe ""` warning emitted by the browser audio layer. */ +const UNKNOWN_RECIPE_PATTERN = /unknown recipe/i; + +/** Matches ANSI SGR escape sequences (e.g. `\x1b[36m`) emitted by xterm. */ +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; + +/** Strip ANSI colour codes and surrounding whitespace from a terminal line. */ +export function stripAnsi(line: string): string { + return line.replace(ANSI_ESCAPE_PATTERN, "").trim(); +} + +/** True when a single terminal line is a shell error worth reporting. */ +export function isShellErrorLine(line: string): boolean { + const clean = stripAnsi(line); + if (!clean) return false; + return SHELL_ERROR_PATTERNS.some((pattern) => pattern.test(clean)); +} + +/** + * Extract the distinct shell error lines from a terminal buffer. + * + * The PTY echoes the command, its output and the next prompt into the same + * buffer; this narrows the buffer down to the lines that explain a failure. + */ +export function extractShellErrors(terminalText: string): string[] { + const seen = new Set(); + const errors: string[] = []; + for (const rawLine of terminalText.split(/\r?\n/)) { + const line = stripAnsi(rawLine); + if (!line || !isShellErrorLine(line) || seen.has(line)) continue; + seen.add(line); + errors.push(line); + } + return errors; +} + +/** The `Unknown recipe` warnings present in a console capture. */ +export function findUnknownRecipeWarnings(records: ConsoleRecord[]): string[] { + return records + .filter((record) => UNKNOWN_RECIPE_PATTERN.test(record.text)) + .map((record) => record.text); +} + +/** + * The console messages worth attaching to a failure: warnings, errors and any + * warning naming an unresolved recipe (which is otherwise easy to miss). + */ +export function selectDiagnosticConsole(records: ConsoleRecord[]): ConsoleRecord[] { + return records.filter( + (record) => + record.type === "warning" || + record.type === "error" || + record.type === "pageerror" || + UNKNOWN_RECIPE_PATTERN.test(record.text), + ); +} + +export interface TimeoutDiagnostics { + /** Human description of what the wait expected. */ + expected: string; + /** Timeout in milliseconds. */ + timeoutMs: number; + /** Last observed terminal output (empty when not terminal-based). */ + lastOutput: string; + /** Browser console capture, when available. */ + consoleRecords?: ConsoleRecord[]; + /** Pre-computed shell errors (recomputed from `lastOutput` when omitted). */ + shellErrors?: string[]; + /** One-line explanation of the most likely root cause. */ + hint?: string; +} + +/** + * Build a timeout message that names the root cause instead of printing a + * generic "timed out" line. Shell errors and the `Unknown recipe` warning are + * highlighted separately because they point straight at the broken step. + */ +export function formatTimeoutDiagnostics(options: TimeoutDiagnostics): string { + const lines: string[] = [ + `Timed out after ${options.timeoutMs}ms waiting for: ${options.expected}`, + ]; + if (options.hint) { + lines.push(options.hint); + } + + const shellErrors = + options.shellErrors?.length + ? options.shellErrors + : extractShellErrors(options.lastOutput); + if (shellErrors.length > 0) { + lines.push(""); + lines.push( + "Shell error(s) detected in the terminal — this is the likely root cause:", + ); + for (const error of shellErrors) { + lines.push(` $ ${error}`); + } + } + + const consoleRecords = options.consoleRecords ?? []; + const unknownRecipes = findUnknownRecipeWarnings(consoleRecords); + if (unknownRecipes.length > 0) { + lines.push(""); + lines.push( + "Browser console reported an unresolved recipe — the registry did not resolve it:", + ); + for (const warning of unknownRecipes) { + lines.push(` ! ${warning}`); + } + } + + const relevantConsole = selectDiagnosticConsole(consoleRecords); + if (relevantConsole.length > 0) { + lines.push(""); + lines.push("Browser console (warnings/errors):"); + for (const record of relevantConsole) { + lines.push(` [${record.type}] ${record.text}`); + } + } + + if (options.lastOutput) { + lines.push(""); + lines.push(`Last terminal output (${options.lastOutput.length} chars):`); + lines.push(options.lastOutput.slice(0, 2000)); + } + + return lines.join("\n"); +} + +/** + * Start capturing browser console messages and uncaught page errors. + * + * Call once per page before navigation so warnings emitted during + * initialisation are recorded. + */ +export function captureConsole(page: Page): ConsoleCapture { + const records: ConsoleRecord[] = []; + page.on("console", (message) => { + records.push({ type: message.type(), text: message.text() }); + }); + page.on("pageerror", (error) => { + records.push({ type: "pageerror", text: error.message }); + }); + return { + records, + clear() { + records.length = 0; + }, + }; +} + +/** + * Read the visible text from the xterm.js terminal. + * + * xterm exposes two views of the same buffer: the accessibility tree (used by + * screen readers, and the only text source when the canvas renderer is active) + * and the DOM-rendered `.xterm-rows`. Either view can lag or hold only a slice + * of the scrollback — notably the accessibility tree can drop earlier rows + * when the shell prompt wraps a long working directory — so both are read and + * merged. Merging keeps `includes(expected)` truthful regardless of renderer. + */ +export async function getTerminalText(page: Page): Promise { + return page.evaluate(() => { + const readRows = (selector: string): string => + Array.from(document.querySelectorAll(selector)) + .map((row) => row.textContent ?? "") + .join("\n"); + + const accessibility = readRows( + ".xterm-accessibility .xterm-accessibility-tree div", + ); + const renderedRows = readRows(".xterm-rows > div"); + + if (accessibility && renderedRows) { + return accessibility.includes(renderedRows) + ? accessibility + : `${accessibility}\n${renderedRows}`; + } + return accessibility || renderedRows; + }); +} + +/** + * Wait until the terminal contains `expected`. + * + * Fails fast when the buffer already contains a shell error (e.g. + * `command not found`): that error — not the missing text — is the root cause, + * so it is reported explicitly. + */ +export async function waitForTerminalText( + page: Page, + expected: string, + timeoutMs: number, + capture?: ConsoleCapture, +): Promise { + const start = Date.now(); + let last = ""; + while (Date.now() - start < timeoutMs) { + last = await getTerminalText(page); + if (last.includes(expected)) { + return last; + } + + const shellErrors = extractShellErrors(last); + if (shellErrors.length > 0) { + throw new Error( + formatTimeoutDiagnostics({ + expected: `terminal text: ${expected}`, + timeoutMs, + lastOutput: last, + consoleRecords: capture?.records, + shellErrors, + hint: "The PTY reported a shell error before the expected text appeared.", + }), + ); + } + await page.waitForTimeout(250); + } + + throw new Error( + formatTimeoutDiagnostics({ + expected: `terminal text: ${expected}`, + timeoutMs, + lastOutput: last, + consoleRecords: capture?.records, + }), + ); +} + +/** + * Read the length of the last buffer rendered by the patched + * `OfflineAudioContext.startRendering` (installed by the smoke spec). + */ +async function readRenderedBufferLength(page: Page): Promise { + return page.evaluate(() => { + const value = (globalThis as { __tfLastRenderedLength?: unknown }) + .__tfLastRenderedLength; + return typeof value === "number" ? value : 0; + }); +} + +/** + * Wait until a browser-side render produces a non-zero buffer length. + * + * Fails fast — naming the `Unknown recipe` warning — when the registry did not + * resolve the recipe, because in that case `startRendering()` is never called + * and a generic timeout would hide the real cause. + */ +export async function waitForRenderedBufferLength( + page: Page, + timeoutMs: number, + capture?: ConsoleCapture, +): Promise { + const expected = "a non-zero OfflineAudioContext rendered buffer length"; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const length = await readRenderedBufferLength(page); + if (length > 0) { + return length; + } + + const unknownRecipes = findUnknownRecipeWarnings(capture?.records ?? []); + if (unknownRecipes.length > 0) { + throw new Error( + formatTimeoutDiagnostics({ + expected, + timeoutMs, + lastOutput: "", + consoleRecords: capture?.records, + hint: + "The browser recipe registry did not resolve the recipe, so " + + "startRendering() was never called.", + }), + ); + } + await page.waitForTimeout(200); + } + + throw new Error( + formatTimeoutDiagnostics({ + expected, + timeoutMs, + lastOutput: "", + consoleRecords: capture?.records, + hint: + "No render was observed. Check the browser console for Node-only " + + "imports or an unresolved recipe.", + }), + ); +} diff --git a/web/e2e/runtime-recipes.spec.ts b/web/e2e/runtime-recipes.spec.ts new file mode 100644 index 00000000..0cbadc11 --- /dev/null +++ b/web/e2e/runtime-recipes.spec.ts @@ -0,0 +1,178 @@ +/** + * Browser e2e tests for the ToneForge Runtime and recipe rendering. + * + * The Runtime and recipes are bundled separately (esbuild) and injected into + * the real browser page, so these tests exercise the browser-native Web Audio + * API path rather than the Node.js offline renderer. + * + * Work item: TF-0MUUPJ95Z001OHEN + */ +import { test, expect, type Page } from "@playwright/test"; +import { build, type Plugin } from "esbuild"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** + * esbuild does not resolve TypeScript's `.js` specifiers to `.ts` sources on + * its own; this plugin re-maps them for the harness bundle. + */ +const jsToTsPlugin: Plugin = { + name: "js-to-ts", + setup(builder) { + builder.onResolve({ filter: /\.js$/ }, (args) => { + if (!args.path.startsWith(".")) return null; + const candidate = resolve( + args.resolveDir, + args.path.replace(/\.js$/, ".ts"), + ); + return existsSync(candidate) ? { path: candidate } : null; + }); + }, +}; + +async function bundleHarness(): Promise { + const result = await build({ + entryPoints: [resolve(here, "fixtures/toneforge-browser-harness.ts")], + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + external: ["node:*"], + plugins: [jsToTsPlugin], + logLevel: "silent", + }); + const output = result.outputFiles?.[0]; + if (!output) throw new Error("esbuild produced no output for the harness"); + return output.text; +} + +let harnessCode: string; + +test.beforeAll(async () => { + harnessCode = await bundleHarness(); +}); + +async function runScenario(page: Page, name: string): Promise { + return (await page.evaluate((scenarioName) => { + const harness = ( + globalThis as unknown as { + __tfHarness?: { run: (n: string) => unknown }; + } + ).__tfHarness; + if (!harness) throw new Error("__tfHarness was not injected"); + return harness.run(scenarioName); + }, name)) as T; +} + +test.describe("ToneForge Runtime and recipes in the browser", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForSelector(".xterm", { timeout: 15_000 }); + await page.addScriptTag({ type: "module", content: harnessCode }); + await page.waitForFunction( + () => + (globalThis as unknown as { __tfHarness?: unknown }).__tfHarness !== + undefined, + undefined, + { timeout: 15_000 }, + ); + }); + + test("start/stop lifecycle logs events and notifies listeners", async ({ + page, + }) => { + const result = await runScenario<{ + sessionId: string; + runningAfterStart: boolean; + runningAfterStop: boolean; + loggedTypes: string[]; + notified: string[]; + }>(page, "lifecycle"); + + expect(result.sessionId).toMatch(/^session-/); + expect(result.runningAfterStart).toBe(true); + expect(result.runningAfterStop).toBe(false); + expect(result.loggedTypes).toContain("start"); + expect(result.loggedTypes).toContain("stop"); + expect(result.notified).toContain("start"); + }); + + test("state changes activate sequences and fire deterministic events", async ({ + page, + }) => { + const result = await runScenario<{ + from: string; + to: string; + currentState: string; + transitionCount: number; + activeSequences: string[]; + eventTypes: string[]; + startedSequences: string[]; + resolvedRecipes: string[]; + }>(page, "stateMachine"); + + expect(result.from).toBe("idle"); + expect(result.to).toBe("walk"); + expect(result.currentState).toBe("run"); + expect(result.transitionCount).toBe(2); + expect(result.activeSequences).toEqual(["footsteps_run"]); + expect(result.eventTypes).toContain("state_change"); + expect(result.eventTypes).toContain("sequence_start"); + expect(result.eventTypes).toContain("event_fire"); + expect(result.startedSequences).toEqual(["footsteps_walk", "footsteps_run"]); + expect(result.resolvedRecipes.length).toBeGreaterThan(0); + }); + + test("context changes are logged and reflected in inspection", async ({ + page, + }) => { + const result = await runScenario<{ + changeCount: number; + snapshot: Record; + eventTypes: string[]; + }>(page, "context"); + + expect(result.changeCount).toBe(1); + expect(result.snapshot.surface).toBe("gravel"); + expect(result.eventTypes).toContain("context_change"); + }); + + test("the same seed produces identical event logs", async ({ page }) => { + const result = await runScenario<{ + first: unknown[]; + second: unknown[]; + equal: boolean; + }>(page, "determinism"); + + expect(result.first.length).toBeGreaterThan(0); + expect(result.equal).toBe(true); + }); + + test("built-in recipes render non-silent, deterministic audio", async ({ + page, + }) => { + const result = await runScenario<{ + results: Array<{ + name: string; + length: number; + sampleRate: number; + nonSilent: boolean; + deterministic: boolean; + seedVaries: boolean; + }>; + }>(page, "renderRecipes"); + + expect(result.results.length).toBeGreaterThan(0); + for (const recipe of result.results) { + expect(recipe.length, `${recipe.name} rendered no samples`).toBeGreaterThan(0); + expect(recipe.sampleRate, `${recipe.name} sample rate`).toBe(44100); + expect(recipe.nonSilent, `${recipe.name} rendered silence`).toBe(true); + expect(recipe.deterministic, `${recipe.name} was not deterministic`).toBe(true); + expect(recipe.seedVaries, `${recipe.name} ignored the seed`).toBe(true); + } + }); +}); diff --git a/web/e2e/sound-editor.spec.ts b/web/e2e/sound-editor.spec.ts new file mode 100644 index 00000000..69116194 --- /dev/null +++ b/web/e2e/sound-editor.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; + +/** + * Browser end-to-end coverage for the embeddable SoundEditor demo. + * + * Mounts the editor, edits a control, previews (gesture-gated), exports a WAV + * and verifies mount/dispose cleanliness. Playwright CSS locators pierce the + * editor's open shadow DOM. + */ +test.describe("SoundEditor (browser e2e)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/editor-demo.html"); + await expect( + page.locator('[data-testid="editor-container"] .toneforge-editor'), + ).toBeVisible(); + }); + + test("mounts controls, edits, previews and exports a WAV", async ({ page }) => { + // Load a known recipe/seed for deterministic assertions. + await page.locator('[data-testid="recipe-select"]').selectOption("footstep-stone"); + await page.locator('[data-testid="seed-input"]').fill("42"); + await page.locator('[data-testid="load-button"]').click(); + + const output = page.locator('[data-testid="preset-output"]'); + await expect(output).toContainText('"recipe": "footstep-stone"'); + + // Edit a control (keyboard interaction) and assert the preset updates. + const control = page.locator('[data-parameter="bodyDecay"]'); + await expect(control).toBeVisible(); + await control.focus(); + await control.press("ArrowRight"); + await expect(output).toContainText("bodyDecay"); + + // Preview: gesture gate then playback (or the documented fallback). + const enable = page.locator(".tf-audition__enable"); + await expect(enable).toBeVisible(); + await enable.click(); + + const status = page.locator(".tf-audition__status"); + await expect(status).toHaveText(/Audio enabled|unavailable/i, { + timeout: 15_000, + }); + if ((await status.textContent())?.includes("enabled")) { + await page.locator(".tf-audition__play").click(); + await expect(status).toHaveText(/Playing/i, { timeout: 15_000 }); + } + + // Export triggers a WAV download with a deterministic filename. + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.locator('[data-testid="export-button"]').click(), + ]); + expect(download.suggestedFilename()).toBe("footstep-stone-seed-42.wav"); + }); + + test("dispose empties the container and leaks no globals", async ({ page }) => { + const before = await page.evaluate(() => Object.keys(window).sort()); + + await page.locator('[data-testid="dispose-button"]').click(); + + await expect(page.locator('[data-testid="editor-container"]')).toBeEmpty(); + + const after = await page.evaluate(() => Object.keys(window).sort()); + expect(after).toEqual(before); + }); +}); diff --git a/web/e2e/tonegraph-smoke.spec.ts b/web/e2e/tonegraph-smoke.spec.ts index 1a3cc63d..ee556256 100644 --- a/web/e2e/tonegraph-smoke.spec.ts +++ b/web/e2e/tonegraph-smoke.spec.ts @@ -2,6 +2,12 @@ import { expect, test, type Page } from "@playwright/test"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + captureConsole, + findUnknownRecipeWarnings, + waitForRenderedBufferLength, + waitForTerminalText, +} from "./helpers/diagnostics"; const NODE_ERROR_PATTERNS = [ /require is not defined/i, @@ -16,85 +22,39 @@ const CORE_BROWSER_FILES = [ resolve(thisFileDir, "../../src/core/tonegraph.ts"), ]; -async function getTerminalText(page: Page): Promise { - return page.evaluate(() => { - const rows = document.querySelectorAll(".xterm-accessibility .xterm-accessibility-tree div"); - if (rows.length > 0) { - return Array.from(rows) - .map((row) => row.textContent ?? "") - .join("\n"); +/** Install the render probe the diagnostics helper reads. */ +async function installRenderedBufferProbe(page: Page): Promise { + await page.addInitScript(() => { + const proto = globalThis.OfflineAudioContext?.prototype as + | { startRendering?: (...args: unknown[]) => Promise<{ length: number }> } + | undefined; + if (!proto || typeof proto.startRendering !== "function") { + return; } - const fallbackRows = document.querySelectorAll(".xterm-rows > div"); - return Array.from(fallbackRows) - .map((row) => row.textContent ?? "") - .join("\n"); + const original = proto.startRendering; + proto.startRendering = async function patchedStartRendering(...args: unknown[]) { + const rendered = await original.apply(this, args); + (globalThis as { __tfLastRenderedLength?: number }).__tfLastRenderedLength = + rendered.length; + return rendered; + }; }); } -async function waitForTerminalText(page: Page, expected: string, timeoutMs: number): Promise { - const start = Date.now(); - let last = ""; - while (Date.now() - start < timeoutMs) { - last = await getTerminalText(page); - if (last.includes(expected)) { - return last; - } - await page.waitForTimeout(400); - } - - throw new Error( - `Timed out after ${timeoutMs}ms waiting for terminal text: ${expected}.\n` + - `Last output:\n${last.slice(0, 2000)}`, - ); -} - -async function waitForRenderedBufferLength(page: Page, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const length = await page.evaluate(() => { - const value = (globalThis as { __tfLastRenderedLength?: unknown }).__tfLastRenderedLength; - return typeof value === "number" ? value : 0; - }); - if (length > 0) { - return length; - } - await page.waitForTimeout(200); - } - - throw new Error(`Timed out after ${timeoutMs}ms waiting for non-zero rendered buffer length.`); -} - test.describe("ToneGraph browser smoke", () => { test("renders a recipe in browser without Node-only console errors", async ({ page }) => { test.setTimeout(120_000); - await page.addInitScript(() => { - const proto = globalThis.OfflineAudioContext?.prototype as - | { startRendering?: (...args: unknown[]) => Promise<{ length: number }> } - | undefined; - if (!proto || typeof proto.startRendering !== "function") { - return; - } - - const original = proto.startRendering; - proto.startRendering = async function patchedStartRendering(...args: unknown[]) { - const rendered = await original.apply(this, args); - (globalThis as { __tfLastRenderedLength?: number }).__tfLastRenderedLength = rendered.length; - return rendered; - }; - }); - - const consoleErrors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") { - consoleErrors.push(msg.text()); - } - }); + await installRenderedBufferProbe(page); + + // Capture console messages up-front so a rendering timeout can report the + // real cause (e.g. an "Unknown recipe" warning) rather than a bare timeout. + const consoles = captureConsole(page); await page.goto("/"); await page.waitForSelector(".xterm", { timeout: 10_000 }); - await waitForTerminalText(page, "ToneForge Terminal", 20_000); + await waitForTerminalText(page, "ToneForge Terminal", 20_000, consoles); const demoSelect = page.locator("#demo-select"); if (await demoSelect.count()) { @@ -107,12 +67,22 @@ test.describe("ToneGraph browser smoke", () => { const runButton = page.locator(".wizard-btn-run"); await expect(runButton).toBeVisible({ timeout: 5_000 }); - consoleErrors.length = 0; + // Scope diagnostics to the Run interaction. + consoles.clear(); await runButton.click(); - const renderedBufferLength = await waitForRenderedBufferLength(page, 45_000); + const renderedBufferLength = await waitForRenderedBufferLength(page, 45_000, consoles); expect(renderedBufferLength).toBeGreaterThan(0); + // The render only happens when the browser recipe registry resolves + // "ui-scifi-confirm"; if it did not, web/src/audio.ts emits + // "Unknown recipe ..." and startRendering() is never called. Asserting the + // warning is absent is the observable proof of registry resolution. + expect(findUnknownRecipeWarnings(consoles.records)).toHaveLength(0); + + const consoleErrors = consoles.records + .filter((record) => record.type === "error") + .map((record) => record.text); expect(consoleErrors).toHaveLength(0); for (const pattern of NODE_ERROR_PATTERNS) { diff --git a/web/e2e/tutorial.spec.ts b/web/e2e/tutorial.spec.ts index 7a10e8bf..a66bbd1a 100644 --- a/web/e2e/tutorial.spec.ts +++ b/web/e2e/tutorial.spec.ts @@ -5,94 +5,48 @@ * through every wizard step clicking Run buttons and verifying that the * terminal receives and executes each command successfully. * + * Timeouts report the true root cause via the shared diagnostics helper + * (`./helpers/diagnostics`), so a missing `toneforge` binary surfaces + * "command not found" rather than a generic "Timed out waiting for terminal + * text". Work item: TF-0MUVK0K230013UC3. + * * Prerequisites (handled by playwright.config.ts webServer): * - Root project built: npm run build (in project root) * - Web project built: npm run build (in web/) * - Server started: node dist-server/index.js (in web/) */ import { test, expect, type Page } from "@playwright/test"; +import { + captureConsole, + waitForTerminalText, + type ConsoleCapture, +} from "./helpers/diagnostics"; // -- Helpers ---------------------------------------------------------- /** - * Read the visible text content from the xterm.js terminal buffer. - * xterm.js v5 uses canvas rendering, so we can't just read DOM text. - * Instead we access the Terminal instance's buffer API via the global. - * - * Falls back to reading .xterm-rows textContent if the API is unavailable. - */ -async function getTerminalText(page: Page): Promise { - // xterm renders rows as a series of
elements inside .xterm-rows - // Even with canvas renderer, there's a DOM-based accessibility tree. - // We'll also try reading from the serialized buffer via evaluate. - const text = await page.evaluate(() => { - // Try the xterm accessibility rows first (screen reader text) - const rows = document.querySelectorAll(".xterm-accessibility .xterm-accessibility-tree div"); - if (rows.length > 0) { - return Array.from(rows) - .map((r) => r.textContent ?? "") - .join("\n"); - } - // Fallback: try .xterm-rows - const xtermRows = document.querySelectorAll(".xterm-rows > div"); - if (xtermRows.length > 0) { - return Array.from(xtermRows) - .map((r) => r.textContent ?? "") - .join("\n"); - } - return ""; - }); - return text; -} - -/** - * Wait until the terminal contains the expected text. - * Polls the terminal buffer at intervals. - */ -async function waitForTerminalText( - page: Page, - expected: string, - timeoutMs = 30_000, -): Promise { - const start = Date.now(); - let lastText = ""; - while (Date.now() - start < timeoutMs) { - lastText = await getTerminalText(page); - if (lastText.includes(expected)) { - return lastText; - } - await page.waitForTimeout(500); - } - throw new Error( - `Timed out after ${timeoutMs}ms waiting for terminal to contain "${expected}".\n` + - `Last terminal text (${lastText.length} chars):\n${lastText.slice(0, 2000)}`, - ); -} - -/** - * Wait until the terminal shows a shell prompt after a command finishes. - * We detect the prompt by waiting for a `$` character to appear after the - * last known output, indicating the shell is ready for the next command. + * Wait until the terminal shows the output that means a command finished. * - * For vitest commands (act-4) we wait for the test result output instead. + * For vitest commands (act-4) we wait for the test summary; for generate + * commands we wait for the rendered output; otherwise we wait for the next + * shell prompt. All waits go through the diagnostics helper so a shell error + * is reported explicitly. */ async function waitForCommandCompletion( page: Page, command: string, timeoutMs = 60_000, + capture?: ConsoleCapture, ): Promise { - // For vitest commands, wait for the test summary line if (command.includes("vitest")) { - return waitForTerminalText(page, "Tests", timeoutMs); + return waitForTerminalText(page, "Tests", timeoutMs, capture); } - // For generate commands, wait for the "Playing..." or "Rendered" output if (command.includes("generate")) { - return waitForTerminalText(page, "Rendered", timeoutMs); + return waitForTerminalText(page, "Rendered", timeoutMs, capture); } - // Generic: wait for the next shell prompt - return waitForTerminalText(page, "$", timeoutMs); + return waitForTerminalText(page, "$", timeoutMs, capture); } // -- Tests ------------------------------------------------------------ @@ -116,10 +70,7 @@ test.describe("Tutorial walkthrough", () => { test("terminal connects and shows banner", async ({ page }) => { // Collect browser console messages to verify connection logging - const consoleMessages: { type: string; text: string }[] = []; - page.on("console", (msg) => { - consoleMessages.push({ type: msg.type(), text: msg.text() }); - }); + const consoles = captureConsole(page); await page.goto("/"); @@ -127,37 +78,64 @@ test.describe("Tutorial walkthrough", () => { await page.waitForSelector(".xterm", { timeout: 10_000 }); // The terminal should show the "ToneForge Terminal" banner on connect - await waitForTerminalText(page, "ToneForge Terminal", 15_000); + await waitForTerminalText(page, "ToneForge Terminal", 15_000, consoles); // Verify console shows connection logs (no silent failures) - const toneForgeMessages = consoleMessages.filter((m) => m.text.includes("[ToneForge]")); + const toneForgeMessages = consoles.records.filter((record) => + record.text.includes("[ToneForge]"), + ); expect(toneForgeMessages.length).toBeGreaterThan(0); - expect(toneForgeMessages.some((m) => m.text.includes("WebSocket connected"))).toBe(true); + expect( + toneForgeMessages.some((record) => record.text.includes("WebSocket connected")), + ).toBe(true); // No AudioContext errors on page load - const audioContextErrors = consoleMessages.filter( - (m) => m.type === "error" && m.text.includes("AudioContext"), + const audioContextErrors = consoles.records.filter( + (record) => record.type === "error" && record.text.includes("AudioContext"), ); expect(audioContextErrors).toHaveLength(0); }); + test("PTY environment exposes a resolvable toneforge command", async ({ page }) => { + const consoles = captureConsole(page); + + await page.goto("/"); + await page.waitForSelector(".xterm", { timeout: 10_000 }); + await waitForTerminalText(page, "ToneForge Terminal", 15_000, consoles); + + // Drive the PTY directly through xterm's hidden input textarea. The marker + // is computed at runtime so the shell's command echo cannot satisfy the + // wait before the command actually runs. + const terminalInput = page.locator(".xterm-helper-textarea"); + await terminalInput.click(); + await page.keyboard.type("toneforge version >/dev/null && echo TONEFORGE_READY_$((6*7))"); + await page.keyboard.press("Enter"); + + // If `toneforge` is missing, bash prints "toneforge: command not found" + // and the diagnostics helper fails naming that error. + const terminalText = await waitForTerminalText( + page, + "TONEFORGE_READY_42", + 20_000, + consoles, + ); + expect(terminalText).toContain("TONEFORGE_READY_42"); + }); + test("full tutorial: click Run on every step and verify terminal output", async ({ page }) => { test.setTimeout(180_000); // 3 minutes for the full walkthrough // Collect console messages to verify commands are sent - const consoleMessages: { type: string; text: string }[] = []; - page.on("console", (msg) => { - consoleMessages.push({ type: msg.type(), text: msg.text() }); - }); + const consoles = captureConsole(page); await page.goto("/"); // Wait for terminal to connect await page.waitForSelector(".xterm", { timeout: 10_000 }); - await waitForTerminalText(page, "ToneForge Terminal", 15_000); + await waitForTerminalText(page, "ToneForge Terminal", 15_000, consoles); - // Wait a moment for the shell prompt to appear - await page.waitForTimeout(2000); + // Wait for the shell prompt to appear + await waitForTerminalText(page, "$", 15_000, consoles); // Step definitions: map step button labels to expected behaviour const steps = [ @@ -170,7 +148,7 @@ test.describe("Tutorial walkthrough", () => { label: "1/4", hasRun: true, title: "Unblock your build on day one", - // Command: tf generate --recipe ui-scifi-confirm --seed 42 + // Command: toneforge generate --recipe ui-scifi-confirm --seed 42 expectInTerminal: "Rendered", }, { @@ -225,28 +203,33 @@ test.describe("Tutorial walkthrough", () => { page, step.label === "4/4" ? "vitest" : "generate", step.label === "4/4" ? 90_000 : 30_000, // vitest takes longer + consoles, ); // Basic sanity: terminal should have some output expect(termText.length).toBeGreaterThan(0); - // Wait for the command to finish before proceeding to next step - // Give the shell a moment to return to prompt - await page.waitForTimeout(1000); + // Wait for the wizard's global run guard to clear before the next + // step — the Run button is disabled and re-labelled while a command + // is executing. This replaces a fixed sleep with an explicit wait. + await expect(runBtn).toBeEnabled({ timeout: 15_000 }); + await expect(runBtn).not.toHaveClass(/wizard-btn-running/, { + timeout: 15_000, + }); } } // After the full walkthrough, verify that commands were actually sent // (not silently swallowed by a disconnected WebSocket) - const sendMessages = consoleMessages.filter( - (m) => m.text.includes("[ToneForge] Executing command:"), + const sendMessages = consoles.records.filter((record) => + record.text.includes("[ToneForge] Executing command:"), ); // Acts 1-4 send commands: 1 + 3 + 1 + 1 = 6 commands total expect(sendMessages.length).toBe(6); // No AudioContext errors during the walkthrough - const audioContextErrors = consoleMessages.filter( - (m) => m.type === "error" && m.text.includes("AudioContext"), + const audioContextErrors = consoles.records.filter( + (record) => record.type === "error" && record.text.includes("AudioContext"), ); expect(audioContextErrors).toHaveLength(0); }); diff --git a/web/package.json b/web/package.json index 2d9905c1..60590c58 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "description": "Web-based demo for ToneForge CLI", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build && tsc -p tsconfig.server.json", "start": "node dist-server/index.js", "test": "vitest run", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 0b18c65c..2c7d78d3 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,6 +1,54 @@ import { defineConfig } from "@playwright/test"; +import { findFreePortSync } from "./scripts/select-port.js"; -const port = parseInt(process.env.PORT || "3000", 10); +/** Env var used to share the selected port with Playwright worker processes. */ +const PORT_ENV = "TF_E2E_PORT"; + +/** + * Resolve the port the e2e web server binds. + * + * `PORT` wins when set explicitly. Otherwise the first free port at or after + * 3000 is selected so a busy default port (for example another project's dev + * server) does not abort the run before a single test executes. + * + * Playwright evaluates this config in the main process *and* in every worker + * process; without a shared value the workers would probe different ports and + * navigate to a server that was never started. The main process evaluates + * first and caches its choice in `TF_E2E_PORT`, which worker processes inherit. + */ +function resolvePort(): number { + const requested = process.env.PORT + ? Number.parseInt(process.env.PORT, 10) + : undefined; + if (requested !== undefined && Number.isFinite(requested)) { + return requested; + } + + const cached = process.env[PORT_ENV] + ? Number.parseInt(process.env[PORT_ENV] as string, 10) + : undefined; + if (cached !== undefined && Number.isFinite(cached)) { + return cached; + } + + const selected = findFreePortSync(3000); + process.env[PORT_ENV] = String(selected); + return selected; +} + +const port = resolvePort(); + +/** True when running under a CI provider (GitHub Actions sets `CI=true`). */ +const isCI = process.env.CI === "true" || process.env.CI === "1"; + +/** Environment for the web server command, preserving the inherited env. */ +const webServerEnv: Record = {}; +for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + webServerEnv[key] = value; + } +} +webServerEnv.PORT = String(port); export default defineConfig({ testDir: "./e2e", @@ -9,11 +57,22 @@ export default defineConfig({ timeout: 30_000, }, fullyParallel: false, // tests share a single server - retries: 0, + // Retry on CI only: the terminal-walkthrough specs drive a PTY and are the + // most sensitive to cold-start timing; a retry absorbs that without hiding + // genuine failures locally. + retries: isCI ? 2 : 0, + // CI emits both a list (for the job log) and an HTML report (uploaded as an + // artifact); local runs keep the concise list output. + reporter: isCI ? [["list"], ["html", { open: "never" }]] : "list", use: { baseURL: `http://localhost:${port}`, headless: true, - trace: "retain-on-failure", + // Capture a trace on failure but skip DOM snapshots: the snapshot capture + // races xterm.js's DOM renderer and can drop the "ToneForge Terminal" + // banner (and other early terminal output) from the viewport, which + // previously made the banner assertion fail intermittently. Screenshots, + // sources, console and network activity are still recorded. + trace: { mode: "retain-on-failure", snapshots: false }, screenshot: "only-on-failure", }, webServer: { @@ -21,11 +80,20 @@ export default defineConfig({ port, reuseExistingServer: false, timeout: 15_000, + env: webServerEnv, }, projects: [ { name: "chromium", use: { browserName: "chromium" }, }, + { + // Cross-browser coverage for the Runtime/recipe browser tests. + // Scoped to runtime-recipes.spec.ts so the terminal-walkthrough specs + // keep their chromium-only assumptions. + name: "firefox", + testMatch: /runtime-recipes\.spec\.ts/, + use: { browserName: "firefox" }, + }, ], }); diff --git a/web/plugins/yaml-recipe-plugin.ts b/web/plugins/yaml-recipe-plugin.ts new file mode 100644 index 00000000..0e2bfd80 --- /dev/null +++ b/web/plugins/yaml-recipe-plugin.ts @@ -0,0 +1,40 @@ +import type { Plugin } from "vite"; +import { resolve } from "node:path"; +import yaml from "js-yaml"; + +/** + * Vite/Vitest plugin that converts YAML recipe files to JSON module exports. + * + * `presets/recipes/*.yaml` documents are parsed with `js-yaml` (only available + * at build time in Node) and emitted as `export default ` so the browser + * bundle can import them as plain objects via `import.meta.glob` — without + * shipping a YAML parser to the client. + * + * `projectRoot` is the repository root that contains `presets/recipes/`. + */ +export function yamlRecipePlugin(projectRoot: string): Plugin { + const recipeDirectory = resolve(projectRoot, "presets/recipes"); + + return { + name: "toneforge-yaml-recipe-plugin", + enforce: "pre", + transform(code, id) { + // `id` may carry a query suffix (e.g. `?used`); strip it before matching. + const filePath = id.split("?")[0]; + const resolved = resolve(filePath); + + if (!resolved.startsWith(recipeDirectory + "/")) { + return null; + } + if (!resolved.endsWith(".yaml") && !resolved.endsWith(".yml")) { + return null; + } + + const parsed = yaml.load(code) as Record; + return { + code: `export default ${JSON.stringify(parsed)};`, + map: null, + }; + }, + }; +} diff --git a/web/scripts/select-port.ts b/web/scripts/select-port.ts new file mode 100644 index 00000000..c08f9226 --- /dev/null +++ b/web/scripts/select-port.ts @@ -0,0 +1,84 @@ +/** + * Free-port selection for Playwright's `webServer`. + * + * The e2e server binds a fixed port (default 3000). When that port is already + * occupied — for example by another project's dev server — Playwright aborts + * with "port is already used" before running a single test. Selecting a free + * port up-front keeps local e2e runs working regardless of what else is + * running on the machine, while CI (where 3000 is free) keeps using 3000. + * + * `findFreePort` probes with an asynchronous `net.Server`; the Playwright + * config is evaluated synchronously, so `findFreePortSync` runs the same probe + * in a short-lived child process. + * + * Work item: TF-0MOKJJI7N005LT61. + */ +import { execFileSync } from "node:child_process"; +import { createServer } from "node:net"; + +/** Loopback interface the probes bind to. */ +const HOST = "127.0.0.1"; + +/** Return true when `port` can be bound on the loopback interface. */ +export function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, HOST, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** + * Return the first free port in `[start, start + attempts)`, or `start` when + * none of the candidates is free. + */ +export async function findFreePort(start: number, attempts = 20): Promise { + for (let port = start; port < start + attempts; port += 1) { + if (await isPortFree(port)) { + return port; + } + } + return start; +} + +/** + * Child-process probe mirroring {@link findFreePort}. Receives `start` and + * `attempts` as argv and prints the selected port on stdout. + */ +const SYNC_PROBE = ` +const net = require("node:net"); +const start = Number(process.argv[1]); +const attempts = Number(process.argv[2]); +const tryPort = (port, remaining) => new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => { + if (remaining > 0) resolve(tryPort(port + 1, remaining - 1)); + else resolve(start); + }); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(port))); +}); +tryPort(start, attempts).then((port) => process.stdout.write(String(port))); +`; + +/** + * Synchronous {@link findFreePort}, safe to call while the Playwright config + * is being evaluated. Falls back to `start` when the probe cannot run (for + * example in a restricted sandbox). + */ +export function findFreePortSync(start: number, attempts = 20): number { + try { + const output = execFileSync( + process.execPath, + ["-e", SYNC_PROBE, String(start), String(attempts)], + { encoding: "utf8", timeout: 5_000 }, + ); + const port = Number.parseInt(output.trim(), 10); + return Number.isFinite(port) ? port : start; + } catch { + return start; + } +} diff --git a/web/server/index.ts b/web/server/index.ts index 5036f91f..19e17a7b 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -103,12 +103,38 @@ wss.on("connection", (ws: WebSocket) => { log("CONNECTED", undefined, "Terminal session started"); const shell = process.platform === "win32" ? "powershell.exe" : "bash"; + + /** + * Resolve a directory path relative to the server script. + * Uses __dirname so the path is stable after TypeScript compilation. + */ + function serverRelative(...parts: string[]): string { + return resolve(__dirname, ...parts); + } + + /** + * Path to the CI-safe CLI shim directory. + * Contains `toneforge` and `tf` shell scripts that delegate to + * `bin/dev-cli.js` so the CLI is resolvable in the PTY without + * a global `npm link`. + */ + const SHIM_DIR = serverRelative("..", "..", "bin", "tf-shim"); + + /** + * Prepend the shim directory to the PTY PATH so that `toneforge` and `tf` + * commands resolve inside the terminal without a global `npm link`. + */ + const ptyEnv = { ...process.env } as Record; + if (ptyEnv.PATH) { + ptyEnv.PATH = `${SHIM_DIR}:${ptyEnv.PATH}`; + } + const ptyProcess = pty.spawn(shell, [], { name: "xterm-256color", cols: 80, rows: 24, cwd: PROJECT_ROOT, - env: { ...process.env } as Record, + env: ptyEnv, }); // Buffer for detecting and stripping OSC 133;D exit-code sequences from PTY output. diff --git a/web/src/audio.ts b/web/src/audio.ts index a0d8435d..168bddbc 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -1,5 +1,23 @@ import { createRng } from "@toneforge/core/rng.js"; import { registry } from "@toneforge/recipes/index.js"; +import { OfflineAudioContext } from "@toneforge/audio/web-audio.js"; +import { registerBrowserRecipes } from "./browser-recipe-registry.js"; + +/** + * Ensure the file-backed recipe registry is populated before a lookup. + * + * In the browser `discoverFileBackedRecipes()` is a no-op, so the inlined + * recipe bundle (see `browser-recipe-registry`) must be registered first. + * Registration is idempotent and cheap after the first call. + */ +let browserRecipesRegistered = false; +function ensureBrowserRecipes(): void { + if (browserRecipesRegistered) { + return; + } + registerBrowserRecipes(); + browserRecipesRegistered = true; +} let realtimeCtx: AudioContext | null = null; @@ -74,6 +92,10 @@ async function ensureAudioContext(): Promise { * Render and play a recipe with the given seed in the browser. */ export async function renderAndPlay(recipeName: string, seed: number): Promise { + // File-backed recipes are inlined into the browser bundle rather than + // discovered from disk; make sure they are registered before the lookup. + ensureBrowserRecipes(); + const registration = registry.getRegistration(recipeName); if (!registration) { console.warn(`Unknown recipe "${recipeName}" - skipping audio playback.`); @@ -87,7 +109,7 @@ export async function renderAndPlay(recipeName: string, seed: number): Promise( + "../../presets/recipes/*.yaml", + { eager: true }, +); + +/** + * Register every inlined file-backed recipe into a registry. + * + * Recipes that fail schema validation are skipped with a warning so a single + * malformed file cannot prevent the remaining recipes (and the app) from + * loading — mirroring the Node discovery path's per-file error handling. + * + * @param target Registry to populate. Defaults to the shared application + * registry; tests can pass a fresh registry to observe browser registration + * in isolation from the Node discovery path. + * @returns The names of the recipes that were successfully registered. + */ +export function registerBrowserRecipes( + target: RecipeRegistry = registry, +): string[] { + const registered: string[] = []; + + for (const [path, module] of Object.entries(recipeModules)) { + const fileName = path.split("/").pop() ?? path; + const recipeName = fileName.replace(/\.(yaml|yml)$/, ""); + + try { + const rawDoc = module.default; + const graph = validateToneGraph(rawDoc); + target.register( + recipeName, + createFileBackedRegistration(recipeName, graph, rawDoc), + ); + registered.push(recipeName); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `Skipping browser recipe ${fileName}: ${message}`, + ); + } + } + + return registered; +} + +/** + * Cached promise for the one-time browser recipe registration pass. + * + * Kept module-scoped so repeated calls reuse the same registration result + * (idempotent, no duplicate registration). + */ +let browserRecipesPromise: Promise | undefined; + +/** + * Initialise the file-backed recipe registry for the browser environment. + * + * This is the browser equivalent of `initializeRecipeRegistry()` from + * `@toneforge/recipes/index.js`, which is a no-op outside Node. Call it once + * during application initialisation, before any audio rendering occurs. + * + * The returned promise resolves with the list of registered recipe names. + */ +export function initializeBrowserRecipeRegistry(): Promise { + if (browserRecipesPromise === undefined) { + browserRecipesPromise = Promise.resolve(registerBrowserRecipes()); + } + return browserRecipesPromise; +} diff --git a/web/src/components/SoundEditor/README.md b/web/src/components/SoundEditor/README.md new file mode 100644 index 00000000..0146766f --- /dev/null +++ b/web/src/components/SoundEditor/README.md @@ -0,0 +1,192 @@ +# SoundEditor (embeddable) + +Framework-agnostic, single-sound editor shell. It mounts into any host +container, owns its own shadow DOM and lifecycle, and never leaks global CSS, +listeners or symbols into the host page. + +> The editor is **single-sound** by design. Stacking, sequencing and mixing +> UIs are explicitly out of scope and belong to separate work items. + +## Usage + +```ts +import { createSoundEditor } from "./components/SoundEditor/index.js"; + +const editor = createSoundEditor({ + preset: { version: 1, recipe: "weapon-laser-zap", seed: 1234, overrides: {} }, + // Optional: an audio engine to release on dispose (built by later slices). + audioEngine: { dispose: () => {} }, + // Optional: accessible label for the editor region. + label: "Enemy sound", +}); + +editor.mount(document.getElementById("host")!); + +const unsubscribe = editor.onChange((preset) => { + // Persist / forward the preset to the host application. + console.log(preset); +}); + +editor.setPreset({ ...editor.getPreset(), seed: 5678 }); + +unsubscribe(); +editor.dispose(); +``` + +## Public API + +| Member | Description | +| --- | --- | +| `mount(container)` | Renders exactly one scoped root element into `container`. Throws if already mounted or disposed. Never constructs an `AudioContext`. | +| `dispose()` | Removes all DOM, disconnect observers, and disposes the audio engine. Idempotent. | +| `onChange(listener)` | Subscribes to state changes; returns an unsubscribe function. Listeners are cleared by `dispose()` and are never invoked afterwards. | +| `getPreset()` | Returns a defensive copy of the current `SoundPreset`. | +| `setPreset(preset)` | Replaces the current preset and notifies listeners when it actually changes. | + +### `SoundPreset` + +```ts +interface SoundPreset { + version: number; // schema version (currently 1) + recipe: string; // registered recipe name + seed: number; // deterministic seed + overrides: Record; // parameter overrides (may be empty) +} +``` + +## Lifecycle & embedding guarantees + +- **Scoped styles** — the stylesheet is injected into the editor's shadow root; + nothing is written to `document.head` or the host page. +- **No globals** — mounting creates no properties on `globalThis` and adds no + listeners to `document` or `window`. +- **Clean dispose** — the container is emptied, the `ResizeObserver` is + disconnected, the audio engine is disposed, and the change-listener set is + cleared. +- **Lazy audio** — audio is never created on mount; an `AudioEngine` is supplied + by the host when auditioning is available (later slices) and is optional. +- **Graceful degradation** — the shell depends only on DOM APIs and does not + throw when Web Audio is unavailable. + +## WAV export + +The editor renders offline (no live `AudioContext` required) and encodes with +the existing ToneForge WAV encoder: + +```ts +const bytes: Uint8Array = await editor.exportWav(); // current preset +const other = await editor.exportWav(otherPreset); // explicit preset +``` + +Hosts can build a download with the exported helper: + +```ts +import { createWavDownload } from "./components/SoundEditor/index.js"; + +const wav = await createWavDownload(preset); // { bytes, filename, blob, download() } +wav.download(); // triggers a browser download (filename: -seed-.wav) +``` + +Export is deterministic: the same preset always produces byte-identical WAV +bytes, and the bytes match the samples returned by `renderPreset`. Unknown +recipes throw a typed `PresetExportError`. + +## Audition (play / stop / loop) + +The editor includes audition controls. Per browser autoplay policy, **no +`AudioContext` is created until an explicit user gesture** — an "Enable audio" +button is shown until then. After the gesture, Play renders the current preset +and starts playback, Stop halts it, and Loop repeats until stopped. Edits made +while playing are picked up on the **next render** (loop boundary or next Play), +never by mutating live nodes. If Web Audio is unavailable the control disables +itself with an accessible message and the rest of the editor keeps working. + +## Theming + +Theme the editor with CSS custom properties set on (or above) the mount +container. The `--tf-editor-*` names are still accepted as aliases. + +| Property | Default | Purpose | +| --- | --- | --- | +| `--tfe-background` | `#16181d` | Editor background | +| `--tfe-foreground` | `#f5f5f5` | Text colour | +| `--tfe-accent` | `#6ea8fe` | Buttons / Loop active / focus accents | +| `--tfe-font` | `system-ui, sans-serif` | Font family | + +```css +#enemy-gym .enemy-gym-panel { + --tfe-background: #101418; + --tfe-foreground: #f5f5f5; + --tfe-accent: #ff9f1c; +} +``` + +A host can also pass the same values programmatically (they are written as CSS +custom properties on the editor root): + +```ts +const editor = createSoundEditor({ + preset, + theme: { accent: "#ff9f1c", compactBreakpoint: 420 }, +}); +``` + +## Responsive layout + +The editor adapts to the **container** width (not the window) via a +`ResizeObserver`, toggling `toneforge-editor--compact` below the breakpoint +(default 480px) and `toneforge-editor--wide` otherwise. The compact layout uses +a single column; the wide layout uses an auto-fitting grid. The observer is +disconnected on `dispose()`. + +## Accessibility + +- **Labelled** — every control has a name (`aria-label` / visible label) sourced + from the recipe parameter; the editor region is a labelled `role="group"`. +- **Keyboard operable** — sliders/dials: Arrow keys (±1 step), PageUp/PageDown + (±10 steps), Home/End; XY pad: per-axis Arrow/Home/End; toggle/select: Arrow + keys, Home/End, Enter/Space. Audition buttons are native ` + + +
+ +
+

+
+    
+  
+
diff --git a/web/src/editor-demo.ts b/web/src/editor-demo.ts
new file mode 100644
index 00000000..97ad37b2
--- /dev/null
+++ b/web/src/editor-demo.ts
@@ -0,0 +1,80 @@
+/**
+ * Standalone Sound Editor demo entry.
+ *
+ * Mounts the embeddable editor, lets the user pick a recipe + seed, auditions
+ * the sound and exports a WAV — all in the browser, without a host framework.
+ */
+
+import { initializeRecipeRegistry, registry } from "@toneforge/recipes/index.js";
+import { initializeBrowserRecipeRegistry } from "./browser-recipe-registry.js";
+import {
+  createSoundEditor,
+  createWavDownload,
+  type SoundPreset,
+} from "./components/SoundEditor/index.js";
+
+const recipeSelect = document.querySelector("#recipe")!;
+const seedInput = document.querySelector("#seed")!;
+const loadButton = document.querySelector("#load")!;
+const exportButton = document.querySelector("#export")!;
+const disposeButton = document.querySelector("#dispose")!;
+const container = document.querySelector("#editor")!;
+const output = document.querySelector("#preset-out")!;
+
+function populateRecipes(): void {
+  for (const name of registry.list()) {
+    const option = document.createElement("option");
+    option.value = name;
+    option.textContent = name;
+    recipeSelect.appendChild(option);
+  }
+}
+
+async function main(): Promise {
+  // File-backed recipes are a Node-only discovery path; in the browser it is
+  // a no-op, so register the inlined recipe bundle instead. Both calls are
+  // idempotent.
+  await initializeRecipeRegistry();
+  await initializeBrowserRecipeRegistry();
+  populateRecipes();
+
+  let preset: SoundPreset = {
+    version: 1,
+    recipe: recipeSelect.value || "ui-scifi-confirm",
+    seed: Number(seedInput.value) || 42,
+    overrides: {},
+  };
+  recipeSelect.value = preset.recipe;
+
+  const editor = createSoundEditor({ preset });
+  editor.mount(container);
+
+  const renderOutput = (): void => {
+    output.textContent = JSON.stringify(editor.getPreset(), null, 2);
+  };
+  renderOutput();
+
+  editor.onChange(() => renderOutput());
+
+  loadButton.addEventListener("click", () => {
+    preset = {
+      version: 1,
+      recipe: recipeSelect.value,
+      seed: Number(seedInput.value) || 0,
+      overrides: {},
+    };
+    editor.setPreset(preset);
+    renderOutput();
+  });
+
+  exportButton.addEventListener("click", () => {
+    void createWavDownload(editor.getPreset()).then((wav) => wav.download());
+  });
+
+  disposeButton.addEventListener("click", () => {
+    editor.dispose();
+    output.textContent = "";
+  });
+}
+
+void main();
diff --git a/web/src/main.ts b/web/src/main.ts
index 82e643c6..ae86276b 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -2,11 +2,17 @@
 import "@xterm/xterm/css/xterm.css";
 import { createTerminal } from "./terminal.js";
 import { createWizard } from "./wizard.js";
+import { initializeBrowserRecipeRegistry } from "./browser-recipe-registry.js";
 import type { TerminalController } from "./terminal.js";
 
 let terminal: TerminalController | null = null;
 
 function init(): void {
+  // Register file-backed recipes inlined into the browser bundle. The Node
+  // discovery path is a no-op here, so without this the wizard's Run button
+  // would report "Unknown recipe" and never render audio.
+  void initializeBrowserRecipeRegistry();
+
   const terminalContainer = document.getElementById("terminal-container");
   const wizardContainer = document.getElementById("wizard");
 
diff --git a/web/src/models/README.md b/web/src/models/README.md
new file mode 100644
index 00000000..3e733695
--- /dev/null
+++ b/web/src/models/README.md
@@ -0,0 +1,40 @@
+# SoundPreset model
+
+Versioned, deterministic description of a single sound, used to hand sounds
+between the editor, hosts and persistence.
+
+## Schema (version 1)
+
+```json
+{
+  "version": 1,
+  "recipe": "weapon-laser-zap",
+  "seed": 1234,
+  "overrides": { "carrierFreq": 500 }
+}
+```
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `version` | `number` | Schema version. Only `1` is accepted by `parsePreset`. |
+| `recipe` | `string` | Registered recipe name. |
+| `seed` | `number` | Integer seed; fully determines baseline parameters. |
+| `overrides` | `Record` | Sparse parameter overrides on top of the seed baseline. |
+
+## API
+
+| Function | Behaviour |
+| --- | --- |
+| `createPreset(recipe, seed, overrides?)` | Builds a normalised preset. |
+| `normalisePreset(preset)` | Validates recipe/seed; drops unknown/non-finite overrides; clamps out-of-range values; rounds integer (`unit: "int"`) parameters. |
+| `serialisePreset(preset)` | Canonical JSON: fixed field order, sorted override keys → byte-identical for equal presets. |
+| `parsePreset(json)` | Strict parse: rejects invalid JSON, unsupported version, unknown recipe, non-integer seed, unknown parameters, non-finite/out-of-range/integer violations. |
+| `getBaselineParams(recipe, seed)` | Seed-derived parameter values, reusing `registry…getParams(rng)`. |
+| `getEffectiveParams(preset)` | Baseline parameters with overrides applied. |
+| `presetsEqual(a, b)` | Deep, order-independent equality. |
+
+## Determinism
+
+`parsePreset(serialisePreset(preset))` deep-equals `preset` and re-serialising
+is byte-identical. Parameter definitions are never duplicated — descriptors and
+baselines come from the shared recipe registry (`src/recipes/index.ts`).
diff --git a/web/src/models/__tests__/preset.test.ts b/web/src/models/__tests__/preset.test.ts
new file mode 100644
index 00000000..0b46de39
--- /dev/null
+++ b/web/src/models/__tests__/preset.test.ts
@@ -0,0 +1,222 @@
+/**
+ * Tests for the SoundPreset model and deterministic serialisation.
+ *
+ * AC (TF-0MUV11PVB008I6EA): versioned schema; round-trip with byte-identical
+ * re-serialisation; strict validation of version/recipe/seed/overrides;
+ * clamping/dropping via normalisation; registry-sourced descriptors.
+ */
+import { describe, it, expect } from "vitest";
+import {
+  SOUND_PRESET_VERSION,
+  PresetValidationError,
+  assertOverrideValid,
+  clampOverride,
+  createPreset,
+  getBaselineParams,
+  getEffectiveParams,
+  normalisePreset,
+  parsePreset,
+  presetsEqual,
+  serialisePreset,
+  type SoundPreset,
+} from "../preset.js";
+import { registry } from "../../../../src/recipes/index.js";
+import { createRng } from "../../../../src/core/rng.js";
+import type { ParamDescriptor } from "../../../../src/core/recipe.js";
+
+const RECIPE = "weapon-laser-zap";
+
+function validPreset(overrides: Record = {}): SoundPreset {
+  return createPreset(RECIPE, 1234, overrides);
+}
+
+describe("createPreset", () => {
+  it("builds a versioned preset with the seed and overrides", () => {
+    const preset = validPreset({ carrierFreq: 500 });
+    expect(preset.version).toBe(SOUND_PRESET_VERSION);
+    expect(preset.recipe).toBe(RECIPE);
+    expect(preset.seed).toBe(1234);
+    expect(preset.overrides).toEqual({ carrierFreq: 500 });
+  });
+
+  it("rejects a non-integer seed", () => {
+    expect(() => createPreset(RECIPE, 1.5)).toThrow(PresetValidationError);
+  });
+
+  it("rejects an unknown recipe", () => {
+    expect(() => createPreset("no-such-recipe", 1)).toThrow(/Unknown recipe/);
+  });
+});
+
+describe("serialisePreset / parsePreset round-trip", () => {
+  it("deep-equals the original preset after a round-trip", () => {
+    const preset = validPreset({ carrierFreq: 500, decay: 0.1 });
+    const parsed = parsePreset(serialisePreset(preset));
+    expect(parsed).toEqual(preset);
+    expect(presetsEqual(parsed, preset)).toBe(true);
+  });
+
+  it("re-serialises byte-identically", () => {
+    const preset = validPreset({ carrierFreq: 500, decay: 0.1 });
+    const once = serialisePreset(preset);
+    const twice = serialisePreset(parsePreset(once));
+    expect(twice).toBe(once);
+  });
+
+  it("is insensitive to override key insertion order", () => {
+    const a = serialisePreset(validPreset({ carrierFreq: 500, decay: 0.1 }));
+    const b = serialisePreset(validPreset({ decay: 0.1, carrierFreq: 500 }));
+    expect(b).toBe(a);
+  });
+
+  it("emits fields in a stable order", () => {
+    expect(serialisePreset(validPreset())).toBe(
+      '{"version":1,"recipe":"weapon-laser-zap","seed":1234,"overrides":{}}',
+    );
+  });
+
+  it("refuses to serialise non-finite overrides", () => {
+    const preset = validPreset();
+    preset.overrides.carrierFreq = Number.NaN;
+    expect(() => serialisePreset(preset)).toThrow(/non-finite/);
+  });
+});
+
+describe("parsePreset validation", () => {
+  it("rejects invalid JSON", () => {
+    expect(() => parsePreset("{not json")).toThrow(/Invalid preset JSON/);
+  });
+
+  it("rejects a non-object payload", () => {
+    expect(() => parsePreset("[]")).toThrow(/must be a JSON object/);
+  });
+
+  it("rejects an unsupported schema version", () => {
+    const json = JSON.stringify({
+      version: 999,
+      recipe: RECIPE,
+      seed: 1,
+      overrides: {},
+    });
+    expect(() => parsePreset(json)).toThrow(/Unsupported preset version/);
+  });
+
+  it("rejects an unknown recipe", () => {
+    const json = JSON.stringify({
+      version: SOUND_PRESET_VERSION,
+      recipe: "no-such-recipe",
+      seed: 1,
+      overrides: {},
+    });
+    expect(() => parsePreset(json)).toThrow(/Unknown recipe/);
+  });
+
+  it("rejects a non-integer seed", () => {
+    const json = JSON.stringify({
+      version: SOUND_PRESET_VERSION,
+      recipe: RECIPE,
+      seed: 1.25,
+      overrides: {},
+    });
+    expect(() => parsePreset(json)).toThrow(/seed must be an integer/);
+  });
+
+  it("rejects an unknown parameter name", () => {
+    const json = JSON.stringify({
+      version: SOUND_PRESET_VERSION,
+      recipe: RECIPE,
+      seed: 1,
+      overrides: { notAParam: 1 },
+    });
+    expect(() => parsePreset(json)).toThrow(/Unknown parameter/);
+  });
+
+  it("rejects an out-of-range value", () => {
+    const json = JSON.stringify({
+      version: SOUND_PRESET_VERSION,
+      recipe: RECIPE,
+      seed: 1,
+      overrides: { carrierFreq: 999999 },
+    });
+    expect(() => parsePreset(json)).toThrow(/outside/);
+  });
+
+  it("rejects a non-finite value", () => {
+    // JSON.stringify turns NaN into null, so craft the payload textually.
+    const json =
+      '{"version":1,"recipe":"weapon-laser-zap","seed":1,"overrides":{"carrierFreq":null}}';
+    expect(() => parsePreset(json)).toThrow(/finite number/);
+  });
+});
+
+describe("normalisePreset", () => {
+  it("clamps out-of-range overrides and drops unknown/non-finite ones", () => {
+    const normalised = normalisePreset({
+      version: SOUND_PRESET_VERSION,
+      recipe: RECIPE,
+      seed: 1234,
+      overrides: {
+        carrierFreq: 999999,
+        notAParam: 5,
+        decay: Number.NaN,
+      },
+    });
+
+    expect(normalised.overrides).toEqual({ carrierFreq: 2000 });
+    expect(normalised.version).toBe(SOUND_PRESET_VERSION);
+  });
+
+  it("is idempotent", () => {
+    const once = normalisePreset({
+      version: SOUND_PRESET_VERSION,
+      recipe: RECIPE,
+      seed: 1234,
+      overrides: { carrierFreq: -100 },
+    });
+    expect(normalisePreset(once)).toEqual(once);
+  });
+});
+
+describe("descriptor helpers", () => {
+  it("rounds integer-typed overrides and clamps to range", () => {
+    const descriptor: ParamDescriptor = {
+      name: "voices",
+      min: 1,
+      max: 8,
+      unit: "int",
+    };
+    expect(clampOverride(descriptor, 3.6)).toBe(4);
+    expect(clampOverride(descriptor, 99)).toBe(8);
+    expect(clampOverride(descriptor, -5)).toBe(1);
+  });
+
+  it("validates integer and range constraints", () => {
+    const descriptor: ParamDescriptor = {
+      name: "voices",
+      min: 1,
+      max: 8,
+      unit: "int",
+    };
+    expect(() => assertOverrideValid(descriptor, 4)).not.toThrow();
+    expect(() => assertOverrideValid(descriptor, 4.5)).toThrow(/integer/);
+    expect(() => assertOverrideValid(descriptor, 9)).toThrow(/outside/);
+    expect(() => assertOverrideValid(descriptor, Number.NaN)).toThrow(/finite/);
+  });
+});
+
+describe("baseline parameter reuse", () => {
+  it("derives baseline values from the registry getParams", () => {
+    const registration = registry.getRegistration(RECIPE);
+    expect(registration).toBeDefined();
+    const expected = registration?.getParams(createRng(1234));
+    expect(getBaselineParams(RECIPE, 1234)).toEqual(expected);
+  });
+
+  it("merges baseline and overrides in getEffectiveParams", () => {
+    const preset = validPreset({ carrierFreq: 500 });
+    const baseline = getBaselineParams(RECIPE, preset.seed);
+    const effective = getEffectiveParams(preset);
+    expect(effective.carrierFreq).toBe(500);
+    expect(effective.decay).toBe(baseline.decay);
+  });
+});
diff --git a/web/src/models/preset.ts b/web/src/models/preset.ts
new file mode 100644
index 00000000..e5ddbe88
--- /dev/null
+++ b/web/src/models/preset.ts
@@ -0,0 +1,298 @@
+/**
+ * SoundPreset model — versioned, deterministic description of a single sound.
+ *
+ * A preset is `{ version, recipe, seed, overrides }`:
+ * - `version`   — schema version (currently {@link SOUND_PRESET_VERSION}).
+ * - `recipe`    — a registered recipe name.
+ * - `seed`      — integer seed; the seed alone fully determines the baseline
+ *                 parameter values (via the recipe's `getParams(rng)`).
+ * - `overrides` — sparse map of parameter name → value applied on top of the
+ *                 seed-derived baseline.
+ *
+ * The model is framework-free and has no DOM/audio dependency so it can be
+ * unit-tested in isolation and handed between host applications as JSON.
+ *
+ * Determinism contract: `parsePreset(serialisePreset(p))` deep-equals `p`, and
+ * re-serialising is byte-identical (override keys are emitted in sorted order).
+ */
+
+import type { ParamDescriptor } from "../../../src/core/recipe.js";
+import { createRng } from "../../../src/core/rng.js";
+import { registry } from "../../../src/recipes/index.js";
+
+/** Current schema version understood by this module. */
+export const SOUND_PRESET_VERSION = 1;
+
+/** Versioned, deterministic description of a single sound. */
+export interface SoundPreset {
+  version: number;
+  recipe: string;
+  seed: number;
+  overrides: Record;
+}
+
+/** Thrown when a preset is malformed, unknown or out of range. */
+export class PresetValidationError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "PresetValidationError";
+  }
+}
+
+function isRecord(value: unknown): value is Record {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Descriptors for a recipe, or a clear error when it is not registered. */
+export function getParamDescriptors(recipe: string): ParamDescriptor[] {
+  const registration = registry.getRegistration(recipe);
+  if (!registration) {
+    throw new PresetValidationError(`Unknown recipe: ${recipe}`);
+  }
+  return registration.params;
+}
+
+/**
+ * Integer-typed parameters are declared with the `int` unit by the recipe
+ * registry (`src/core/recipe.ts` maps `type: integer` to `unit: "int"`).
+ */
+export function isIntegerDescriptor(descriptor: ParamDescriptor): boolean {
+  const unit = descriptor.unit.toLowerCase();
+  return unit === "int" || unit === "integer";
+}
+
+/**
+ * Clamp + quantise a value for a descriptor.
+ *
+ * Out-of-range values are clamped to `[min, max]`; integer-typed parameters
+ * are rounded to the nearest integer.
+ */
+export function clampOverride(
+  descriptor: ParamDescriptor,
+  value: number,
+): number {
+  const clamped = Math.min(descriptor.max, Math.max(descriptor.min, value));
+  return isIntegerDescriptor(descriptor) ? Math.round(clamped) : clamped;
+}
+
+/** Validate a value against a descriptor, throwing on any violation. */
+export function assertOverrideValid(
+  descriptor: ParamDescriptor,
+  value: number,
+): void {
+  if (typeof value !== "number" || !Number.isFinite(value)) {
+    throw new PresetValidationError(
+      `Override '${descriptor.name}' must be a finite number`,
+    );
+  }
+  if (isIntegerDescriptor(descriptor) && !Number.isInteger(value)) {
+    throw new PresetValidationError(
+      `Override '${descriptor.name}' must be an integer`,
+    );
+  }
+  if (value < descriptor.min || value > descriptor.max) {
+    throw new PresetValidationError(
+      `Override '${descriptor.name}' = ${value} is outside ` +
+        `[${descriptor.min}, ${descriptor.max}]`,
+    );
+  }
+}
+
+function sortRecord(record: Record): Record {
+  const sorted: Record = {};
+  for (const key of Object.keys(record).sort()) {
+    sorted[key] = record[key];
+  }
+  return sorted;
+}
+
+/**
+ * Sanitise a preset: validate recipe + seed, drop unknown/non-finite
+ * overrides, clamp out-of-range values and quantise integer parameters.
+ * The returned preset always uses the current schema version.
+ *
+ * @throws {PresetValidationError} For an unknown recipe or non-integer seed.
+ */
+export function normalisePreset(preset: SoundPreset): SoundPreset {
+  const descriptors = getParamDescriptors(preset.recipe);
+
+  if (!Number.isInteger(preset.seed)) {
+    throw new PresetValidationError(
+      `Preset seed must be an integer (received ${String(preset.seed)})`,
+    );
+  }
+
+  const byName = new Map(descriptors.map((d) => [d.name, d]));
+  const overrides: Record = {};
+  for (const [name, raw] of Object.entries(preset.overrides ?? {})) {
+    const descriptor = byName.get(name);
+    if (!descriptor) {
+      continue; // documented: unknown parameter names are dropped
+    }
+    if (typeof raw !== "number" || !Number.isFinite(raw)) {
+      continue; // documented: non-finite overrides are dropped
+    }
+    overrides[name] = clampOverride(descriptor, raw);
+  }
+
+  return {
+    version: SOUND_PRESET_VERSION,
+    recipe: preset.recipe,
+    seed: preset.seed,
+    overrides: sortRecord(overrides),
+  };
+}
+
+/**
+ * Create a new preset. The result is normalised (unknown/invalid overrides
+ * dropped, in-range values clamped).
+ */
+export function createPreset(
+  recipe: string,
+  seed: number,
+  overrides: Record = {},
+): SoundPreset {
+  return normalisePreset({
+    version: SOUND_PRESET_VERSION,
+    recipe,
+    seed,
+    overrides,
+  });
+}
+
+/**
+ * Serialise a preset to canonical JSON.
+ *
+ * Field order is fixed and override keys are sorted, so equal presets always
+ * produce byte-identical output.
+ *
+ * @throws {PresetValidationError} If an override is non-finite (JSON cannot
+ *   represent `NaN`/`Infinity`).
+ */
+export function serialisePreset(preset: SoundPreset): string {
+  const overrides: Record = {};
+  for (const key of Object.keys(preset.overrides).sort()) {
+    const value = preset.overrides[key];
+    if (typeof value !== "number" || !Number.isFinite(value)) {
+      throw new PresetValidationError(
+        `Cannot serialise non-finite override '${key}'`,
+      );
+    }
+    overrides[key] = value;
+  }
+
+  return JSON.stringify({
+    version: preset.version,
+    recipe: preset.recipe,
+    seed: preset.seed,
+    overrides,
+  });
+}
+
+/**
+ * Parse and strictly validate a preset from JSON.
+ *
+ * @throws {PresetValidationError} For invalid JSON, an unsupported version, an
+ *   unknown recipe, a non-integer seed, an unknown parameter, a non-finite
+ *   value, an out-of-range value or a non-integer value for an integer
+ *   parameter.
+ */
+export function parsePreset(json: string): SoundPreset {
+  let raw: unknown;
+  try {
+    raw = JSON.parse(json);
+  } catch (error) {
+    throw new PresetValidationError(
+      `Invalid preset JSON: ${(error as Error).message}`,
+    );
+  }
+
+  if (!isRecord(raw)) {
+    throw new PresetValidationError("Preset must be a JSON object");
+  }
+  if (raw.version !== SOUND_PRESET_VERSION) {
+    throw new PresetValidationError(
+      `Unsupported preset version: ${String(raw.version)} ` +
+        `(expected ${SOUND_PRESET_VERSION})`,
+    );
+  }
+  if (typeof raw.recipe !== "string" || raw.recipe.length === 0) {
+    throw new PresetValidationError("Preset recipe must be a non-empty string");
+  }
+  if (typeof raw.seed !== "number" || !Number.isInteger(raw.seed)) {
+    throw new PresetValidationError(
+      `Preset seed must be an integer (received ${String(raw.seed)})`,
+    );
+  }
+  if (raw.overrides !== undefined && !isRecord(raw.overrides)) {
+    throw new PresetValidationError("Preset overrides must be an object");
+  }
+
+  const descriptors = getParamDescriptors(raw.recipe);
+  const byName = new Map(descriptors.map((d) => [d.name, d]));
+  const overrides: Record = {};
+  for (const [name, value] of Object.entries(raw.overrides ?? {})) {
+    const descriptor = byName.get(name);
+    if (!descriptor) {
+      throw new PresetValidationError(
+        `Unknown parameter '${name}' for recipe '${raw.recipe}'`,
+      );
+    }
+    if (typeof value !== "number") {
+      throw new PresetValidationError(
+        `Override '${name}' must be a finite number`,
+      );
+    }
+    assertOverrideValid(descriptor, value);
+    overrides[name] = value;
+  }
+
+  return {
+    version: SOUND_PRESET_VERSION,
+    recipe: raw.recipe,
+    seed: raw.seed,
+    overrides: sortRecord(overrides),
+  };
+}
+
+/** Deep-equality for presets (order-independent). */
+export function presetsEqual(a: SoundPreset, b: SoundPreset): boolean {
+  if (
+    a.version !== b.version ||
+    a.recipe !== b.recipe ||
+    a.seed !== b.seed
+  ) {
+    return false;
+  }
+  const aKeys = Object.keys(a.overrides);
+  const bKeys = Object.keys(b.overrides);
+  if (aKeys.length !== bKeys.length) {
+    return false;
+  }
+  return aKeys.every((key) => Object.is(a.overrides[key], b.overrides[key]));
+}
+
+/**
+ * Seed-derived baseline parameter values for a recipe, reusing the recipe
+ * registry's `getParams(rng)` — parameter definitions are never duplicated.
+ */
+export function getBaselineParams(
+  recipe: string,
+  seed: number,
+): Record {
+  const registration = registry.getRegistration(recipe);
+  if (!registration) {
+    throw new PresetValidationError(`Unknown recipe: ${recipe}`);
+  }
+  return { ...registration.getParams(createRng(seed)) };
+}
+
+/** Baseline parameters with the preset's overrides applied on top. */
+export function getEffectiveParams(
+  preset: SoundPreset,
+): Record {
+  return {
+    ...getBaselineParams(preset.recipe, preset.seed),
+    ...preset.overrides,
+  };
+}
diff --git a/web/src/widgets/README.md b/web/src/widgets/README.md
new file mode 100644
index 00000000..bd11180e
--- /dev/null
+++ b/web/src/widgets/README.md
@@ -0,0 +1,51 @@
+# Widget kit
+
+Framework-agnostic, accessible, disposable controls for the SoundEditor. A
+widget knows nothing about recipes or audio — it takes typed bounds and emits a
+numeric value change.
+
+| Factory | Kind | Value |
+| --- | --- | --- |
+| `createSlider(bounds)` | range slider | `number` |
+| `createRotary(bounds)` | rotary dial | `number` |
+| `createXYPad(bounds)` | XY touchpad | `{ x, y }` |
+| `createToggleSelect(bounds)` | toggle / select | `number` (one of `options`) |
+
+## Contract
+
+```ts
+const slider = createSlider({
+  min: 200, max: 2000, step: 10, unit: "Hz",
+  value: 500, label: "Carrier Frequency", name: "carrierFreq",
+});
+
+slider.element;                 // root element (not attached by the factory)
+slider.getValue();              // 500
+slider.setValue(700);           // clamps + renders; never emits
+const off = slider.onChange((v) => console.log(v)); // keyboard/pointer changes
+slider.element.addEventListener("change", (e) => console.log(e.detail.value));
+off();
+slider.dispose();               // removes listeners; emits nothing afterwards
+```
+
+- **Typed bounds** — `{ min, max, step?, unit?, value?, label, name? }`. `step`
+  defaults to 1/100th of the range. Values are clamped to `[min, max]` and
+  quantised to `step` (floating-point noise removed).
+- **Change events** — a `change` `CustomEvent` (`detail.value`) is dispatched on
+  the root element, and `onChange` listeners are invoked. Events fire only when
+  the value actually changes; `setValue` is programmatic and silent.
+- **Accessibility** — slider/rotary use `role="slider"` with
+  `aria-label`/`aria-valuemin`/`aria-valuemax`/`aria-valuenow`/`aria-valuetext`.
+  The XY pad is a labelled `role="group"` containing two `role="slider"` axes,
+  each carrying its own `unit` (configured per axis via `x.unit`/`y.unit`) in
+  `aria-valuetext`.
+  Toggle/select is a labelled `role="listbox"` of `role="option"` children with
+  `aria-selected`.
+- **Keyboard** — Arrow keys (slider/rotary: ±1 step, Home/End; XY pad: per
+  axis), PageUp/PageDown (±10 steps), toggle/select: arrows move, Home/End jump,
+  Enter/Space toggle a two-option control.
+- **Disposal** — `dispose()` removes listeners and is idempotent. Widgets create
+  no globals and inject no global CSS.
+
+Component tests run under happy-dom via `// @vitest-environment happy-dom` and
+are run with `npm test --prefix web`.
diff --git a/web/src/widgets/__tests__/widgets.test.ts b/web/src/widgets/__tests__/widgets.test.ts
new file mode 100644
index 00000000..c0e127fb
--- /dev/null
+++ b/web/src/widgets/__tests__/widgets.test.ts
@@ -0,0 +1,591 @@
+// @vitest-environment happy-dom
+/**
+ * Widget kit tests: clamping/quantisation, keyboard operation, ARIA exposure,
+ * programmatic `setValue` semantics and disposal.
+ *
+ * AC (TF-0MUV11U9D0087TTS): four widgets; bounds + change events; clamping and
+ * step quantisation; keyboard operability; ARIA roles/values; no spurious
+ * programmatic change; disposable and no globals/global CSS.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+  clampAxis,
+  clampToBounds,
+  createRotary,
+  createSlider,
+  createToggleSelect,
+  createXYPad,
+  formatValue,
+  quantise,
+  resolveStep,
+} from "../index.js";
+
+function press(element: HTMLElement, key: string): void {
+  element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
+}
+
+afterEach(() => {
+  document.body.innerHTML = "";
+  vi.restoreAllMocks();
+});
+
+describe("clamping and quantisation", () => {
+  it("clamps below min and above max", () => {
+    const bounds = { min: 0, max: 10, step: 0.5, label: "Level" };
+    expect(clampToBounds(-5, bounds)).toBe(0);
+    expect(clampToBounds(99, bounds)).toBe(10);
+  });
+
+  it("quantises to the nearest step", () => {
+    const bounds = { min: 0, max: 1, step: 0.1, label: "Mix" };
+    expect(clampToBounds(0.34, bounds)).toBe(0.3);
+    expect(clampToBounds(0.36, bounds)).toBe(0.4);
+    expect(clampToBounds(0.3, bounds)).toBe(0.3);
+  });
+
+  it("removes floating-point noise from stepped values", () => {
+    expect(quantise(0.1 + 0.2, 0, 0.1)).toBe(0.3);
+    const bounds = { min: 0, max: 1, step: 0.1, label: "Mix" };
+    expect(clampToBounds(0.1 + 0.2, bounds)).toBe(0.3);
+  });
+
+  it("derives a default step of 1/100th of the range", () => {
+    expect(resolveStep({ min: 0, max: 100 })).toBe(1);
+    expect(resolveStep({ min: 0, max: 5 })).toBe(0.05);
+  });
+
+  it("clamps XY axes", () => {
+    expect(clampAxis(999, { min: 10, max: 20 })).toBe(20);
+    expect(clampAxis(0, { min: 10, max: 20 })).toBe(10);
+  });
+
+  it("formats values with units", () => {
+    expect(formatValue(500, "Hz")).toBe("500 Hz");
+    expect(formatValue(12.5)).toBe("12.5");
+  });
+});
+
+describe("slider", () => {
+  it("exposes role, label and value range", () => {
+    const slider = createSlider({ min: 200, max: 2000, step: 10, unit: "Hz", value: 500, label: "Carrier" });
+    const el = slider.element;
+    expect(el.getAttribute("role")).toBe("slider");
+    expect(el.getAttribute("aria-label")).toBe("Carrier");
+    expect(el.getAttribute("aria-valuemin")).toBe("200");
+    expect(el.getAttribute("aria-valuemax")).toBe("2000");
+    expect(el.getAttribute("aria-valuenow")).toBe("500");
+    expect(el.getAttribute("aria-valuetext")).toBe("500 Hz");
+    expect(el.tabIndex).toBe(0);
+    slider.dispose();
+  });
+
+  it("is keyboard operable", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 10, value: 50, label: "Level" });
+    press(slider.element, "ArrowRight");
+    expect(slider.getValue()).toBe(60);
+    press(slider.element, "ArrowLeft");
+    expect(slider.getValue()).toBe(50);
+    press(slider.element, "Home");
+    expect(slider.getValue()).toBe(0);
+    press(slider.element, "End");
+    expect(slider.getValue()).toBe(100);
+    slider.dispose();
+  });
+
+  it("emits only when the value actually changes and not on setValue", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 10, value: 50, label: "Level" });
+    const listener = vi.fn();
+    slider.onChange(listener);
+
+    slider.setValue(80);
+    expect(slider.getValue()).toBe(80);
+    expect(listener).not.toHaveBeenCalled();
+
+    press(slider.element, "ArrowRight");
+    expect(listener).toHaveBeenCalledTimes(1);
+    expect(listener).toHaveBeenLastCalledWith(90);
+
+    press(slider.element, "End");
+    expect(listener).toHaveBeenCalledTimes(2); // 90 -> 100
+    press(slider.element, "End");
+    expect(listener).toHaveBeenCalledTimes(2); // unchanged
+
+    slider.dispose();
+  });
+
+  it("dispatches a change CustomEvent with the value", () => {
+    const slider = createSlider({ min: 0, max: 10, step: 1, value: 5, label: "Level" });
+    const handler = vi.fn();
+    slider.element.addEventListener("change", handler);
+    press(slider.element, "ArrowRight");
+    expect(handler).toHaveBeenCalledTimes(1);
+    expect((handler.mock.calls[0][0] as CustomEvent).detail.value).toBe(6);
+    slider.dispose();
+  });
+
+  it("stops emitting after dispose and is idempotent", () => {
+    const slider = createSlider({ min: 0, max: 10, step: 1, value: 5, label: "Level" });
+    const listener = vi.fn();
+    slider.onChange(listener);
+    slider.dispose();
+    slider.dispose();
+    press(slider.element, "ArrowRight");
+    expect(listener).not.toHaveBeenCalled();
+  });
+});
+
+describe("rotary dial", () => {
+  it("exposes slider ARIA and responds to arrow keys", () => {
+    const dial = createRotary({ min: 0, max: 360, step: 15, unit: "deg", value: 90, label: "Pan" });
+    expect(dial.element.getAttribute("role")).toBe("slider");
+    expect(dial.element.getAttribute("aria-valuenow")).toBe("90");
+    press(dial.element, "ArrowUp");
+    expect(dial.getValue()).toBe(105);
+    expect(dial.element.getAttribute("aria-valuetext")).toBe("105 deg");
+    dial.dispose();
+  });
+
+  it("does not emit on setValue", () => {
+    const dial = createRotary({ min: 0, max: 1, step: 0.1, value: 0.5, label: "Mix" });
+    const listener = vi.fn();
+    dial.onChange(listener);
+    dial.setValue(0.9);
+    expect(dial.getValue()).toBe(0.9);
+    expect(listener).not.toHaveBeenCalled();
+    dial.dispose();
+  });
+});
+
+describe("XY pad", () => {
+  it("exposes a labelled group with two slider axes", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 10, value: 20, label: "X" },
+      y: { min: 0, max: 100, step: 10, value: 80, label: "Y" },
+    });
+    expect(pad.element.getAttribute("role")).toBe("group");
+    expect(pad.element.querySelectorAll('[role="slider"]').length).toBe(2);
+    expect(pad.getValue()).toEqual({ x: 20, y: 80 });
+    expect(pad.element.querySelector('[aria-label="Position X"]')?.getAttribute("aria-valuenow")).toBe("20");
+    expect(pad.element.querySelector('[aria-label="Position Y"]')?.getAttribute("aria-valuenow")).toBe("80");
+    pad.dispose();
+  });
+
+  it("exposes each axis unit in aria-valuetext", () => {
+    const pad = createXYPad({
+      label: "Tone",
+      x: { min: 200, max: 2000, step: 10, unit: "Hz", value: 500, label: "carrierFreq" },
+      y: { min: 50, max: 500, step: 10, unit: "Hz", value: 100, label: "modulatorFreq" },
+    });
+    expect(pad.element.querySelector('[aria-label="Tone X"]')?.getAttribute("aria-valuetext")).toBe("500 Hz");
+    expect(pad.element.querySelector('[aria-label="Tone Y"]')?.getAttribute("aria-valuetext")).toBe("100 Hz");
+    pad.dispose();
+  });
+
+  it("does not use the axis label as a pseudo-unit when no unit is given", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 10, value: 20, label: "carrierFreq" },
+      y: { min: 0, max: 100, step: 10, value: 80, label: "modulatorFreq" },
+    });
+    const valuetext = pad.element.querySelector('[aria-label="Position X"]')?.getAttribute("aria-valuetext");
+    expect(valuetext).toBe("20");
+    expect(valuetext).not.toContain("carrierFreq");
+    pad.dispose();
+  });
+
+  it("changes each axis with the keyboard", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 10, value: 20 },
+      y: { min: 0, max: 100, step: 10, value: 80 },
+    });
+    const xAxis = pad.element.querySelector(".tf-xy-pad__axis--x")!;
+    const yAxis = pad.element.querySelector(".tf-xy-pad__axis--y")!;
+
+    const listener = vi.fn();
+    pad.onChange(listener);
+
+    press(xAxis, "ArrowRight");
+    expect(pad.getValue()).toEqual({ x: 30, y: 80 });
+    press(yAxis, "ArrowDown");
+    expect(pad.getValue()).toEqual({ x: 30, y: 70 });
+    expect(listener).toHaveBeenCalledTimes(2);
+
+    pad.setValue({ x: 0, y: 100 });
+    expect(pad.getValue()).toEqual({ x: 0, y: 100 });
+    expect(listener).toHaveBeenCalledTimes(2);
+    pad.dispose();
+  });
+});
+
+describe("toggle/select", () => {
+  const options = [
+    { label: "Sine", value: 0 },
+    { label: "Square", value: 1 },
+    { label: "Saw", value: 2 },
+  ];
+
+  it("exposes listbox/option roles", () => {
+    const toggle = createToggleSelect({ label: "Waveform", options, value: 1 });
+    expect(toggle.element.getAttribute("role")).toBe("listbox");
+    expect(toggle.element.getAttribute("aria-label")).toBe("Waveform");
+    const opts = toggle.element.querySelectorAll('[role="option"]');
+    expect(opts.length).toBe(3);
+    expect(opts[1].getAttribute("aria-selected")).toBe("true");
+    toggle.dispose();
+  });
+
+  it("navigates with arrows, Home and End", () => {
+    const toggle = createToggleSelect({ label: "Waveform", options, value: 0 });
+    press(toggle.element, "ArrowRight");
+    expect(toggle.getValue()).toBe(1);
+    press(toggle.element, "End");
+    expect(toggle.getValue()).toBe(2);
+    press(toggle.element, "Home");
+    expect(toggle.getValue()).toBe(0);
+    toggle.dispose();
+  });
+
+  it("toggles a two-option control with Enter/Space", () => {
+    const toggle = createToggleSelect({
+      label: "Enabled",
+      options: [
+        { label: "Off", value: 0 },
+        { label: "On", value: 1 },
+      ],
+      value: 0,
+    });
+    press(toggle.element, "Enter");
+    expect(toggle.getValue()).toBe(1);
+    press(toggle.element, " ");
+    expect(toggle.getValue()).toBe(0);
+    toggle.dispose();
+  });
+
+  it("does not emit on setValue and snaps to the nearest option", () => {
+    const toggle = createToggleSelect({ label: "Waveform", options, value: 0 });
+    const listener = vi.fn();
+    toggle.onChange(listener);
+    toggle.setValue(2);
+    expect(toggle.getValue()).toBe(2);
+    toggle.setValue(1.6);
+    expect(toggle.getValue()).toBe(2);
+    expect(listener).not.toHaveBeenCalled();
+    toggle.dispose();
+  });
+
+  it("rejects an empty option list", () => {
+    expect(() => createToggleSelect({ label: "Empty", options: [] })).toThrow(/at least one option/);
+  });
+});
+
+describe("embedding hygiene", () => {
+  it("creates no globals and writes no global styles", () => {
+    const before = new Set(Object.keys(globalThis));
+    const slider = createSlider({ min: 0, max: 1, value: 0.5, label: "Mix" });
+    const pad = createXYPad({
+      label: "Pos",
+      x: { min: 0, max: 1 },
+      y: { min: 0, max: 1 },
+    });
+    document.body.append(slider.element, pad.element);
+
+    expect(document.head.querySelectorAll("style").length).toBe(0);
+    const after = Object.keys(globalThis).filter((key) => !before.has(key));
+    expect(after).toEqual([]);
+
+    slider.dispose();
+    pad.dispose();
+  });
+});
+
+/* ── Pointer drag interaction tests ──────────────────────── */
+
+describe("slider pointer drag", () => {
+  function mockRect(track: HTMLElement, left: number, width: number): void {
+    vi.spyOn(track, "getBoundingClientRect").mockReturnValue({
+      left, width, top: 0, bottom: 1, right: left + width, x: left, y: 0, height: 1,
+      toJSON: () => ({}),
+    } as DOMRect);
+  }
+
+  it("pointer drag on track changes value", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 1, value: 50, label: "Level" });
+    const track = slider.element.querySelector(".tf-slider__track")!;
+    document.body.append(slider.element);
+    mockRect(track, 0, 100);
+
+    const changeHandler = vi.fn();
+    slider.element.addEventListener("change", changeHandler);
+
+    // Pointer at 75% of track → value 75
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: 75, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 75, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(slider.getValue()).toBe(75);
+    expect(changeHandler).toHaveBeenCalledTimes(1);
+
+    slider.dispose();
+  });
+
+  it("pointer drag on track respects clamping", () => {
+    const slider = createSlider({ min: 10, max: 90, step: 1, value: 50, label: "Level" });
+    const track = slider.element.querySelector(".tf-slider__track")!;
+    document.body.append(slider.element);
+    mockRect(track, 0, 100);
+
+    // Pointer far left → clamped to min
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: -200, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: -200, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(slider.getValue()).toBe(10);
+
+    // Pointer far right → clamped to max
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: 300, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 300, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(slider.getValue()).toBe(90);
+
+    slider.dispose();
+  });
+
+  it("pointer drag on thumb changes value", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 1, value: 50, label: "Level" });
+    const thumb = slider.element.querySelector(".tf-slider__thumb")!;
+    document.body.append(slider.element);
+    mockRect(slider.element.querySelector(".tf-slider__track")!, 0, 100);
+
+    const changeHandler = vi.fn();
+    slider.element.addEventListener("change", changeHandler);
+
+    thumb.dispatchEvent(new PointerEvent("pointerdown", { clientX: 25, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 25, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(slider.getValue()).toBe(25);
+    expect(changeHandler).toHaveBeenCalledTimes(1);
+
+    slider.dispose();
+  });
+
+  it("no change event when pointer drag results in no value change", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 1, value: 50, label: "Level" });
+    const track = slider.element.querySelector(".tf-slider__track")!;
+    document.body.append(slider.element);
+    mockRect(track, 50, 100);
+
+    const changeHandler = vi.fn();
+    slider.element.addEventListener("change", changeHandler);
+
+    // Start at 50% of track (value 50), move to same position
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: 100, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(changeHandler).not.toHaveBeenCalled();
+
+    slider.dispose();
+  });
+
+  it("pointer move during drag updates the value live", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 1, value: 50, label: "Level" });
+    const track = slider.element.querySelector(".tf-slider__track")!;
+    document.body.append(slider.element);
+    mockRect(track, 0, 100);
+
+    const changeHandler = vi.fn();
+    slider.element.addEventListener("change", changeHandler);
+
+    // Pointer down at 25% of track
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: 25, bubbles: true }));
+    expect(slider.getValue()).toBe(25);
+    expect(changeHandler).toHaveBeenCalledTimes(1);
+
+    // Move further to 50% — live update during drag
+    slider.element.dispatchEvent(new PointerEvent("pointermove", { clientX: 50, bubbles: true }));
+    expect(slider.getValue()).toBe(50);
+    expect(changeHandler).toHaveBeenCalledTimes(2);
+
+    // Release — no additional event
+    slider.element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+    expect(changeHandler).toHaveBeenCalledTimes(2);
+
+    slider.dispose();
+  });
+
+  it("stops updating after dispose", () => {
+    const slider = createSlider({ min: 0, max: 100, step: 1, value: 50, label: "Level" });
+    const track = slider.element.querySelector(".tf-slider__track")!;
+    document.body.append(slider.element);
+    mockRect(track, 0, 100);
+
+    track.dispatchEvent(new PointerEvent("pointerdown", { clientX: 25, bubbles: true }));
+    slider.dispose();
+
+    // Even if we send move/up, no change
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 75, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(slider.getValue()).toBe(25);
+
+    slider.dispose(); // idempotent
+  });
+});
+
+describe("rotary pointer drag", () => {
+  it("horizontal drag rotates dial and changes value", () => {
+    const dial = createRotary({ min: 0, max: 100, step: 1, value: 0, label: "Pan" });
+    const dialEl = dial.element.querySelector(".tf-rotary__dial")!;
+    document.body.append(dial.element);
+
+    const changeHandler = vi.fn();
+    dial.element.addEventListener("change", changeHandler);
+
+    // 75px right of a 150px full-range sweep → +50 on a 0–100 range.
+    dialEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 0, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointermove", { clientX: 75, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(dial.getValue()).toBe(50);
+    expect(changeHandler).toHaveBeenCalledTimes(1);
+
+    dial.dispose();
+  });
+
+  it("horizontal drag clamps to min and max values", () => {
+    const dial = createRotary({ min: 0, max: 100, step: 1, value: 50, label: "Pan" });
+    const dialEl = dial.element.querySelector(".tf-rotary__dial")!;
+    document.body.append(dial.element);
+
+    // Drag far right → clamps to max.
+    dialEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 0, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointermove", { clientX: 1000, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(dial.getValue()).toBe(100);
+
+    // Drag far left → clamps to min.
+    dialEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 0, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointermove", { clientX: -1000, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(dial.getValue()).toBe(0);
+
+    dial.dispose();
+  });
+
+  it("stops updating after dispose", () => {
+    const dial = createRotary({ min: 0, max: 100, step: 1, value: 50, label: "Pan" });
+    const dialEl = dial.element.querySelector(".tf-rotary__dial")!;
+    document.body.append(dial.element);
+
+    dialEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 0, bubbles: true }));
+    dial.dispose();
+
+    dial.element.dispatchEvent(new PointerEvent("pointermove", { clientX: 1000, bubbles: true }));
+    dial.element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    // Value should stay at the starting value, unchanged by the drag.
+    expect(dial.getValue()).toBe(50);
+
+    dial.dispose();
+  });
+});
+
+describe("xy pad pointer drag", () => {
+  function mockRect(pad: HTMLElement, left: number, top: number, width: number): void {
+    vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
+      left, top, width, height: width, right: left + width, x: left, y: top,
+      toJSON: () => ({}),
+    } as DOMRect);
+  }
+
+  it("pointer drag moves thumb and updates values", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 1, value: 50 },
+      y: { min: 0, max: 100, step: 1, value: 50 },
+    });
+    const padEl = pad.element.querySelector(".tf-xy-pad__pad")!;
+    document.body.append(pad.element);
+    mockRect(padEl, 0, 0, 100);
+
+    const changeHandler = vi.fn();
+    pad.element.addEventListener("change", changeHandler);
+
+    // Pointer at bottom-right of pad → x=100, y=0 (Y is inverted)
+    padEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 100, clientY: 100, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 100, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(pad.getValue()).toEqual({ x: 100, y: 0 });
+    expect(changeHandler).toHaveBeenCalledTimes(1);
+
+    pad.dispose();
+  });
+
+  it("pointer drag clamps values to axis bounds", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 10, max: 90, step: 1, value: 50 },
+      y: { min: 10, max: 90, step: 1, value: 50 },
+    });
+    const padEl = pad.element.querySelector(".tf-xy-pad__pad")!;
+    document.body.append(pad.element);
+    mockRect(padEl, 0, 0, 100);
+
+    // Pointer far outside → clamped
+    padEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: -200, clientY: -200, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: -200, clientY: -200, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(pad.getValue()).toEqual({ x: 10, y: 90 }); // x clamped to min, y clamped to max (inverted)
+
+    pad.dispose();
+  });
+
+  it("pointer drag to center of pad", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 1, value: 50 },
+      y: { min: 0, max: 100, step: 1, value: 50 },
+    });
+    const padEl = pad.element.querySelector(".tf-xy-pad__pad")!;
+    document.body.append(pad.element);
+    mockRect(padEl, 0, 0, 100);
+
+    padEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 50, clientY: 50, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 50, clientY: 50, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    // Center of pad: xRatio=0.5 → x=50, yRatio=0.5 → y=50
+    expect(pad.getValue()).toEqual({ x: 50, y: 50 });
+
+    pad.dispose();
+  });
+
+  it("stops updating after dispose", () => {
+    const pad = createXYPad({
+      label: "Position",
+      x: { min: 0, max: 100, step: 1, value: 50 },
+      y: { min: 0, max: 100, step: 1, value: 50 },
+    });
+    const padEl = pad.element.querySelector(".tf-xy-pad__pad")!;
+    document.body.append(pad.element);
+    mockRect(padEl, 0, 0, 100);
+
+    padEl.dispatchEvent(new PointerEvent("pointerdown", { clientX: 25, clientY: 25, bubbles: true }));
+    pad.dispose();
+
+    document.dispatchEvent(new PointerEvent("pointermove", { clientX: 75, clientY: 75, bubbles: true }));
+    document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
+
+    expect(pad.getValue()).toEqual({ x: 25, y: 75 }); // value at time of pointer down, unchanged
+
+    pad.dispose();
+  });
+});
diff --git a/web/src/widgets/base-widget.ts b/web/src/widgets/base-widget.ts
new file mode 100644
index 00000000..9536f5e0
--- /dev/null
+++ b/web/src/widgets/base-widget.ts
@@ -0,0 +1,144 @@
+/**
+ * Shared base for scalar widgets (slider, rotary dial).
+ *
+ * Owns value clamping/quantisation, ARIA state, change notification and
+ * disposal. Subclasses build the visible DOM and implement `render()` (visual
+ * + ARIA update) and `onDispose()` (remove their own listeners).
+ */
+
+import {
+  clampToBounds,
+  formatValue,
+  resolveStep,
+  type ScalarBounds,
+  type ScalarWidget,
+  type Unsubscribe,
+  type WidgetChangeDetail,
+} from "./widget-types.js";
+
+export abstract class ScalarWidgetBase implements ScalarWidget {
+  readonly element: HTMLElement;
+  protected readonly bounds: ScalarBounds;
+  protected readonly step: number;
+  protected currentValue: number;
+
+  private readonly listeners = new Set<(value: number) => void>();
+  private disposed = false;
+
+  private readonly handleKeyDown = (event: KeyboardEvent): void => {
+    if (this.disposed) {
+      return;
+    }
+    switch (event.key) {
+      case "ArrowRight":
+      case "ArrowUp":
+        this.nudge(1);
+        break;
+      case "ArrowLeft":
+      case "ArrowDown":
+        this.nudge(-1);
+        break;
+      case "PageUp":
+        this.nudge(10);
+        break;
+      case "PageDown":
+        this.nudge(-10);
+        break;
+      case "Home":
+        this.commit(this.bounds.min);
+        break;
+      case "End":
+        this.commit(this.bounds.max);
+        break;
+      default:
+        return;
+    }
+    event.preventDefault();
+  };
+
+  constructor(tag: string, className: string, bounds: ScalarBounds) {
+    this.element = document.createElement(tag);
+    this.element.className = className;
+    this.element.setAttribute("role", "slider");
+    this.element.tabIndex = 0;
+
+    this.bounds = bounds;
+    this.step = resolveStep(bounds);
+    this.currentValue = clampToBounds(bounds.value ?? bounds.min, bounds);
+
+    this.element.setAttribute("aria-label", bounds.label);
+    this.element.setAttribute("aria-valuemin", String(bounds.min));
+    this.element.setAttribute("aria-valuemax", String(bounds.max));
+    this.element.addEventListener("keydown", this.handleKeyDown);
+  }
+
+  getValue(): number {
+    return this.currentValue;
+  }
+
+  /** Programmatic set: clamps + renders, never emits a change event. */
+  setValue(value: number): void {
+    this.currentValue = clampToBounds(value, this.bounds);
+    this.render();
+  }
+
+  onChange(listener: (value: number) => void): Unsubscribe {
+    this.listeners.add(listener);
+    return () => {
+      this.listeners.delete(listener);
+    };
+  }
+
+  dispose(): void {
+    if (this.disposed) {
+      return;
+    }
+    this.disposed = true;
+    this.listeners.clear();
+    this.element.removeEventListener("keydown", this.handleKeyDown);
+    this.onDispose();
+    this.element.remove();
+  }
+
+  protected isDisposed(): boolean {
+    return this.disposed;
+  }
+
+  /** Apply an interaction value; emits only when the value actually changes. */
+  protected commit(next: number): void {
+    if (this.disposed) {
+      return;
+    }
+    const clamped = clampToBounds(next, this.bounds);
+    if (Object.is(clamped, this.currentValue)) {
+      return;
+    }
+    this.currentValue = clamped;
+    this.render();
+    const detail: WidgetChangeDetail = {
+      value: clamped,
+      name: this.bounds.name,
+    };
+    this.element.dispatchEvent(new CustomEvent("change", { detail }));
+    for (const listener of [...this.listeners]) {
+      listener(clamped);
+    }
+  }
+
+  /** Move by a whole number of steps. */
+  protected nudge(steps: number): void {
+    this.commit(this.currentValue + steps * this.step);
+  }
+
+  /** Update `aria-valuenow`/`aria-valuetext` from the current value. */
+  protected syncAria(): void {
+    this.element.setAttribute("aria-valuenow", String(this.currentValue));
+    this.element.setAttribute(
+      "aria-valuetext",
+      formatValue(this.currentValue, this.bounds.unit),
+    );
+  }
+
+  protected abstract render(): void;
+  protected abstract onDispose(): void;
+}
diff --git a/web/src/widgets/index.ts b/web/src/widgets/index.ts
new file mode 100644
index 00000000..920c29bc
--- /dev/null
+++ b/web/src/widgets/index.ts
@@ -0,0 +1,30 @@
+/**
+ * Widget kit barrel export.
+ *
+ * Framework-agnostic, accessible, disposable controls for the SoundEditor.
+ */
+
+export { createSlider } from "./slider.js";
+export { createRotary } from "./rotary.js";
+export { createXYPad } from "./xy-pad.js";
+export { createToggleSelect } from "./toggle-select.js";
+export {
+  DEFAULT_STEP_FRACTION,
+  clampAxis,
+  clampToBounds,
+  decimalsOf,
+  formatValue,
+  quantise,
+  resolveStep,
+  type ScalarBounds,
+  type ScalarWidget,
+  type ToggleOption,
+  type ToggleSelectBounds,
+  type Unsubscribe,
+  type Widget,
+  type WidgetChangeDetail,
+  type XYAxisBounds,
+  type XYPadBounds,
+  type XYPadValue,
+  type XYPadWidget,
+} from "./widget-types.js";
diff --git a/web/src/widgets/rotary.ts b/web/src/widgets/rotary.ts
new file mode 100644
index 00000000..b7b66154
--- /dev/null
+++ b/web/src/widgets/rotary.ts
@@ -0,0 +1,90 @@
+/**
+ * Rotary dial widget (plain DOM, accessible, pointer- and keyboard-operable).
+ *
+ * Renders a circular dial whose indicator rotates across a 270° arc. Pointer
+ * dragging moves the value horizontally (the standard DAW knob gesture);
+ * keyboard handling matches the slider.
+ */
+
+import { ScalarWidgetBase } from "./base-widget.js";
+import type { ScalarBounds, ScalarWidget } from "./widget-types.js";
+
+const MIN_ANGLE = -135;
+const MAX_ANGLE = 135;
+
+/** Horizontal pointer travel (px) that sweeps the full value range. */
+const DRAG_RANGE_PX = 150;
+
+class RotaryWidget extends ScalarWidgetBase {
+  private readonly indicator: HTMLElement;
+  private readonly dial: HTMLElement;
+  private pointerDragging = false;
+  private pointerStartX = 0;
+  private pointerStartValue = 0;
+
+  private readonly handlePointerDown = (event: PointerEvent): void => {
+    if (this.isDisposed()) {
+      return;
+    }
+    event.preventDefault();
+    this.pointerDragging = true;
+    this.pointerStartX = event.clientX;
+    this.pointerStartValue = this.currentValue;
+    document.addEventListener("pointermove", this.handlePointerMove);
+    document.addEventListener("pointerup", this.handlePointerUp);
+  };
+
+  private readonly handlePointerMove = (event: PointerEvent): void => {
+    if (this.isDisposed() || !this.pointerDragging) {
+      return;
+    }
+    event.preventDefault();
+    const delta = event.clientX - this.pointerStartX;
+    const span = this.bounds.max - this.bounds.min;
+    this.commit(this.pointerStartValue + (delta / DRAG_RANGE_PX) * span);
+  };
+
+  private readonly handlePointerUp = (): void => {
+    if (this.pointerDragging) {
+      this.pointerDragging = false;
+      document.removeEventListener("pointermove", this.handlePointerMove);
+      document.removeEventListener("pointerup", this.handlePointerUp);
+    }
+  };
+
+  constructor(bounds: ScalarBounds) {
+    super("div", "tf-widget tf-rotary", bounds);
+
+    this.dial = document.createElement("div");
+    this.dial.className = "tf-rotary__dial";
+
+    this.indicator = document.createElement("div");
+    this.indicator.className = "tf-rotary__indicator";
+
+    this.dial.appendChild(this.indicator);
+    this.dial.style.touchAction = "none";
+    this.dial.style.userSelect = "none";
+    this.dial.addEventListener("pointerdown", this.handlePointerDown);
+    this.element.appendChild(this.dial);
+    this.render();
+  }
+
+  protected render(): void {
+    const span = this.bounds.max - this.bounds.min;
+    const ratio = span > 0 ? (this.currentValue - this.bounds.min) / span : 0;
+    const angle = MIN_ANGLE + Math.min(1, Math.max(0, ratio)) * (MAX_ANGLE - MIN_ANGLE);
+    this.indicator.style.transform = `rotate(${angle}deg)`;
+    this.syncAria();
+  }
+
+  protected onDispose(): void {
+    this.dial.removeEventListener("pointerdown", this.handlePointerDown);
+    document.removeEventListener("pointermove", this.handlePointerMove);
+    document.removeEventListener("pointerup", this.handlePointerUp);
+  }
+}
+
+/** Create a rotary dial. */
+export function createRotary(bounds: ScalarBounds): ScalarWidget {
+  return new RotaryWidget(bounds);
+}
diff --git a/web/src/widgets/slider.ts b/web/src/widgets/slider.ts
new file mode 100644
index 00000000..55c9c8d1
--- /dev/null
+++ b/web/src/widgets/slider.ts
@@ -0,0 +1,93 @@
+/**
+ * Range slider widget (plain DOM, accessible, keyboard-operable).
+ */
+
+import { ScalarWidgetBase } from "./base-widget.js";
+import type { ScalarBounds, ScalarWidget } from "./widget-types.js";
+
+class SliderWidget extends ScalarWidgetBase {
+  private readonly fill: HTMLElement;
+  private readonly thumb: HTMLElement;
+  private readonly track: HTMLElement;
+  private pointerDragging = false;
+
+  private readonly handlePointerDown = (event: PointerEvent): void => {
+    if (this.isDisposed()) {
+      return;
+    }
+    const isTrack = event.target === this.track || this.track.contains(event.target as Node);
+    const isThumb = event.target === this.thumb || this.thumb.contains(event.target as Node);
+    if (!isTrack && !isThumb) {
+      return;
+    }
+    event.preventDefault();
+    this.pointerDragging = true;
+    this.updateFromPointer(event);
+    document.addEventListener("pointermove", this.handlePointerMove);
+    document.addEventListener("pointerup", this.handlePointerUp);
+  };
+
+  private readonly handlePointerMove = (event: PointerEvent): void => {
+    if (this.isDisposed() || !this.pointerDragging) {
+      return;
+    }
+    event.preventDefault();
+    this.updateFromPointer(event);
+  };
+
+  private readonly handlePointerUp = (): void => {
+    if (this.pointerDragging) {
+      this.pointerDragging = false;
+      document.removeEventListener("pointermove", this.handlePointerMove);
+      document.removeEventListener("pointerup", this.handlePointerUp);
+    }
+  };
+
+  private updateFromPointer(event: PointerEvent): void {
+    const rect = this.track.getBoundingClientRect();
+    const x = event.clientX - rect.left;
+    const ratio = rect.width > 0 ? x / rect.width : 0;
+    const next = this.bounds.min + ratio * (this.bounds.max - this.bounds.min);
+    this.commit(next);
+  }
+
+  constructor(bounds: ScalarBounds) {
+    super("div", "tf-widget tf-slider", bounds);
+
+    this.track = document.createElement("div");
+    this.track.className = "tf-slider__track";
+
+    this.fill = document.createElement("div");
+    this.fill.className = "tf-slider__fill";
+
+    this.thumb = document.createElement("div");
+    this.thumb.className = "tf-slider__thumb";
+
+    this.track.append(this.fill, this.thumb);
+    this.element.appendChild(this.track);
+    this.element.style.touchAction = "none";
+    this.element.style.userSelect = "none";
+    this.track.addEventListener("pointerdown", this.handlePointerDown);
+    this.render();
+  }
+
+  protected render(): void {
+    const span = this.bounds.max - this.bounds.min;
+    const ratio = span > 0 ? (this.currentValue - this.bounds.min) / span : 0;
+    const percent = `${Math.min(100, Math.max(0, ratio * 100))}%`;
+    this.fill.style.width = percent;
+    this.thumb.style.left = percent;
+    this.syncAria();
+  }
+
+  protected onDispose(): void {
+    this.track.removeEventListener("pointerdown", this.handlePointerDown);
+    document.removeEventListener("pointermove", this.handlePointerMove);
+    document.removeEventListener("pointerup", this.handlePointerUp);
+  }
+}
+
+/** Create a range slider. */
+export function createSlider(bounds: ScalarBounds): ScalarWidget {
+  return new SliderWidget(bounds);
+}
diff --git a/web/src/widgets/toggle-select.ts b/web/src/widgets/toggle-select.ts
new file mode 100644
index 00000000..994f33fa
--- /dev/null
+++ b/web/src/widgets/toggle-select.ts
@@ -0,0 +1,179 @@
+/**
+ * Toggle/select widget (plain DOM, accessible, keyboard-operable).
+ *
+ * Renders a `listbox` of discrete numeric options. Arrow keys move the
+ * selection, Home/End jump to the ends, and Enter/Space toggle a two-option
+ * control (or re-select the current option).
+ */
+
+import {
+  type ScalarWidget,
+  type ToggleOption,
+  type ToggleSelectBounds,
+  type Unsubscribe,
+  type WidgetChangeDetail,
+} from "./widget-types.js";
+
+class ToggleSelectWidget implements ScalarWidget {
+  readonly element: HTMLElement;
+
+  private readonly options: ToggleOption[];
+  private readonly optionElements: HTMLElement[] = [];
+  private readonly listeners = new Set<(value: number) => void>();
+
+  private currentValue: number;
+  private disposed = false;
+
+  private readonly boundHandleKey = (event: KeyboardEvent): void => {
+    this.handleKey(event);
+  };
+
+  private readonly boundHandleClick = (event: MouseEvent): void => {
+    const target = event.target as HTMLElement | null;
+    const optionElement = target?.closest('[role="option"]') as HTMLElement | null;
+    if (!optionElement) {
+      return;
+    }
+    const index = this.optionElements.indexOf(optionElement);
+    if (index >= 0) {
+      this.selectIndex(index);
+    }
+  };
+
+  constructor(bounds: ToggleSelectBounds) {
+    if (!bounds.options || bounds.options.length === 0) {
+      throw new Error("ToggleSelect requires at least one option");
+    }
+    this.options = [...bounds.options];
+    this.currentValue = this.nearestOption(bounds.value ?? this.options[0].value);
+
+    this.element = document.createElement("div");
+    this.element.className = "tf-widget tf-toggle-select";
+    this.element.setAttribute("role", "listbox");
+    this.element.setAttribute("aria-label", bounds.label);
+    this.element.tabIndex = 0;
+
+    for (const option of this.options) {
+      const optionElement = document.createElement("div");
+      optionElement.className = "tf-toggle-select__option";
+      optionElement.setAttribute("role", "option");
+      optionElement.textContent = option.label;
+      this.optionElements.push(optionElement);
+      this.element.appendChild(optionElement);
+    }
+
+    this.element.addEventListener("keydown", this.boundHandleKey);
+    this.element.addEventListener("click", this.boundHandleClick);
+    this.render();
+  }
+
+  getValue(): number {
+    return this.currentValue;
+  }
+
+  setValue(value: number): void {
+    this.currentValue = this.nearestOption(value);
+    this.render();
+  }
+
+  onChange(listener: (value: number) => void): Unsubscribe {
+    this.listeners.add(listener);
+    return () => {
+      this.listeners.delete(listener);
+    };
+  }
+
+  dispose(): void {
+    if (this.disposed) {
+      return;
+    }
+    this.disposed = true;
+    this.listeners.clear();
+    this.element.removeEventListener("keydown", this.boundHandleKey);
+    this.element.removeEventListener("click", this.boundHandleClick);
+    this.element.remove();
+  }
+
+  private nearestOption(value: number): number {
+    let best = this.options[0].value;
+    let bestDistance = Number.POSITIVE_INFINITY;
+    for (const option of this.options) {
+      const distance = Math.abs(option.value - value);
+      if (distance < bestDistance) {
+        bestDistance = distance;
+        best = option.value;
+      }
+    }
+    return best;
+  }
+
+  private currentIndex(): number {
+    const index = this.options.findIndex((o) => o.value === this.currentValue);
+    return index < 0 ? 0 : index;
+  }
+
+  private selectValue(value: number): void {
+    if (this.disposed || Object.is(value, this.currentValue)) {
+      return;
+    }
+    this.currentValue = value;
+    this.render();
+    const detail: WidgetChangeDetail = { value, name: undefined };
+    this.element.dispatchEvent(new CustomEvent("change", { detail }));
+    for (const listener of [...this.listeners]) {
+      listener(value);
+    }
+  }
+
+  private selectIndex(index: number): void {
+    const clamped = Math.min(this.options.length - 1, Math.max(0, index));
+    this.selectValue(this.options[clamped].value);
+  }
+
+  private handleKey(event: KeyboardEvent): void {
+    if (this.disposed) {
+      return;
+    }
+    switch (event.key) {
+      case "ArrowDown":
+      case "ArrowRight":
+        this.selectIndex(this.currentIndex() + 1);
+        break;
+      case "ArrowUp":
+      case "ArrowLeft":
+        this.selectIndex(this.currentIndex() - 1);
+        break;
+      case "Home":
+        this.selectIndex(0);
+        break;
+      case "End":
+        this.selectIndex(this.options.length - 1);
+        break;
+      case "Enter":
+      case " ":
+        if (this.options.length === 2) {
+          this.selectIndex(this.currentIndex() === 0 ? 1 : 0);
+        } else {
+          this.selectIndex(this.currentIndex());
+        }
+        break;
+      default:
+        return;
+    }
+    event.preventDefault();
+  }
+
+  private render(): void {
+    const activeIndex = this.currentIndex();
+    this.optionElements.forEach((element, index) => {
+      const selected = index === activeIndex;
+      element.setAttribute("aria-selected", String(selected));
+      element.classList.toggle("is-selected", selected);
+    });
+  }
+}
+
+/** Create a toggle/select widget. */
+export function createToggleSelect(bounds: ToggleSelectBounds): ScalarWidget {
+  return new ToggleSelectWidget(bounds);
+}
diff --git a/web/src/widgets/widget-types.ts b/web/src/widgets/widget-types.ts
new file mode 100644
index 00000000..ba6bbdcb
--- /dev/null
+++ b/web/src/widgets/widget-types.ts
@@ -0,0 +1,157 @@
+/**
+ * Shared contracts for the framework-agnostic widget kit.
+ *
+ * Widgets know nothing about recipes or audio: they accept typed bounds and
+ * emit numeric value changes. The mapping layer (a later slice) binds them to
+ * recipe parameters.
+ */
+
+/** Bounds for a scalar (single-value) widget. */
+export interface ScalarBounds {
+  min: number;
+  max: number;
+  /** Quantisation step. Defaults to 1/100th of the range when omitted. */
+  step?: number;
+  /** Display unit, e.g. "Hz" or "s". */
+  unit?: string;
+  /** Initial value. Defaults to `min`. */
+  value?: number;
+  /** Accessible label. */
+  label: string;
+  /** Optional parameter name reported in change events. */
+  name?: string;
+}
+
+/** Bounds for one axis of an XY pad. */
+export interface XYAxisBounds {
+  min: number;
+  max: number;
+  /** Quantisation step. Defaults to 1/100th of the range when omitted. */
+  step?: number;
+  /** Display unit, e.g. "Hz" or "s". */
+  unit?: string;
+  /** Initial value. Defaults to `min`. */
+  value?: number;
+  /** Axis label, e.g. "X" or "Pitch". */
+  label?: string;
+}
+
+/** Options for an XY pad (two scalar axes). */
+export interface XYPadBounds {
+  x: XYAxisBounds;
+  y: XYAxisBounds;
+  /** Accessible label for the pad as a whole. */
+  label: string;
+  /** Optional parameter name reported in change events. */
+  name?: string;
+}
+
+/** An option in a toggle/select widget. */
+export interface ToggleOption {
+  label: string;
+  value: number;
+}
+
+/** Options for a toggle/select widget. */
+export interface ToggleSelectBounds {
+  options: ToggleOption[];
+  /** Initial value; defaults to the first option. */
+  value?: number;
+  /** Accessible label. */
+  label: string;
+  /** Optional parameter name reported in change events. */
+  name?: string;
+}
+
+/** Unsubscribe function returned by `onChange`. */
+export type Unsubscribe = () => void;
+
+/** Details carried by a widget `change` CustomEvent. */
+export interface WidgetChangeDetail {
+  value: T;
+  name?: string;
+}
+
+/** Common widget lifecycle surface. */
+export interface Widget {
+  /** The widget's root element (not attached to the document by the factory). */
+  readonly element: HTMLElement;
+  /** Remove all listeners; the widget emits nothing afterwards. */
+  dispose(): void;
+}
+
+/** A single-value widget. */
+export interface ScalarWidget extends Widget {
+  getValue(): number;
+  setValue(value: number): void;
+  onChange(listener: (value: number) => void): Unsubscribe;
+}
+
+/** A two-axis (XY) widget. */
+export interface XYPadValue {
+  x: number;
+  y: number;
+}
+
+export interface XYPadWidget extends Widget {
+  getValue(): XYPadValue;
+  setValue(value: XYPadValue): void;
+  onChange(listener: (value: XYPadValue) => void): Unsubscribe;
+}
+
+/** Default step when none is supplied: 1/100th of the range. */
+export const DEFAULT_STEP_FRACTION = 100;
+
+/** Resolve the effective step for bounds (never zero). */
+export function resolveStep(bounds: { min: number; max: number; step?: number }): number {
+  if (typeof bounds.step === "number" && Number.isFinite(bounds.step) && bounds.step > 0) {
+    return bounds.step;
+  }
+  const span = Math.abs(bounds.max - bounds.min);
+  return span > 0 ? span / DEFAULT_STEP_FRACTION : 1;
+}
+
+/** Number of decimal places used by a number's canonical string form. */
+export function decimalsOf(value: number): number {
+  if (!Number.isFinite(value)) return 0;
+  const text = String(value);
+  const exponentIndex = text.search(/[eE]/);
+  if (exponentIndex >= 0) {
+    const [mantissa, exponent] = text.split(/[eE]/);
+    const mantissaDecimals = (mantissa.split(".")[1] ?? "").length;
+    return Math.max(0, mantissaDecimals - Number(exponent));
+  }
+  const dot = text.indexOf(".");
+  return dot < 0 ? 0 : text.length - dot - 1;
+}
+
+/** Quantise a value to the given step, removing floating-point noise. */
+export function quantise(value: number, min: number, step: number): number {
+  if (!(step > 0)) return value;
+  const steps = Math.round((value - min) / step);
+  const raw = min + steps * step;
+  const decimals = Math.max(decimalsOf(step), decimalsOf(min));
+  return decimals > 0 ? Number(raw.toFixed(decimals)) : raw;
+}
+
+/** Clamp + quantise a value into scalar bounds. */
+export function clampToBounds(value: number, bounds: ScalarBounds): number {
+  const step = resolveStep(bounds);
+  const safe = Number.isFinite(value) ? value : bounds.min;
+  const quantised = quantise(safe, bounds.min, step);
+  return Math.min(bounds.max, Math.max(bounds.min, quantised));
+}
+
+/** Clamp + quantise a value into a single XY axis. */
+export function clampAxis(value: number, axis: XYAxisBounds): number {
+  const step = resolveStep(axis);
+  const safe = Number.isFinite(value) ? value : axis.min;
+  const quantised = quantise(safe, axis.min, step);
+  return Math.min(axis.max, Math.max(axis.min, quantised));
+}
+
+/** Format a value + unit for display and `aria-valuetext`. */
+export function formatValue(value: number, unit?: string): string {
+  const text = Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)));
+  return unit ? `${text} ${unit}` : text;
+}
diff --git a/web/src/widgets/xy-pad.ts b/web/src/widgets/xy-pad.ts
new file mode 100644
index 00000000..b1716ec5
--- /dev/null
+++ b/web/src/widgets/xy-pad.ts
@@ -0,0 +1,230 @@
+/**
+ * XY touchpad widget (plain DOM, accessible, keyboard-operable).
+ *
+ * Because an XY pad has two values, it exposes two focusable `role="slider"`
+ * axes inside a labelled `role="group"`, so every dimension keeps its own
+ * ARIA value range and keyboard support.
+ */
+
+import {
+  clampAxis,
+  formatValue,
+  resolveStep,
+  type Unsubscribe,
+  type WidgetChangeDetail,
+  type XYAxisBounds,
+  type XYPadBounds,
+  type XYPadValue,
+  type XYPadWidget,
+} from "./widget-types.js";
+
+class XYPadWidgetImpl implements XYPadWidget {
+  readonly element: HTMLElement;
+
+  private readonly bounds: XYPadBounds;
+  private readonly xAxis: HTMLElement;
+  private readonly yAxis: HTMLElement;
+  private readonly thumb: HTMLElement;
+  private readonly pad: HTMLElement;
+  private readonly listeners = new Set<(value: XYPadValue) => void>();
+
+  private x: number;
+  private y: number;
+  private disposed = false;
+  private pointerDragging = false;
+
+  private readonly handlePointerDown = (event: PointerEvent): void => {
+    if (this.disposed) {
+      return;
+    }
+    if (event.target !== this.pad && !this.pad.contains(event.target as Node)) {
+      return;
+    }
+    event.preventDefault();
+    this.pointerDragging = true;
+    this.updateFromPointer(event);
+    document.addEventListener("pointermove", this.handlePointerMove);
+    document.addEventListener("pointerup", this.handlePointerUp);
+  };
+
+  private readonly handlePointerMove = (event: PointerEvent): void => {
+    if (this.disposed || !this.pointerDragging) {
+      return;
+    }
+    event.preventDefault();
+    this.updateFromPointer(event);
+  };
+
+  private readonly handlePointerUp = (): void => {
+    if (this.pointerDragging) {
+      this.pointerDragging = false;
+      document.removeEventListener("pointermove", this.handlePointerMove);
+      document.removeEventListener("pointerup", this.handlePointerUp);
+    }
+  };
+
+  private updateFromPointer(event: PointerEvent): void {
+    const rect = this.pad.getBoundingClientRect();
+    const xRatio = rect.width > 0 ? Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) : 0;
+    const yRatio = rect.height > 0 ? Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)) : 0;
+    const newX = this.bounds.x.min + xRatio * (this.bounds.x.max - this.bounds.x.min);
+    const newY = this.bounds.y.min + (1 - yRatio) * (this.bounds.y.max - this.bounds.y.min);
+    if (newX !== this.x || newY !== this.y) {
+      this.x = newX;
+      this.y = newY;
+      this.render();
+      const value: XYPadValue = { x: this.x, y: this.y };
+      const detail: WidgetChangeDetail = { value, name: this.bounds.name };
+      this.element.dispatchEvent(new CustomEvent("change", { detail }));
+      for (const listener of [...this.listeners]) {
+        listener(value);
+      }
+    }
+  };
+
+  private readonly boundHandleKey = (event: KeyboardEvent): void => {
+    const target = event.target as HTMLElement | null;
+    if (!target) {
+      return;
+    }
+    const dimension = target.classList.contains("tf-xy-pad__axis--y") ? "y" : "x";
+    this.handleKey(event, dimension);
+  };
+
+  constructor(bounds: XYPadBounds) {
+    this.bounds = bounds;
+    this.x = clampAxis(bounds.x.value ?? bounds.x.min, bounds.x);
+    this.y = clampAxis(bounds.y.value ?? bounds.y.min, bounds.y);
+
+    this.element = document.createElement("div");
+    this.element.className = "tf-widget tf-xy-pad";
+    this.element.setAttribute("role", "group");
+    this.element.setAttribute("aria-label", bounds.label);
+
+    this.pad = document.createElement("div");
+    this.pad.className = "tf-xy-pad__pad";
+    this.pad.style.touchAction = "none";
+    this.pad.style.userSelect = "none";
+    this.thumb = document.createElement("div");
+    this.thumb.className = "tf-xy-pad__thumb";
+    this.pad.appendChild(this.thumb);
+
+    this.xAxis = this.createAxis("x", bounds.x);
+    this.yAxis = this.createAxis("y", bounds.y);
+
+    this.element.append(this.pad, this.xAxis, this.yAxis);
+    this.element.addEventListener("keydown", this.boundHandleKey);
+    this.pad.addEventListener("pointerdown", this.handlePointerDown);
+    this.render();
+  }
+
+  private createAxis(dimension: "x" | "y", axis: XYAxisBounds): HTMLElement {
+    const element = document.createElement("div");
+    element.className = `tf-xy-pad__axis tf-xy-pad__axis--${dimension}`;
+    element.setAttribute("role", "slider");
+    element.tabIndex = 0;
+    element.setAttribute("aria-label", `${this.bounds.label} ${dimension.toUpperCase()}`);
+    element.setAttribute("aria-valuemin", String(axis.min));
+    element.setAttribute("aria-valuemax", String(axis.max));
+    return element;
+  }
+
+  getValue(): XYPadValue {
+    return { x: this.x, y: this.y };
+  }
+
+  setValue(value: XYPadValue): void {
+    this.x = clampAxis(value.x, this.bounds.x);
+    this.y = clampAxis(value.y, this.bounds.y);
+    this.render();
+  }
+
+  onChange(listener: (value: XYPadValue) => void): Unsubscribe {
+    this.listeners.add(listener);
+    return () => {
+      this.listeners.delete(listener);
+    };
+  }
+
+  dispose(): void {
+    if (this.disposed) {
+      return;
+    }
+    this.disposed = true;
+    this.listeners.clear();
+    this.element.removeEventListener("keydown", this.boundHandleKey);
+    this.pad.removeEventListener("pointerdown", this.handlePointerDown);
+    document.removeEventListener("pointermove", this.handlePointerMove);
+    document.removeEventListener("pointerup", this.handlePointerUp);
+    this.element.remove();
+  }
+
+  private handleKey(event: KeyboardEvent, dimension: "x" | "y"): void {
+    if (this.disposed) {
+      return;
+    }
+    const axis = dimension === "x" ? this.bounds.x : this.bounds.y;
+    const step = resolveStep(axis);
+    const current = dimension === "x" ? this.x : this.y;
+    switch (event.key) {
+      case "ArrowRight":
+      case "ArrowUp":
+        this.commitDimension(dimension, current + step);
+        break;
+      case "ArrowLeft":
+      case "ArrowDown":
+        this.commitDimension(dimension, current - step);
+        break;
+      case "Home":
+        this.commitDimension(dimension, axis.min);
+        break;
+      case "End":
+        this.commitDimension(dimension, axis.max);
+        break;
+      default:
+        return;
+    }
+    event.preventDefault();
+  }
+
+  private commitDimension(dimension: "x" | "y", next: number): void {
+    if (dimension === "x") {
+      this.x = clampAxis(next, this.bounds.x);
+    } else {
+      this.y = clampAxis(next, this.bounds.y);
+    }
+    this.render();
+    const value: XYPadValue = { x: this.x, y: this.y };
+    const detail: WidgetChangeDetail = { value, name: this.bounds.name };
+    this.element.dispatchEvent(new CustomEvent("change", { detail }));
+    for (const listener of [...this.listeners]) {
+      listener(value);
+    }
+  }
+
+  private render(): void {
+    const xSpan = this.bounds.x.max - this.bounds.x.min;
+    const ySpan = this.bounds.y.max - this.bounds.y.min;
+    const xRatio = xSpan > 0 ? (this.x - this.bounds.x.min) / xSpan : 0;
+    const yRatio = ySpan > 0 ? (this.y - this.bounds.y.min) / ySpan : 0;
+    this.thumb.style.left = `${Math.min(100, Math.max(0, xRatio * 100))}%`;
+    this.thumb.style.top = `${Math.min(100, Math.max(0, (1 - yRatio) * 100))}%`;
+
+    this.syncAxisAria(this.xAxis, this.bounds.x, this.x);
+    this.syncAxisAria(this.yAxis, this.bounds.y, this.y);
+  }
+
+  private syncAxisAria(
+    element: HTMLElement,
+    axis: XYAxisBounds,
+    value: number,
+  ): void {
+    element.setAttribute("aria-valuenow", String(value));
+    element.setAttribute("aria-valuetext", formatValue(value, axis.unit));
+  }
+}
+
+/** Create an XY pad. */
+export function createXYPad(bounds: XYPadBounds): XYPadWidget {
+  return new XYPadWidgetImpl(bounds);
+}
diff --git a/web/test/browser-recipe-registry.test.ts b/web/test/browser-recipe-registry.test.ts
new file mode 100644
index 00000000..8ff2d2c0
--- /dev/null
+++ b/web/test/browser-recipe-registry.test.ts
@@ -0,0 +1,118 @@
+/**
+ * Unit tests for the browser file-backed recipe registry initialiser.
+ *
+ * These verify the behaviour that fixes the browser rendering smoke test:
+ * file-backed recipes (migrated from `presets/recipes/*.yaml`) must resolve in
+ * the registry the browser bundle uses. The Node discovery path is a no-op in
+ * the browser, so `registerBrowserRecipes` is the mechanism that makes the
+ * recipe available.
+ *
+ * Work item: TF-0MUVK0MGO001P2OF.
+ */
+import { describe, it, expect } from "vitest";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { RecipeRegistry, discoverFileBackedRecipes } from "../../src/core/recipe.js";
+import { createRng } from "../../src/core/rng.js";
+import { registry } from "../../src/recipes/index.js";
+import {
+  registerBrowserRecipes,
+  initializeBrowserRecipeRegistry,
+} from "../src/browser-recipe-registry.js";
+
+const PRESETS_DIR = resolve(
+  dirname(fileURLToPath(import.meta.url)),
+  "..",
+  "..",
+  "presets",
+  "recipes",
+);
+
+/** Recipes migrated from TypeScript to file-backed ToneGraph YAML. */
+const MIGRATED = [
+  "ui-scifi-confirm",
+  "weapon-laser-zap",
+  "footstep-gravel",
+  "ambient-wind-gust",
+  "card-transform",
+  "frequency-sweep-demo",
+] as const;
+
+describe("registerBrowserRecipes", () => {
+  it("registers every migrated file-backed recipe into a fresh registry", () => {
+    const browserRegistry = new RecipeRegistry();
+
+    const registered = registerBrowserRecipes(browserRegistry);
+
+    expect(registered.sort()).toEqual([...MIGRATED].sort());
+    for (const name of MIGRATED) {
+      expect(
+        browserRegistry.getRegistration(name),
+        `${name} should be registered from the inlined bundle`,
+      ).toBeDefined();
+    }
+  });
+
+  it("makes ui-scifi-confirm resolvable and renderable in the browser registry", () => {
+    const browserRegistry = new RecipeRegistry();
+    registerBrowserRecipes(browserRegistry);
+
+    const registration = browserRegistry.getRegistration("ui-scifi-confirm");
+    expect(registration).toBeDefined();
+
+    // getDuration reflects the recipe's declared meta.duration.
+    expect(registration!.getDuration(createRng(1))).toBeCloseTo(
+      0.08468207950044473,
+      10,
+    );
+
+    // Declared parameters are surfaced (the wizard/editor rely on these).
+    const paramNames = registration!.params.map((p) => p.name).sort();
+    expect(paramNames).toEqual(
+      ["attack", "decay", "filterCutoff", "frequency"].sort(),
+    );
+  });
+
+  it("produces a registration equivalent to the Node discovery path", async () => {
+    const browserRegistry = new RecipeRegistry();
+    registerBrowserRecipes(browserRegistry);
+
+    const nodeRegistry = new RecipeRegistry();
+    await discoverFileBackedRecipes(nodeRegistry, {
+      recipeDirectory: PRESETS_DIR,
+    });
+
+    for (const name of MIGRATED) {
+      const browser = browserRegistry.getRegistration(name);
+      const node = nodeRegistry.getRegistration(name);
+      expect(browser, `${name} missing from browser registry`).toBeDefined();
+      expect(node, `${name} missing from Node registry`).toBeDefined();
+
+      // Duration and declared parameter shape must match the on-disk recipe.
+      expect(browser!.getDuration(createRng(42))).toBeCloseTo(
+        node!.getDuration(createRng(42)),
+        10,
+      );
+      expect(browser!.params).toEqual(node!.params);
+      expect(browser!.category).toBe(node!.category);
+    }
+  });
+});
+
+describe("initializeBrowserRecipeRegistry", () => {
+  it("populates the shared registry and resolves with the recipe names", async () => {
+    const names = await initializeBrowserRecipeRegistry();
+
+    for (const name of MIGRATED) {
+      expect(names).toContain(name);
+      expect(registry.getRegistration(name)).toBeDefined();
+    }
+  });
+
+  it("is idempotent — repeated calls return the cached result", async () => {
+    const first = await initializeBrowserRecipeRegistry();
+    const second = await initializeBrowserRecipeRegistry();
+
+    expect(second).toBe(first);
+  });
+});
diff --git a/web/test/determinism.test.ts b/web/test/determinism.test.ts
new file mode 100644
index 00000000..186faabd
--- /dev/null
+++ b/web/test/determinism.test.ts
@@ -0,0 +1,115 @@
+/**
+ * Tests for the SoundEditor determinism helper.
+ *
+ * AC (TF-0MUV11IXS007W29J): a determinism helper renders a
+ * `{ recipe, seed, overrides }` input twice via the existing Node render path
+ * and returns/asserts byte-identical `Float32Array` output.
+ *
+ * These run in the default Node environment because the offline render path
+ * uses `node-web-audio-api`.
+ */
+import { describe, it, expect } from "vitest";
+import {
+  assertDeterministic,
+  compareSamples,
+  renderPresetTwice,
+} from "./helpers/determinism.js";
+import { getEditorFixture } from "./fixtures/editor-fixtures.js";
+
+describe("compareSamples", () => {
+  it("reports identical for equal buffers", () => {
+    const samples = new Float32Array([0, 0.5, -0.5, 1]);
+    expect(compareSamples(samples, samples.slice())).toEqual({
+      identical: true,
+      lhsLength: 4,
+      rhsLength: 4,
+    });
+  });
+
+  it("reports the first differing sample index", () => {
+    const lhs = new Float32Array([0, 0.25, 0.5]);
+    const rhs = new Float32Array([0, 0.25, 0.75]);
+    expect(compareSamples(lhs, rhs)).toMatchObject({
+      identical: false,
+      firstDifference: 2,
+    });
+  });
+
+  it("reports a length mismatch as a difference at the shared length", () => {
+    const lhs = new Float32Array([0, 1, 2, 3]);
+    const rhs = new Float32Array([0, 1, 2]);
+    expect(compareSamples(lhs, rhs)).toMatchObject({
+      identical: false,
+      firstDifference: 3,
+      lhsLength: 4,
+      rhsLength: 3,
+    });
+  });
+
+  it("treats NaN samples as equal", () => {
+    const lhs = new Float32Array([Number.NaN]);
+    const rhs = new Float32Array([Number.NaN]);
+    expect(compareSamples(lhs, rhs).identical).toBe(true);
+  });
+});
+
+describe("assertDeterministic", () => {
+  for (const id of ["oscillator-laser", "noise-filter-footstep"]) {
+    it(`renders the '${id}' fixture byte-identically twice`, async () => {
+      const fixture = getEditorFixture(id);
+      const result = await assertDeterministic({
+        recipe: fixture.recipe,
+        seed: fixture.seed,
+      });
+      expect(result.samples.length).toBeGreaterThan(0);
+      expect(result.numberOfChannels).toBe(1);
+    });
+  }
+
+  it("returns two identical renders from renderPresetTwice", async () => {
+    const fixture = getEditorFixture("oscillator-laser");
+    const { first, second, comparison } = await renderPresetTwice({
+      recipe: fixture.recipe,
+      seed: fixture.seed,
+    });
+
+    expect(comparison.identical).toBe(true);
+    expect(second.samples.length).toBe(first.samples.length);
+    expect(second.samples).toEqual(first.samples);
+  });
+
+  it("detects that different seeds produce different audio", async () => {
+    const fixture = getEditorFixture("oscillator-laser");
+    const a = await assertDeterministic({ recipe: fixture.recipe, seed: 1 });
+    const b = await assertDeterministic({ recipe: fixture.recipe, seed: 2 });
+    expect(compareSamples(a.samples, b.samples).identical).toBe(false);
+  });
+
+  it("renders a preset-shaped input including overrides deterministically", async () => {
+    const { first, comparison } = await renderPresetTwice({
+      recipe: "footstep-stone",
+      seed: 42,
+      overrides: { filterFreq: 800 },
+    });
+
+    expect(first.samples).toBeInstanceOf(Float32Array);
+    expect(comparison.identical).toBe(true);
+  });
+
+  it("reflects a parameter override in the rendered audio", async () => {
+    const fixture = getEditorFixture("noise-filter-footstep");
+    const base = await assertDeterministic({
+      recipe: fixture.recipe,
+      seed: fixture.seed,
+    });
+    const overridden = await assertDeterministic({
+      recipe: fixture.recipe,
+      seed: fixture.seed,
+      overrides: { filterFreq: 1999 },
+    });
+
+    expect(compareSamples(base.samples, overridden.samples).identical).toBe(
+      false,
+    );
+  });
+});
diff --git a/web/test/diagnostics.test.ts b/web/test/diagnostics.test.ts
new file mode 100644
index 00000000..5e5bf325
--- /dev/null
+++ b/web/test/diagnostics.test.ts
@@ -0,0 +1,156 @@
+/**
+ * Unit tests for the shared e2e diagnostics helpers.
+ *
+ * These exercise the pure formatting/parsing logic that turns a raw terminal
+ * buffer and browser console capture into a root-cause failure message. The
+ * Playwright wiring (`waitForTerminalText` / `waitForRenderedBufferLength`) is
+ * covered end-to-end by the specs in `web/e2e/`.
+ *
+ * Work item: TF-0MUVK0K230013UC3.
+ */
+import { describe, expect, it } from "vitest";
+import {
+  extractShellErrors,
+  findUnknownRecipeWarnings,
+  formatTimeoutDiagnostics,
+  isShellErrorLine,
+  selectDiagnosticConsole,
+  stripAnsi,
+  type ConsoleRecord,
+} from "../e2e/helpers/diagnostics.js";
+
+describe("stripAnsi", () => {
+  it("removes xterm SGR colour codes and trims surrounding whitespace", () => {
+    expect(stripAnsi("  \x1b[31mtoneforge: command not found\x1b[0m  ")).toBe(
+      "toneforge: command not found",
+    );
+  });
+});
+
+describe("isShellErrorLine", () => {
+  it.each([
+    "toneforge: command not found",
+    "bash: toneforge: command not found",
+    "sh: 1: toneforge: not found",
+    "toneforge: No such file or directory",
+    "bash: ./run.sh: Permission denied",
+  ])("recognises the shell error %j", (line) => {
+    expect(isShellErrorLine(line)).toBe(true);
+  });
+
+  it("does not flag ordinary command output", () => {
+    expect(isShellErrorLine("Rendered ui-scifi-confirm (seed 42)")).toBe(false);
+    expect(isShellErrorLine("Playing...")).toBe(false);
+    expect(isShellErrorLine("")).toBe(false);
+  });
+});
+
+describe("extractShellErrors", () => {
+  it("returns the distinct shell error lines from a PTY buffer", () => {
+    const buffer = [
+      "ToneForge Terminal",
+      "$ toneforge generate --recipe ui-scifi-confirm --seed 42",
+      "toneforge: command not found",
+      "bash: line 1: something: not found",
+      "toneforge: command not found",
+      "user@host:~/repo$",
+    ].join("\r\n");
+
+    expect(extractShellErrors(buffer)).toEqual([
+      "toneforge: command not found",
+      "bash: line 1: something: not found",
+    ]);
+  });
+
+  it("returns an empty list for a clean buffer", () => {
+    expect(extractShellErrors("ToneForge Terminal\r\nuser@host:~/repo$")).toEqual(
+      [],
+    );
+  });
+});
+
+describe("findUnknownRecipeWarnings", () => {
+  it("finds the registry warning emitted for an unresolved recipe", () => {
+    const records: ConsoleRecord[] = [
+      { type: "log", text: "[ToneForge] WebSocket connected" },
+      {
+        type: "warning",
+        text: 'Unknown recipe "ui-scifi-confirm" - skipping audio playback.',
+      },
+    ];
+
+    expect(findUnknownRecipeWarnings(records)).toEqual([
+      'Unknown recipe "ui-scifi-confirm" - skipping audio playback.',
+    ]);
+  });
+
+  it("returns an empty list when no warning is present", () => {
+    expect(
+      findUnknownRecipeWarnings([{ type: "log", text: "all good" }]),
+    ).toEqual([]);
+  });
+});
+
+describe("selectDiagnosticConsole", () => {
+  it("keeps warnings, errors and unresolved-recipe messages but drops logs", () => {
+    const records: ConsoleRecord[] = [
+      { type: "log", text: "noise" },
+      { type: "warning", text: "a warning" },
+      { type: "error", text: "an error" },
+      { type: "pageerror", text: "uncaught" },
+      { type: "log", text: 'Unknown recipe "x" - skipping audio playback.' },
+    ];
+
+    expect(selectDiagnosticConsole(records)).toEqual([
+      { type: "warning", text: "a warning" },
+      { type: "error", text: "an error" },
+      { type: "pageerror", text: "uncaught" },
+      { type: "log", text: 'Unknown recipe "x" - skipping audio playback.' },
+    ]);
+  });
+});
+
+describe("formatTimeoutDiagnostics", () => {
+  it("surfaces a shell command-not-found error instead of a generic timeout", () => {
+    const message = formatTimeoutDiagnostics({
+      expected: "terminal text: Rendered",
+      timeoutMs: 1000,
+      lastOutput: "$ toneforge generate\r\ntoneforge: command not found\r\n",
+    });
+
+    expect(message).toContain("Timed out after 1000ms");
+    expect(message).toContain("terminal text: Rendered");
+    expect(message).toContain("toneforge: command not found");
+    expect(message).toContain("likely root cause");
+  });
+
+  it("surfaces the browser Unknown recipe warning as the root cause", () => {
+    const message = formatTimeoutDiagnostics({
+      expected: "a non-zero OfflineAudioContext rendered buffer length",
+      timeoutMs: 500,
+      lastOutput: "",
+      consoleRecords: [
+        {
+          type: "warning",
+          text: 'Unknown recipe "ui-scifi-confirm" - skipping audio playback.',
+        },
+      ],
+    });
+
+    expect(message).toContain("unresolved recipe");
+    expect(message).toContain('Unknown recipe "ui-scifi-confirm"');
+    expect(message).toContain("[warning]");
+  });
+
+  it("uses pre-computed shell errors when provided", () => {
+    const message = formatTimeoutDiagnostics({
+      expected: "terminal text: Tests",
+      timeoutMs: 2000,
+      lastOutput: "some output",
+      shellErrors: ["bash: vitest: command not found"],
+    });
+
+    expect(message).toContain("bash: vitest: command not found");
+    expect(message).toContain("some output");
+  });
+});
diff --git a/web/test/editor-fixtures.test.ts b/web/test/editor-fixtures.test.ts
new file mode 100644
index 00000000..dfc26663
--- /dev/null
+++ b/web/test/editor-fixtures.test.ts
@@ -0,0 +1,86 @@
+/**
+ * Tests for the shared SoundEditor fixtures.
+ *
+ * AC (TF-0MUV11IXS007W29J): the fixtures module exports representative cases
+ * covering at least one oscillator recipe (weapon-laser-zap), one noise/filter
+ * recipe (footstep-stone) and one file-backed ToneGraph recipe; each fixture
+ * carries its seed and declared parameter descriptors sourced from the shared
+ * recipe registry.
+ */
+import { describe, it, expect } from "vitest";
+import {
+  EDITOR_FIXTURES,
+  descriptorsFor,
+  getEditorFixture,
+  type EditorFixtureKind,
+} from "./fixtures/editor-fixtures.js";
+import { registry } from "../../src/recipes/index.js";
+
+describe("editor fixtures", () => {
+  it("covers every required synthesis category", () => {
+    const kinds = new Set(
+      EDITOR_FIXTURES.map((fixture) => fixture.kind),
+    );
+    expect(kinds).toEqual(
+      new Set(["oscillator", "noise-filter", "file-backed"]),
+    );
+  });
+
+  it("references the named representative recipes", () => {
+    const recipes = EDITOR_FIXTURES.map((fixture) => fixture.recipe);
+    expect(recipes).toContain("weapon-laser-zap");
+    expect(recipes).toContain("footstep-stone");
+    // A recipe that is only available through file-backed ToneGraph discovery.
+    expect(recipes).toContain("ambient-wind-gust");
+  });
+
+  it("resolves every fixture recipe in the shared registry", () => {
+    for (const fixture of EDITOR_FIXTURES) {
+      expect(registry.getRegistration(fixture.recipe)).toBeDefined();
+    }
+  });
+
+  it("gives every fixture a finite integer seed", () => {
+    for (const fixture of EDITOR_FIXTURES) {
+      expect(Number.isInteger(fixture.seed)).toBe(true);
+      expect(Number.isFinite(fixture.seed)).toBe(true);
+    }
+  });
+
+  it("exposes non-empty, well-formed parameter descriptors for every fixture", () => {
+    for (const fixture of EDITOR_FIXTURES) {
+      expect(fixture.descriptors.length).toBeGreaterThan(0);
+      for (const descriptor of fixture.descriptors) {
+        expect(descriptor.name).toBeTruthy();
+        expect(descriptor.unit).toBeTruthy();
+        expect(descriptor.max).toBeGreaterThan(descriptor.min);
+      }
+    }
+  });
+
+  it("keeps fixture descriptors identical to the registry's declared params", () => {
+    for (const fixture of EDITOR_FIXTURES) {
+      const registration = registry.getRegistration(fixture.recipe);
+      expect(registration).toBeDefined();
+      expect(fixture.descriptors).toEqual(registration?.params);
+    }
+  });
+
+  it("returns copies of descriptors so callers cannot mutate the registry", () => {
+    const recipe = "footstep-stone";
+    const first = descriptorsFor(recipe);
+    first[0].min = -999;
+    const second = descriptorsFor(recipe);
+    expect(second[0].min).not.toBe(-999);
+  });
+
+  it("looks fixtures up by id and rejects unknown ids", () => {
+    const fixture = getEditorFixture("oscillator-laser");
+    expect(fixture.recipe).toBe("weapon-laser-zap");
+    expect(() => getEditorFixture("does-not-exist")).toThrow(/Unknown editor fixture/);
+  });
+
+  it("fails descriptively when a recipe is not registered", () => {
+    expect(() => descriptorsFor("not-a-real-recipe")).toThrow(/not registered/);
+  });
+});
diff --git a/web/test/environment-isolation.test.ts b/web/test/environment-isolation.test.ts
new file mode 100644
index 00000000..4691c7d9
--- /dev/null
+++ b/web/test/environment-isolation.test.ts
@@ -0,0 +1,16 @@
+/**
+ * Environment-isolation guard.
+ *
+ * The SoundEditor component tests opt into happy-dom per file. This test
+ * asserts the default environment stays Node-only, so enabling the DOM harness
+ * cannot silently change the environment the existing web tests run in
+ * (AC: "without breaking the existing Node-environment web tests").
+ */
+import { describe, it, expect } from "vitest";
+
+describe("web test environment isolation", () => {
+  it("keeps browser globals out of the default Node environment", () => {
+    expect(typeof document).toBe("undefined");
+    expect(typeof window).toBe("undefined");
+  });
+});
diff --git a/web/test/fixtures/editor-fixtures.ts b/web/test/fixtures/editor-fixtures.ts
new file mode 100644
index 00000000..02238719
--- /dev/null
+++ b/web/test/fixtures/editor-fixtures.ts
@@ -0,0 +1,97 @@
+/**
+ * Shared SoundEditor test fixtures.
+ *
+ * Representative `{ recipe, seed, overrides, descriptors }` cases used across
+ * the editor test suite. Parameter descriptors are read from the shared recipe
+ * registry (`registry.getRegistration(name).params`) so the fixtures can never
+ * drift from the synthesis layer.
+ *
+ * Coverage required by TF-0MUV11IXS007W29J:
+ * - an oscillator/FM recipe (`weapon-laser-zap`, file-backed ToneGraph)
+ * - a noise/filter recipe (`footstep-stone`, built-in)
+ * - a file-backed ToneGraph recipe (`ambient-wind-gust`)
+ *
+ * The registry is initialised by `web/test/setup-init-recipes.ts`, which Vitest
+ * runs before importing any test module.
+ */
+
+import type { ParamDescriptor } from "../../../src/core/recipe.js";
+import { registry } from "../../../src/recipes/index.js";
+
+/** Coarse synthesis category a fixture exercises. */
+export type EditorFixtureKind = "oscillator" | "noise-filter" | "file-backed";
+
+/** A representative single-sound case for editor tests. */
+export interface EditorFixture {
+  /** Stable, human-readable identifier for the case. */
+  id: string;
+  /** Registered recipe name understood by the offline render path. */
+  recipe: string;
+  /** Deterministic seed used to reproduce the sound. */
+  seed: number;
+  /** Synthesis category the fixture covers. */
+  kind: EditorFixtureKind;
+  /** Parameter overrides applied on top of the seed-derived defaults. */
+  overrides: Record;
+  /** Declared parameter descriptors read from the shared registry. */
+  descriptors: ParamDescriptor[];
+}
+
+/**
+ * Resolve the declared descriptors for a recipe from the shared registry.
+ *
+ * @throws If the recipe is not registered, which almost always means
+ *   `initializeRecipeRegistry()` has not run (see `test/setup-init-recipes.ts`).
+ */
+export function descriptorsFor(recipe: string): ParamDescriptor[] {
+  const registration = registry.getRegistration(recipe);
+  if (!registration) {
+    throw new Error(
+      `Editor fixture recipe '${recipe}' is not registered. ` +
+        "Ensure initializeRecipeRegistry() has run (see test/setup-init-recipes.ts).",
+    );
+  }
+  // Copy so callers cannot mutate the registry's descriptor table.
+  return registration.params.map((param) => ({ ...param }));
+}
+
+/** The shared fixture set. Order is stable for deterministic test output. */
+export const EDITOR_FIXTURES: EditorFixture[] = [
+  {
+    id: "oscillator-laser",
+    recipe: "weapon-laser-zap",
+    seed: 1234,
+    kind: "oscillator",
+    overrides: {},
+    descriptors: descriptorsFor("weapon-laser-zap"),
+  },
+  {
+    id: "noise-filter-footstep",
+    recipe: "footstep-stone",
+    seed: 42,
+    kind: "noise-filter",
+    overrides: {},
+    descriptors: descriptorsFor("footstep-stone"),
+  },
+  {
+    id: "file-backed-wind",
+    recipe: "ambient-wind-gust",
+    seed: 7,
+    kind: "file-backed",
+    overrides: {},
+    descriptors: descriptorsFor("ambient-wind-gust"),
+  },
+];
+
+/**
+ * Look up a fixture by id.
+ *
+ * @throws If no fixture with the given id exists.
+ */
+export function getEditorFixture(id: string): EditorFixture {
+  const fixture = EDITOR_FIXTURES.find((entry) => entry.id === id);
+  if (!fixture) {
+    throw new Error(`Unknown editor fixture: ${id}`);
+  }
+  return fixture;
+}
diff --git a/web/test/helpers/determinism.ts b/web/test/helpers/determinism.ts
new file mode 100644
index 00000000..cbf5fcd8
--- /dev/null
+++ b/web/test/helpers/determinism.ts
@@ -0,0 +1,121 @@
+/**
+ * Determinism helper for SoundEditor tests.
+ *
+ * Renders a preset-shaped input (`{ recipe, seed, overrides }`) twice through
+ * the override-aware offline render path and reports (or asserts) that the two
+ * `Float32Array` outputs are byte-identical. This is the executable definition
+ * of the editor's determinism guarantee (parent AC 3): the same preset must
+ * always reproduce the same audio.
+ */
+
+import type { RenderResult } from "../../../src/core/renderer.js";
+
+/** Preset-shaped input accepted by the determinism helper. */
+export interface EditorRenderInput {
+  /** Registered recipe name. */
+  recipe: string;
+  /** Deterministic seed. */
+  seed: number;
+  /** Parameter overrides applied on top of the seed-derived defaults. */
+  overrides?: Record;
+  /** Optional duration override in seconds. */
+  duration?: number;
+}
+
+/** Result of comparing two rendered sample buffers. */
+export interface ComparisonResult {
+  /** True when both buffers have the same length and byte-identical samples. */
+  identical: boolean;
+  /** Index of the first differing sample, when `identical` is false. */
+  firstDifference?: number;
+  /** Length of the left-hand buffer. */
+  lhsLength: number;
+  /** Length of the right-hand buffer. */
+  rhsLength: number;
+}
+
+/** The pair of renders plus their comparison. */
+export interface DeterminismResult {
+  first: RenderResult;
+  second: RenderResult;
+  comparison: ComparisonResult;
+}
+
+async function renderOnce(input: EditorRenderInput): Promise {
+  // Dynamic import keeps `node-web-audio-api` out of non-Node (happy-dom)
+  // test modules that only import this helper for its types.
+  const { renderPreset } = await import("../../../src/core/renderer.js");
+  return renderPreset(
+    { recipe: input.recipe, seed: input.seed, overrides: input.overrides },
+    input.duration,
+  );
+}
+
+/**
+ * Compare two sample buffers for byte-level equality.
+ *
+ * Uses `Object.is` per sample so `NaN` samples compare equal (a byte-identical
+ * render should not be reported as different purely because of NaN), matching
+ * the two-buffer equality semantics required by the determinism guarantee.
+ */
+export function compareSamples(
+  lhs: Float32Array,
+  rhs: Float32Array,
+): ComparisonResult {
+  const sharedLength = Math.min(lhs.length, rhs.length);
+
+  for (let i = 0; i < sharedLength; i += 1) {
+    if (!Object.is(lhs[i], rhs[i])) {
+      return {
+        identical: false,
+        firstDifference: i,
+        lhsLength: lhs.length,
+        rhsLength: rhs.length,
+      };
+    }
+  }
+
+  if (lhs.length !== rhs.length) {
+    return {
+      identical: false,
+      firstDifference: sharedLength,
+      lhsLength: lhs.length,
+      rhsLength: rhs.length,
+    };
+  }
+
+  return { identical: true, lhsLength: lhs.length, rhsLength: rhs.length };
+}
+
+/**
+ * Render an input twice and return both results with their comparison.
+ */
+export async function renderPresetTwice(
+  input: EditorRenderInput,
+): Promise {
+  const first = await renderOnce(input);
+  const second = await renderOnce(input);
+  return { first, second, comparison: compareSamples(first.samples, second.samples) };
+}
+
+/**
+ * Assert that an input renders byte-identically twice.
+ *
+ * @returns The first render result.
+ * @throws If the two renders differ.
+ */
+export async function assertDeterministic(
+  input: EditorRenderInput,
+): Promise {
+  const { first, comparison } = await renderPresetTwice(input);
+
+  if (!comparison.identical) {
+    throw new Error(
+      `Non-deterministic render for recipe '${input.recipe}' seed ${input.seed}: ` +
+        `first difference at sample ${comparison.firstDifference} ` +
+        `(lengths ${comparison.lhsLength} vs ${comparison.rhsLength}).`,
+    );
+  }
+
+  return first;
+}
diff --git a/web/test/select-port.test.ts b/web/test/select-port.test.ts
new file mode 100644
index 00000000..27f12485
--- /dev/null
+++ b/web/test/select-port.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Unit tests for the Playwright e2e free-port selector.
+ *
+ * The selector keeps local e2e runs working when the default port (3000) is
+ * occupied: Playwright aborts with "port is already used" before running any
+ * test, which is the flakiness this guards against.
+ *
+ * Work item: TF-0MOKJJI7N005LT61.
+ */
+import { describe, it, expect } from "vitest";
+import { createServer, type AddressInfo } from "node:net";
+import {
+  isPortFree,
+  findFreePort,
+  findFreePortSync,
+} from "../scripts/select-port.js";
+
+/** Bind an ephemeral port and return the server plus its port. */
+async function occupyPort(): Promise<{ close: () => Promise; port: number }> {
+  const server = createServer();
+  await new Promise((resolve, reject) => {
+    server.once("error", reject);
+    server.listen(0, "127.0.0.1", resolve);
+  });
+  const { port } = server.address() as AddressInfo;
+  return {
+    port,
+    close: () => new Promise((resolve) => server.close(() => resolve())),
+  };
+}
+
+describe("select-port", () => {
+  it("reports a bound port as not free and a released port as free", async () => {
+    const occupied = await occupyPort();
+    try {
+      expect(await isPortFree(occupied.port)).toBe(false);
+    } finally {
+      await occupied.close();
+    }
+    expect(await isPortFree(occupied.port)).toBe(true);
+  });
+
+  it("skips an occupied candidate and returns the next free port", async () => {
+    const occupied = await occupyPort();
+    try {
+      const selected = await findFreePort(occupied.port, 20);
+      expect(selected).toBeGreaterThan(occupied.port);
+      expect(await isPortFree(selected)).toBe(true);
+    } finally {
+      await occupied.close();
+    }
+  });
+
+  it("returns the requested port when it is already free", async () => {
+    const probe = await occupyPort();
+    const freePort = probe.port;
+    await probe.close();
+
+    await expect(findFreePort(freePort, 1)).resolves.toBe(freePort);
+  });
+
+  it("findFreePortSync returns a free port at or after the requested start", async () => {
+    const selected = findFreePortSync(3000, 20);
+
+    expect(Number.isInteger(selected)).toBe(true);
+    expect(selected).toBeGreaterThanOrEqual(3000);
+    expect(await isPortFree(selected)).toBe(true);
+  });
+});
diff --git a/web/test/setup-init-recipes.ts b/web/test/setup-init-recipes.ts
new file mode 100644
index 00000000..0f1e0d93
--- /dev/null
+++ b/web/test/setup-init-recipes.ts
@@ -0,0 +1,11 @@
+/**
+ * Vitest setup (web): initialize the recipe registry with file-backed recipes.
+ *
+ * Web unit tests validate demo/wizard commands against the shared registry,
+ * which now requires an explicit async initialization pass to register
+ * file-backed ToneGraph recipes (the previous top-level await was removed to
+ * keep the browser build free of top-level await).
+ */
+import { initializeRecipeRegistry } from "../../src/recipes/index.js";
+
+await initializeRecipeRegistry();
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 94561624..1dbb3d07 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -4,6 +4,7 @@ import { readFileSync, existsSync } from "node:fs";
 import { request as httpRequest } from "node:http";
 import type { IncomingMessage, ServerResponse } from "node:http";
 import type { Socket } from "node:net";
+import { yamlRecipePlugin } from "./plugins/yaml-recipe-plugin.js";
 
 const projectRoot = resolve(__dirname, "..");
 const PORT_FILE = resolve(__dirname, ".port");
@@ -159,6 +160,12 @@ export default defineConfig({
   build: {
     outDir: resolve(__dirname, "dist"),
     emptyOutDir: true,
+    rollupOptions: {
+      input: {
+        main: resolve(__dirname, "src/index.html"),
+        editorDemo: resolve(__dirname, "src/editor-demo.html"),
+      },
+    },
   },
   resolve: {
     alias: {
@@ -166,9 +173,12 @@ export default defineConfig({
       "@toneforge": resolve(projectRoot, "src"),
       // Allow importing demo markdown files from the repo-root demos/ directory
       "@demos": resolve(projectRoot, "demos"),
+      // Node's `buffer` builtin is unavailable in the browser; resolve to the
+      // npm polyfill so the reused WAV encoder runs in-browser too.
+      buffer: resolve(projectRoot, "node_modules/buffer/index.js"),
     },
   },
-  plugins: [dynamicBackendProxy()],
+  plugins: [yamlRecipePlugin(projectRoot), dynamicBackendProxy()],
   server: {
     port: parseInt(process.env.VITE_PORT || "5173", 10),
     // Proxy is now handled dynamically by the toneforge-dynamic-backend-proxy
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
index 79e17336..2b42bba8 100644
--- a/web/vitest.config.ts
+++ b/web/vitest.config.ts
@@ -1,9 +1,11 @@
 import { defineConfig } from "vitest/config";
 import { resolve } from "node:path";
+import { yamlRecipePlugin } from "./plugins/yaml-recipe-plugin.js";
 
 const projectRoot = resolve(__dirname, "..");
 
 export default defineConfig({
+  plugins: [yamlRecipePlugin(projectRoot)],
   resolve: {
     alias: {
       "@toneforge": resolve(projectRoot, "src"),
@@ -11,7 +13,15 @@ export default defineConfig({
     },
   },
   test: {
-    include: ["test/**/*.test.ts"],
+    // Existing Node-environment suite plus SoundEditor component tests.
+    // Component tests opt into happy-dom per file with the
+    // `// @vitest-environment happy-dom` directive so the default Node
+    // environment (used by the existing web tests) is unchanged.
+    include: [
+      "test/**/*.test.ts",
+      "src/**/*.test.ts",
+    ],
+    setupFiles: ["test/setup-init-recipes.ts"],
     testTimeout: 15_000,
   },
 });