diff --git a/.toneforge/config.yaml b/.toneforge/config.yaml deleted file mode 100644 index 3d9ee53..0000000 --- a/.toneforge/config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -prefixToCategory: - ui: "User Interface" - perf: "Performance" - build: "Build System" - core: "Core" - infra: "Infrastructure" diff --git a/.worklog/config.yaml b/.worklog/config.yaml new file mode 100644 index 0000000..e53922f --- /dev/null +++ b/.worklog/config.yaml @@ -0,0 +1,4 @@ +projectName: ToneForge +prefix: TF +autoExport: true +autoSync: false diff --git a/docs/guides/gamedev-workflow-example.md b/docs/guides/gamedev-workflow-example.md new file mode 100644 index 0000000..69cad51 --- /dev/null +++ b/docs/guides/gamedev-workflow-example.md @@ -0,0 +1,154 @@ +# ToneForge CLI: Game‑dev Workflow (Tableau Card Game) + +This short, copy‑pasteable CLI guide shows how to discover, author, iterate, and export game‑ready sounds for a tableau‑style card game. Commands assume you have the ToneForge CLI installed and the repository checked out. + +## Definitions + +- **Recipe:** A registered generator/orchestration invoked with `toneforge generate --recipe `; it describes the process used to produce sounds (the workflow), not the final artifact. These are parameterized and can be customized and varied when played back. +- **Stack:** A layered combination of sources/effects defined in a stack preset, used for multi-layered or composite sounds. The sounds within a stack can be offset in time and mixed together, making them ideal for things like character actions or environmental sounds where multiple elements combine to create a richer effect. +- **Sequence:** A timed series of sound events defined in a sequence preset, typically used for single-shot or short, ordered sounds. Particularly usefule for things like weapon bursts, UI feedback, or short musical cues in which there are distinct sounds that need to play at specific times relative to each other. +- **Preview:** An ephemeral render played in-memory (omit `--output`) or written to a temporary file for auditioning; previews are for iteration, not canonical exports. + +## Conventions + +- Export path: `assets/sfx/tableau//.wav` +- Events used in examples: `tableau_play_card`, `coin_collect`, `market_upgrade`, `rent_collect`, `turn_end` + +Following consistent naming and export locations makes it simple for game code, CI, and artists to find and validate generated assets. + +## Discovery — Recipes + +Finding existing recipes helps you identify reusable, registered render flows and reduces duplication; explore recipes first to see if a ready-made generator fits your needs. + +Display available recipes (implemented CLI): +```bash +# list all registered recipes (default resource) +toneforge list + +# list recipes explicitly +toneforge list recipes + +# search by keyword or category +toneforge list recipes --search "tableau" +toneforge list recipes --category "card-game" +``` + +## Preview Recipes — audition registered recipes + +Audit a registered recipe before exporting assets. When you omit `--output` the CLI will render and play in memory (no WAV written). + +```bash +# Play a registered recipe (renders and plays in memory) +toneforge generate --recipe ui-scifi-confirm --seed 42 +``` + +## Discovery — Presets + +Preset files (JSON) capture concrete parameter sets for sequences and stacks. Listing them helps you find editable files you can preview or version. + +```bash +# list sequence presets +ls -1 presets/sequences/*.json || true + +# list stack presets +ls -1 presets/stacks/*.json || true + +# or show all preset files recursively +find presets -type f -name "*.json" -print +``` + +## Preview Presets — audition sequence & stack presets + +Preview presets to validate parameters before committing or batch-rendering. For quick, ephemeral checks you can either play in-memory (omit `--output`) or write a temporary preview file and play it back. + +In-memory playback (no file written): +```bash +# Play a sequence preset (renders and plays in memory) +toneforge sequence generate --preset presets/sequences/tableau_play_card.json --seed 42 + +# Play a stack preset (renders and plays in memory) +toneforge stack render --preset presets/stacks/card_play_landing.json --seed 42 +``` + +Temporary preview files (safe, ephemeral): +```bash +# write a short preview file from a sequence preset +toneforge sequence generate --preset presets/sequences/tableau_play_card.json --seed 42 --output /tmp/preview_tableau.wav --duration 1.5 + +# play via ToneForge's playback helper (or fallback message) +toneforge play /tmp/preview_tableau.wav || echo "preview written to /tmp/preview_tableau.wav" + +# stack preset -> write and preview +toneforge stack render --preset presets/stacks/card_play_landing.json --seed 42 --output /tmp/preview_landing.wav --duration 0.6 +``` + +## Authoring — create or tweak a preset + +Authoring presets (JSON files) is the canonical way to capture repeatable parameter sets; since the CLI lacks a create helper, editing presets directly keeps changes explicit and versionable. + +The CLI does not implement a top-level `recipes create` helper. Author presets by adding or copying JSON files under `presets/sequences/` (for timed sequences) or `presets/stacks/` (for layer/stack definitions), then use the corresponding `sequence generate` or `stack render` commands. + +Example: copy an existing sequence preset and edit it: +```bash +# create a new preset by copying an existing one (safe starting point) +mkdir -p presets/sequences +cp presets/sequences/tableau_play_card.json presets/sequences/tableau_coin_collect.json + +# open in your editor and tweak parameters +${EDITOR:-vi} presets/sequences/tableau_coin_collect.json +``` + +After editing, render with `sequence generate` (or `stack render` for stacks). There is no generic `--overrides` flag implemented for ad-hoc parameter overrides; edit the JSON then re-run the generate/render command to test changes. + +## Iteration — batch renders for multiple events/names + +Batch renders produce the canonical assets you ship with the game; this section shows how to generate per-event files in the expected folder structure so they can be consumed by the build/QA process. + +Create the export directory and render canonical files (use the implemented subcommands `sequence generate`, `stack render`, or `generate --recipe` where appropriate): +```bash +# make sure target directories exist +mkdir -p assets/sfx/tableau/{tableau_play_card,coin_collect,market_upgrade,rent_collect,turn_end} + +# sequence preset -> generate a single file +toneforge sequence generate --preset presets/sequences/tableau_play_card.json --seed 42 --output assets/sfx/tableau/tableau_play_card/tableau_play_card_v1.wav --duration 1.5 + +# if you authored a new sequence preset (coin collect) use sequence generate as well +toneforge sequence generate --preset presets/sequences/tableau_coin_collect.json --seed 7 --output assets/sfx/tableau/coin_collect/coin_collect_plain_v1.wav --duration 0.8 + +# stack preset -> render a stacked/layered sound +toneforge stack render --preset presets/stacks/card_play_landing.json --seed 11 --output assets/sfx/tableau/tableau_play_card/landing_v1.wav --duration 0.6 +``` + +## Export verification + +Verification commands confirm that renders completed successfully and provide checksums for reproducibility or CI gating; run these as a quick smoke test after generation. + +Simple checks to ensure files were written and names match expectations: +```bash +# list files (recursive) +ls -l assets/sfx/tableau || true + +# checksum (sha256) each exported file (if sha256sum available) +find assets/sfx/tableau -type f -name "*.wav" -print0 | xargs -0 sha256sum || true + +# simple verification helper: ensure at least one file exists per event +for e in tableau_play_card coin_collect market_upgrade rent_collect turn_end; do + count=$(ls -1 assets/sfx/tableau/$e 2>/dev/null | wc -l || true) + echo "$e: $count files" +done +``` + +## Notes & CI + +Small practices to keep generation reproducible and CI-friendly; these notes help you ensure builds are deterministic and avoid checking generated audio into source control. + +- Keep audio generation reproducible by pinning the ToneForge CLI version in `package.json` and recording the `node`/`npm` versions in CI logs. +- In CI, run the render steps and then verify with the `find`/`sha256sum` checks above; fail the job if expected files are missing. +- Do NOT commit generated audio. Commit only presets/recipes and the guide. + +## References + +- demos/card-game-sounds.md +- demos/recipe-filtering.md +- presets/sequences/tableau_play_card.json +- presets/stacks/card_play_landing.json diff --git a/docs/tonegraph.md b/docs/tonegraph.md new file mode 100644 index 0000000..801902e --- /dev/null +++ b/docs/tonegraph.md @@ -0,0 +1,386 @@ +# ToneGraph v0.1 Specification + +This document defines the normative ToneGraph v0.1 contract for ToneForge recipe files. + +ToneGraph v0.1 targets standard Web Audio API graph construction. This spec is the source of truth for loader, validation, and recipe authoring behavior. + +## Scope + +- 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 + +## Version Compatibility + +- `version` is required and must equal `"0.1"`. +- Loaders must hard-fail unsupported versions with a clear, actionable error. +- Recommended error text: + - `Unsupported ToneGraph version: . Expected 0.1.` + +## Top-Level Shape + +ToneGraph v0.1 documents must be an object with the following fields. + +| Field | Required | Type | v0.1 behavior | +|---|---|---|---| +| `version` | yes | string | Must be `"0.1"`. | +| `engine` | no | object | Engine metadata. Defaults to `{ backend: "webaudio" }`. | +| `meta` | no | object | Human-facing metadata (name, description, tags, duration hint). | +| `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. | + +### Top-Level Defaults + +- `engine.backend`: `"webaudio"` +- `meta.duration`: optional (no default) +- `random.algorithm`: `"xorshift32"` +- `random.seed`: optional +- `transport.tempo`: `120` +- `transport.timeSignature`: `[4, 4]` +- `routing`: must be present, may be empty (`[]`) + +## Engine Field + +`engine` is descriptive metadata for compatibility checks. + +```json +{ + "engine": { + "backend": "webaudio" + } +} +``` + +Rules: +- If present, `backend` must be `"webaudio"` in v0.1. +- Any non-`webaudio` backend value must fail validation. + +## Meta Field + +`meta` contains author-facing metadata and optional duration hints. + +Supported keys in v0.1: +- `name` (string) +- `description` (string) +- `category` (string) +- `tags` (array of strings) +- `duration` (number, seconds, optional hint) + +## Random Field + +`random` declares deterministic randomization settings. + +Supported keys in v0.1: +- `algorithm` (string, default `"xorshift32"`) +- `seed` (integer, optional) + +Rules: +- When `seed` is provided, loaders should initialize deterministic RNG from this value unless runtime options explicitly override it. +- Unknown algorithms must fail validation. + +## Transport Field + +`transport` is metadata for temporal interpretation. + +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. + +## Nodes + +`nodes` is a required object map. Each key is a unique node id used by routing. + +### Node Definition Format + +Each node object uses this structure: + +```json +{ + "kind": "oscillator", + "params": { + "frequency": 880, + "type": "sine" + } +} +``` + +Rules: +- `kind` is required. +- `params` is optional; omitted params use kind-specific defaults. +- Node ids must be unique in `nodes`. +- `destination` kind is a special terminal node and should normally be declared once. + +### Supported Node Kinds (v0.1) + +- `destination` +- `gain` +- `oscillator` +- `noise` +- `biquadFilter` +- `bufferSource` +- `envelope` +- `lfo` +- `constant` +- `fmPattern` + +Implementations may internally expand helper kinds, but validators/loaders must support at least the list above. + +### Kind Parameters + +#### `destination` +- No params. + +#### `gain` +- `gain` (number, default `1.0`) + +#### `oscillator` +- `type` (`sine`, `square`, `sawtooth`, `triangle`; default `sine`) +- `frequency` (number, Hz, default `440`) +- `detune` (number, cents, default `0`) + +#### `noise` +- `color` (`white`, `pink`, `brown`; default `white`) +- `level` (number, default `1.0`) + +#### `biquadFilter` +- `type` (`lowpass`, `highpass`, `bandpass`, default `lowpass`) +- `frequency` (number, Hz, default `1000`) +- `Q` (number, default `1`) +- `gain` (number, default `0`; used by applicable filter types) + +#### `bufferSource` +- `sample` (string, required for sample playback) +- `loop` (boolean, default `false`) +- `playbackRate` (number, default `1.0`) + +#### `envelope` +- `attack` (seconds, default `0.01`) +- `decay` (seconds, default `0.1`) +- `sustain` (0..1, default `0`) +- `release` (seconds, default `0`) + +#### `lfo` +- `type` (`sine`, `square`, `sawtooth`, `triangle`; default `sine`) +- `rate` (Hz, default `1`) +- `depth` (number, default `1`) +- `offset` (number, default `0`) + +#### `constant` +- `value` (number, default `0`) + +#### `fmPattern` +- `carrierFrequency` (number, Hz, default `440`) +- `modulatorFrequency` (number, Hz, default `220`) +- `modulationIndex` (number, default `1`) + +## Parameter Declaration Format + +To support authoring UIs and param extraction, v0.1 supports a declarative parameter descriptor list in `meta.parameters`. + +```json +{ + "meta": { + "parameters": [ + { "name": "frequency", "type": "number", "min": 400, "max": 1200, "unit": "Hz" }, + { "name": "attack", "type": "number", "min": 0.001, "max": 0.01, "unit": "s" } + ] + } +} +``` + +Descriptor fields: +- `name` (string, required) +- `type` (`number`, `integer`, `boolean`, `string`; required) +- `min` (number, optional) +- `max` (number, optional) +- `step` (number, optional) +- `unit` (string, optional) +- `default` (type-matching value, optional) + +Validation rules: +- `name` values must be unique within `meta.parameters`. +- If both `min` and `max` are present, `min <= max`. +- `default` must match `type` and be inside declared bounds when bounds exist. + +## Routing + +`routing` is a required array of connection entries. + +### Flat Link Form + +```json +{ "from": "osc", "to": "filter" } +``` + +Rules: +- `from` and `to` must reference existing node ids. +- Connection order is the declaration order in `routing`. + +### `chain()` Shorthand Form + +```json +{ "chain": ["osc", "filter", "env", "out"] } +``` + +Semantics: +- Equivalent to: + - `{ "from": "osc", "to": "filter" }` + - `{ "from": "filter", "to": "env" }` + - `{ "from": "env", "to": "out" }` + +Validation rules: +- `chain` length must be at least 2. +- Every id in `chain` must exist in `nodes`. + +## Reserved v0.2 Fields + +`sequences` and `namespaces` are reserved for v0.2. + +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. + +## Complete Example (JSON) + +The following example expresses a `ui-scifi-confirm` style graph. + +```json +{ + "version": "0.1", + "engine": { + "backend": "webaudio" + }, + "meta": { + "name": "ui-scifi-confirm", + "description": "Short sci-fi confirmation tone.", + "category": "UI", + "tags": ["sci-fi", "confirm", "ui"], + "duration": 0.19, + "parameters": [ + { "name": "frequency", "type": "number", "min": 400, "max": 1200, "unit": "Hz" }, + { "name": "attack", "type": "number", "min": 0.001, "max": 0.01, "unit": "s" }, + { "name": "decay", "type": "number", "min": 0.05, "max": 0.3, "unit": "s" }, + { "name": "filterCutoff", "type": "number", "min": 800, "max": 4000, "unit": "Hz" } + ] + }, + "random": { + "algorithm": "xorshift32", + "seed": 42 + }, + "transport": { + "tempo": 120, + "timeSignature": [4, 4] + }, + "nodes": { + "osc": { + "kind": "oscillator", + "params": { + "type": "sine", + "frequency": 880 + } + }, + "filter": { + "kind": "biquadFilter", + "params": { + "type": "lowpass", + "frequency": 2200, + "Q": 1 + } + }, + "env": { + "kind": "envelope", + "params": { + "attack": 0.005, + "decay": 0.18, + "sustain": 0, + "release": 0 + } + }, + "out": { + "kind": "destination" + } + }, + "routing": [ + { "chain": ["osc", "filter", "env", "out"] } + ] +} +``` + +## Complete Example (YAML) + +This YAML is equivalent to the JSON example above. + +```yaml +version: "0.1" +engine: + backend: webaudio +meta: + name: ui-scifi-confirm + description: Short sci-fi confirmation tone. + category: UI + tags: + - sci-fi + - confirm + - ui + duration: 0.19 + parameters: + - name: frequency + type: number + min: 400 + max: 1200 + unit: Hz + - name: attack + type: number + min: 0.001 + max: 0.01 + unit: s + - name: decay + type: number + min: 0.05 + max: 0.3 + unit: s + - name: filterCutoff + type: number + min: 800 + max: 4000 + unit: Hz +random: + algorithm: xorshift32 + seed: 42 +transport: + tempo: 120 + timeSignature: [4, 4] +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 880 + filter: + kind: biquadFilter + params: + type: lowpass + frequency: 2200 + Q: 1 + env: + kind: envelope + params: + attack: 0.005 + decay: 0.18 + sustain: 0 + release: 0 + out: + kind: destination +routing: + - chain: [osc, filter, env, out] +``` diff --git a/package-lock.json b/package-lock.json index f992f94..a8f4164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "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" }, @@ -38,15 +37,6 @@ "vitest": "^4.0.18" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1510,19 +1500,6 @@ "node": ">=12" } }, - "node_modules/automation-events": { - "version": "7.1.15", - "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.15.tgz", - "integrity": "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "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", @@ -3521,17 +3498,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==", - "license": "MIT", - "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", @@ -3683,16 +3649,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==", - "license": "MIT", - "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", @@ -3717,6 +3673,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": { diff --git a/package.json b/package.json index 72e7e93..f7f6f47 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:web:e2e": "npm run test:e2e:ci --prefix web", "test:watch": "vitest", "start": "./bin/dev-cli.js", "generate": "./bin/dev-cli.js generate", @@ -40,7 +41,6 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "js-yaml": "^4.1.0", - "tone": "^15.1.22", "unified": "^11.0.5", "yargs": "^17.7.2" }, diff --git a/presets/recipes/ambient-wind-gust.yaml b/presets/recipes/ambient-wind-gust.yaml new file mode 100644 index 0000000..8d5e282 --- /dev/null +++ b/presets/recipes/ambient-wind-gust.yaml @@ -0,0 +1,103 @@ +# Migration note: hand-translated from src/recipes/index.ts ambientWindGustOfflineGraph(). +version: "0.1" +meta: + name: ambient-wind-gust + description: Environmental wind burst with filtered noise and LFO-modulated bandpass sweep. + category: Ambient + tags: + - wind + - ambient + - environment + - nature + duration: 1.6374298564009368 + parameters: + - name: filterFreq + type: number + min: 200 + max: 1500 + unit: Hz + default: 203.4370603047863 + - name: filterQ + type: number + min: 0.5 + max: 3 + unit: Q + default: 2.150779943831912 + - name: lfoRate + type: number + min: 0.5 + max: 4 + unit: Hz + default: 0.8883498038370976 + - name: lfoDepth + type: number + min: 100 + max: 800 + unit: Hz + default: 694.563831527383 + - name: attack + type: number + min: 0.1 + max: 0.5 + unit: s + default: 0.4501757566701099 + - name: sustain + type: number + min: 0.2 + max: 1 + unit: s + default: 0.46848877488367463 + - name: release + type: number + min: 0.2 + max: 0.8 + unit: s + default: 0.7187653248474852 + - name: level + type: number + min: 0.3 + max: 0.8 + unit: amplitude + default: 0.5830806577771623 +nodes: + windNoise: + kind: noise + params: + color: white + level: 1 + pinkFilter: + kind: biquadFilter + params: + type: lowpass + frequency: 2000 + windFilter: + kind: biquadFilter + params: + type: bandpass + frequency: 203.4370603047863 + Q: 2.150779943831912 + automation: + frequency: + - kind: lfo + rate: 0.8883498038370976 + depth: 347.2819157636915 + offset: 203.4370603047863 + start: 0 + end: 1.6374298564009368 + step: 0.00704331396731618 + wave: sine + level: + kind: gain + params: + gain: 0.5830806577771623 + env: + kind: envelope + params: + attack: 0.4501757566701099 + decay: 0.46848877488367463 + sustain: 1 + release: 0.7187653248474852 + out: + kind: destination +routing: + - chain: [windNoise, pinkFilter, windFilter, level, env, out] diff --git a/presets/recipes/card-transform.yaml b/presets/recipes/card-transform.yaml new file mode 100644 index 0000000..dd488b5 --- /dev/null +++ b/presets/recipes/card-transform.yaml @@ -0,0 +1,106 @@ +# Migration note: hand-translated from src/recipes/index.ts cardTransformOfflineGraph(). +version: "0.1" +meta: + name: card-transform + description: Morphing FM synthesis with modulation depth sweep for card transformation or shape-shifting. + category: Card Game + tags: + - card + - transform + - card-game + - state + - fm + - arcade + - morphing + - dramatic + duration: 0.6461314290310561 + parameters: + - name: carrierFreq + type: number + min: 300 + max: 700 + unit: Hz + default: 301.05755701685734 + - name: modRatio + type: number + min: 1 + max: 4 + unit: ratio + default: 2.9809359325982947 + - name: modDepthStart + type: number + min: 50 + max: 200 + unit: Hz + default: 66.6435630215899 + - name: modDepthEnd + type: number + min: 300 + max: 800 + unit: Hz + default: 724.6884510909879 + - name: attack + type: number + min: 0.02 + max: 0.08 + unit: s + default: 0.07252636350051647 + - name: sustain + type: number + min: 0.2 + max: 0.5 + unit: s + default: 0.300683290581378 + - name: release + type: number + min: 0.1 + max: 0.3 + unit: s + default: 0.2729217749491617 + - name: level + type: number + min: 0.5 + max: 0.9 + unit: amplitude + default: 0.72646452622173 +nodes: + modulator: + kind: oscillator + params: + type: sine + frequency: 897.4332894918099 + modDepth: + kind: gain + params: + gain: 66.6435630215899 + automation: + gain: + - kind: set + time: 0 + value: 66.6435630215899 + - kind: linearRamp + time: 0.6461314290310561 + value: 724.6884510909879 + carrier: + kind: oscillator + params: + type: sine + frequency: 301.05755701685734 + level: + kind: gain + params: + gain: 0.72646452622173 + env: + kind: envelope + params: + attack: 0.07252636350051647 + decay: 0.300683290581378 + sustain: 1 + release: 0.2729217749491617 + out: + kind: destination +routing: + - chain: [modulator, modDepth] + - from: modDepth + to: carrier.frequency + - chain: [carrier, level, env, out] diff --git a/presets/recipes/footstep-gravel.yaml b/presets/recipes/footstep-gravel.yaml new file mode 100644 index 0000000..62f94df --- /dev/null +++ b/presets/recipes/footstep-gravel.yaml @@ -0,0 +1,113 @@ +# Migration note: hand-translated from src/recipes/index.ts footstepGravelOfflineGraph(). +version: "0.1" +meta: + name: footstep-gravel + description: Sample-hybrid gravel footstep layering a CC0 impact transient with procedurally varied noise synthesis. + category: Footstep + tags: + - footstep + - gravel + - impact + - foley + - sample-hybrid + duration: 0.13707270715014837 + parameters: + - name: filterFreq + type: number + min: 300 + max: 1800 + unit: Hz + default: 303.965838813215 + - name: transientAttack + type: number + min: 0.001 + max: 0.005 + unit: s + default: 0.0036412479101310597 + - name: bodyDecay + type: number + min: 0.05 + max: 0.25 + unit: s + default: 0.07219141736211987 + - name: tailDecay + type: number + min: 0.04 + max: 0.15 + unit: s + default: 0.1334314592400173 + - name: mixLevel + type: number + min: 0.3 + max: 0.7 + unit: amplitude + default: 0.6501757566701099 + - name: bodyLevel + type: number + min: 0.4 + max: 0.9 + unit: amplitude + default: 0.5678054843022966 + - name: tailLevel + type: number + min: 0.1 + max: 0.4 + unit: amplitude + default: 0.3593826624237426 +nodes: + sample: + kind: bufferSource + params: + sample: footstep-gravel/impact.wav + sampleGain: + kind: gain + params: + gain: 0.6501757566701099 + bodyNoise: + kind: noise + params: + color: white + level: 1 + bodyFilter: + kind: biquadFilter + params: + type: bandpass + frequency: 303.965838813215 + bodyGain: + kind: gain + params: + gain: 0.5678054843022966 + bodyEnv: + kind: envelope + params: + attack: 0.0036412479101310597 + decay: 0.07219141736211987 + sustain: 0 + release: 0 + tailNoise: + kind: noise + params: + color: brown + level: 1 + tailFilter: + kind: biquadFilter + params: + type: lowpass + frequency: 151.9829194066075 + tailGain: + kind: gain + params: + gain: 0.3593826624237426 + tailEnv: + kind: envelope + params: + attack: 0.0036412479101310597 + decay: 0.1334314592400173 + sustain: 0 + release: 0 + out: + kind: destination +routing: + - chain: [sample, sampleGain, out] + - chain: [bodyNoise, bodyFilter, bodyGain, bodyEnv, out] + - chain: [tailNoise, tailFilter, tailGain, tailEnv, out] diff --git a/presets/recipes/ui-scifi-confirm.yaml b/presets/recipes/ui-scifi-confirm.yaml new file mode 100644 index 0000000..b82f020 --- /dev/null +++ b/presets/recipes/ui-scifi-confirm.yaml @@ -0,0 +1,58 @@ +# Migration note: hand-translated from src/recipes/index.ts uiSciFiConfirmOfflineGraph(). +version: "0.1" +meta: + name: ui-scifi-confirm + description: Short sci-fi confirmation tone using sine synthesis with a filtered sweep. + category: UI + tags: + - sci-fi + - confirm + - ui + duration: 0.08468207950044473 + parameters: + - name: frequency + type: number + min: 400 + max: 1200 + unit: Hz + default: 402.1151140337147 + - name: attack + type: number + min: 0.001 + max: 0.01 + unit: s + default: 0.006942807797794885 + - name: decay + type: number + min: 0.05 + max: 0.3 + unit: s + default: 0.07773927170264984 + - name: filterCutoff + type: number + min: 800 + max: 4000 + unit: Hz + default: 3518.0060869823224 +nodes: + osc: + kind: oscillator + params: + type: sine + frequency: 402.1151140337147 + filter: + kind: biquadFilter + params: + type: lowpass + frequency: 3518.0060869823224 + env: + kind: envelope + params: + attack: 0.006942807797794885 + decay: 0.07773927170264984 + sustain: 0 + release: 0 + out: + kind: destination +routing: + - chain: [osc, filter, env, out] diff --git a/presets/recipes/weapon-laser-zap.yaml b/presets/recipes/weapon-laser-zap.yaml new file mode 100644 index 0000000..6f1af84 --- /dev/null +++ b/presets/recipes/weapon-laser-zap.yaml @@ -0,0 +1,100 @@ +# Migration note: hand-translated from src/recipes/index.ts weaponLaserZapOfflineGraph(). +version: "0.1" +meta: + name: weapon-laser-zap + description: Punchy laser zap using FM synthesis with a bandpass-filtered noise burst. + category: Weapon + tags: + - laser + - zap + - sci-fi + - weapon + duration: 0.10833617065971161 + parameters: + - name: carrierFreq + type: number + min: 200 + max: 2000 + unit: Hz + default: 204.759006575858 + - name: modulatorFreq + type: number + min: 50 + max: 500 + unit: Hz + default: 347.1403898897442 + - name: modIndex + type: number + min: 1 + max: 10 + unit: ratio + default: 1.998613781295394 + - name: noiseBurstLevel + type: number + min: 0.1 + max: 0.5 + unit: amplitude + default: 0.4397507608727903 + - name: attack + type: number + min: 0.001 + max: 0.005 + unit: s + default: 0.004501757566701099 + - name: decay + type: number + min: 0.03 + max: 0.25 + unit: s + default: 0.10383441309301052 +nodes: + modulator: + kind: oscillator + params: + type: sine + frequency: 347.1403898897442 + modDepth: + kind: gain + params: + gain: 693.799567277899 + carrier: + kind: oscillator + params: + type: sine + frequency: 204.759006575858 + carrierEnv: + kind: envelope + params: + attack: 0.004501757566701099 + decay: 0.10383441309301052 + sustain: 0 + release: 0 + noise: + kind: noise + params: + color: white + level: 1 + noiseFilter: + kind: biquadFilter + params: + type: bandpass + frequency: 409.518013151716 + noiseLevel: + kind: gain + params: + gain: 0.4397507608727903 + noiseEnv: + kind: envelope + params: + attack: 0.004501757566701099 + decay: 0.05191720654650526 + sustain: 0 + release: 0 + out: + kind: destination +routing: + - chain: [modulator, modDepth] + - from: modDepth + to: carrier.frequency + - chain: [carrier, carrierEnv, out] + - chain: [noise, noiseFilter, noiseLevel, noiseEnv, out] diff --git a/scripts/diagnostics/inspect-cloned.js b/scripts/diagnostics/inspect-cloned.js new file mode 100644 index 0000000..8efa564 --- /dev/null +++ b/scripts/diagnostics/inspect-cloned.js @@ -0,0 +1,49 @@ +import fs from 'fs/promises'; +import path from 'path'; +import yaml from 'js-yaml'; +import { validateToneGraph } from '../dist/core/tonegraph-schema.js'; +import { createRng } from '../dist/core/rng.js'; +import { loadToneGraph } from '../dist/core/tonegraph.js'; +const PRESETS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'presets', 'recipes'); + +async function run() { + const file = path.join(PRESETS, 'ui-scifi-confirm.yaml'); + const src = await fs.readFile(file, 'utf8'); + const raw = yaml.load(src); + const graph = validateToneGraph(raw); + + for (const seed of [1, 2]) { + const rng = createRng(seed); + // derive params from meta.parameters + const derived = {}; + const paramsMeta = graph.meta?.parameters ?? []; + for (const p of paramsMeta) { + const min = p.min ?? 0; + const max = p.max ?? min + 1; + const val = min + ((max - min) * rng()); + derived[p.name] = val; + } + + // clone and apply + const cloned = JSON.parse(JSON.stringify(graph)); + // osc frequency + if (cloned.nodes.osc && cloned.nodes.osc.params) cloned.nodes.osc.params.frequency = derived.frequency; + if (cloned.nodes.filter && cloned.nodes.filter.params) cloned.nodes.filter.params.frequency = derived.filterCutoff; + if (cloned.nodes.env && cloned.nodes.env.params) { + cloned.nodes.env.params.attack = derived.attack; + cloned.nodes.env.params.decay = derived.decay; + } + + const ctx = new (await import('node-web-audio-api')).OfflineAudioContext(1, Math.ceil(44100 * (cloned.meta?.duration ?? 0.1)), 44100); + const handle = await loadToneGraph(cloned, ctx, createRng(seed)); + // Inspect oscillator frequency node if present + const oscNode = handle.nodes['osc']; + const filterNode = handle.nodes['filter']; + console.log('seed', seed, 'osc freq value:', oscNode?.frequency?.value, 'filter freq value:', filterNode?.frequency?.value); + const buf = await ctx.startRendering(); + const samples = new Float32Array(buf.getChannelData(0)); + console.log('first10', Array.from(samples.slice(0,10))); + } +} + +run().catch(e=>{console.error(e); process.exit(1)}); diff --git a/scripts/diagnostics/inspect-file-backed.mjs b/scripts/diagnostics/inspect-file-backed.mjs new file mode 100644 index 0000000..153051a --- /dev/null +++ b/scripts/diagnostics/inspect-file-backed.mjs @@ -0,0 +1,32 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const recipeModule = await import(path.resolve(__dirname, '..', 'dist', 'core', 'recipe.js')); +const { RecipeRegistry, discoverFileBackedRecipes } = recipeModule; + +const PRESETS_DIR = path.resolve(__dirname, '..', 'presets', 'recipes'); + +async function run() { + const registry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(registry, { recipeDirectory: PRESETS_DIR }); + console.log('discovered', discovered); + + const reg = registry.getRegistration('ui-scifi-confirm'); + if (!reg) { + console.error('ui-scifi-confirm not found'); + process.exit(1); + } + + // Call getParams with RNGs for seeds 1 and 2 + const { createRng } = await import(path.resolve(__dirname, '..', 'dist', 'core', 'rng.js')); + const p1 = reg.getParams(createRng(1)); + const p2 = reg.getParams(createRng(2)); + + console.log('params seed=1', p1); + console.log('params seed=2', p2); +} + +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/diagnostics/render-repro-direct.js b/scripts/diagnostics/render-repro-direct.js new file mode 100644 index 0000000..b9edd31 --- /dev/null +++ b/scripts/diagnostics/render-repro-direct.js @@ -0,0 +1,27 @@ +import path from 'path'; +import { RecipeRegistry, discoverFileBackedRecipes } from '../dist/core/recipe.js'; +import { createRng } from '../dist/core/rng.js'; +import { OfflineAudioContext } from 'node-web-audio-api'; +import crypto from 'crypto'; + +async function run() { + const PRESETS_DIR = path.resolve(__dirname, '..', 'presets', 'recipes'); + const registry = new RecipeRegistry(); + await discoverFileBackedRecipes(registry, { recipeDirectory: PRESETS_DIR }); + const reg = registry.getRegistration('ui-scifi-confirm'); + if (!reg) throw new Error('not found'); + + for (const s of [1,2]) { + const duration = reg.getDuration(createRng(s)); + const frameCount = Math.ceil(44100 * duration); + const ctx = new OfflineAudioContext(1, frameCount, 44100); + await reg.buildOfflineGraph(createRng(s), ctx, duration); + const buf = await ctx.startRendering(); + const samples = new Float32Array(buf.getChannelData(0)); + const hash = crypto.createHash('sha256').update(Buffer.from(samples.buffer)).digest('hex'); + console.log(`seed ${s}: duration=${duration} samples=${samples.length} hash=${hash}`); + console.log('first10', Array.from(samples.slice(0,10))); + } +} + +run().catch(e=>{console.error(e); process.exit(1);}); diff --git a/scripts/diagnostics/render-repro-dist.mjs b/scripts/diagnostics/render-repro-dist.mjs new file mode 100644 index 0000000..8114a3c --- /dev/null +++ b/scripts/diagnostics/render-repro-dist.mjs @@ -0,0 +1,41 @@ +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Import compiled renderer from dist +const rendererPath = path.resolve(__dirname, '..', '..', 'dist', 'core', 'renderer.js'); +const { renderRecipe } = await import(rendererPath); + +function hashSamples(samples) { + return crypto.createHash('sha256').update(Buffer.from(samples.buffer)).digest('hex'); +} + +async function run() { + const recipe = process.argv[2] ?? 'ui-scifi-confirm'; + const duration = process.argv[3] ? Number(process.argv[3]) : 0.5; + + console.log(`Rendering recipe=${recipe} duration=${duration}`); + + const seeds = [1, 2]; + const results = []; + + for (const s of seeds) { + console.log(` seed ${s}: starting`); + const res = await renderRecipe(recipe, s, duration); + const h = hashSamples(res.samples); + console.log(` seed ${s}: samples=${res.samples.length} hash=${h}`); + results.push({ seed: s, hash: h, samples: res.samples }); + } + + const identical = results[0].hash === results[1].hash; + console.log(`\nResult: seeds ${seeds[0]} and ${seeds[1]} ${identical ? 'produce IDENTICAL' : 'produce DIFFERENT'} outputs`); + if (!identical) { + console.log('First few samples (seed 1):', Array.from(results[0].samples.slice(0, 10))); + console.log('First few samples (seed 2):', Array.from(results[1].samples.slice(0, 10))); + } +} + +run().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/diagnostics/render-repro.mjs b/scripts/diagnostics/render-repro.mjs new file mode 100644 index 0000000..6baf11a --- /dev/null +++ b/scripts/diagnostics/render-repro.mjs @@ -0,0 +1,38 @@ +import crypto from "crypto"; +import { renderRecipe } from "../../src/core/renderer.ts"; + +function hashSamples(samples) { + // Hash the underlying ArrayBuffer bytes for a compact deterministic fingerprint + return crypto.createHash("sha256").update(Buffer.from(samples.buffer)).digest("hex"); +} + +async function run() { + const recipe = process.argv[2] ?? "ui-scifi-confirm"; + const duration = process.argv[3] ? Number(process.argv[3]) : 0.5; + + console.log(`Rendering recipe=${recipe} duration=${duration}`); + + const seeds = [1, 2]; + const results = []; + + for (const s of seeds) { + console.log(` seed ${s}: starting`); + const res = await renderRecipe(recipe, s, duration); + const h = hashSamples(res.samples); + console.log(` seed ${s}: samples=${res.samples.length} hash=${h}`); + results.push({ seed: s, hash: h, samples: res.samples }); + } + + const identical = results[0].hash === results[1].hash; + console.log(` +Result: seeds ${seeds[0]} and ${seeds[1]} ${identical ? "produce IDENTICAL" : "produce DIFFERENT"} outputs`); + if (!identical) { + console.log("First few samples (seed 1):", Array.from(results[0].samples.slice(0, 10))); + console.log("First few samples (seed 2):", Array.from(results[1].samples.slice(0, 10))); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/generate-tableau-example.sh b/scripts/generate-tableau-example.sh new file mode 100644 index 0000000..3cd35fc --- /dev/null +++ b/scripts/generate-tableau-example.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Helper script to generate example tableau sounds into assets/sfx/tableau/ +# By default the script runs in dry-run mode and only prints the commands it would run. +# To actually generate audio set RUN=1 in the environment: `RUN=1 bash scripts/generate-tableau-example.sh` + +set -euo pipefail + +RUN=${RUN:-0} + +ROOT_DIR=$(dirname "$0")/.. +ROOT_DIR=$(cd "$ROOT_DIR" && pwd) + +OUTDIR="$ROOT_DIR/assets/sfx/tableau" + +mkdir -p "$OUTDIR/{tableau_play_card,coin_collect,market_upgrade,rent_collect,turn_end}" + +echo "Target output dir: $OUTDIR" + +cmds=( + "toneforge sequence generate --preset presets/sequences/tableau_play_card.json --seed 42 --output $OUTDIR/tableau_play_card/tableau_play_card_v1.wav --duration 1.5" + "toneforge sequence generate --preset presets/sequences/tableau_coin_collect.json --seed 7 --output $OUTDIR/coin_collect/coin_collect_plain_v1.wav --duration 0.8" + "toneforge stack render --preset presets/stacks/card_play_landing.json --seed 11 --output $OUTDIR/tableau_play_card/landing_v1.wav --duration 0.6" +) + +echo +echo "Commands to run:" +for c in "${cmds[@]}"; do + echo " $c" +done + +if [ "$RUN" != "1" ]; then + echo + echo "Dry-run mode (no audio will be generated)." + echo "To execute and generate audio set RUN=1, e.g." + echo " RUN=1 bash $0" + exit 0 +fi + +echo +echo "Executing..." +for c in "${cmds[@]}"; do + echo "+ $c" + # shellcheck disable=SC2086 + eval "$c" +done + +echo +echo "Generation complete. Checksums:" +find "$OUTDIR" -type f -name "*.wav" -print0 | xargs -0 sha256sum || true + +echo +echo "Summary per event:" +for e in tableau_play_card coin_collect market_upgrade rent_collect turn_end; do + count=$(ls -1 "$OUTDIR/$e" 2>/dev/null | wc -l || true) + echo "$e: $count files" +done + +echo +echo "Reminder: do NOT commit generated audio. Commit only presets and this script." diff --git a/src/audio/sample-loader.ts b/src/audio/sample-loader.ts index 4b9aa6f..34b9dda 100644 --- a/src/audio/sample-loader.ts +++ b/src/audio/sample-loader.ts @@ -14,17 +14,8 @@ * Reference: docs/prd/CORE_PRD.md Section 5 */ -import { resolve, dirname } from "node:path"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; import type { OfflineAudioContext, AudioBuffer } from "node-web-audio-api"; -/** Directory of this file (src/audio/) */ -const __dirname = dirname(fileURLToPath(import.meta.url)); - -/** Project root (two levels up from src/audio/) */ -const PROJECT_ROOT = resolve(__dirname, "..", ".."); - /** * Detect whether we are running in a browser environment. */ @@ -60,7 +51,15 @@ async function loadSampleNode( relativePath: string, ctx: OfflineAudioContext, ): Promise { - const fullPath = resolve(PROJECT_ROOT, "assets", "samples", relativePath); + const [{ resolve, dirname }, { readFileSync }, { fileURLToPath }] = await Promise.all([ + import("node:path"), + import("node:fs"), + import("node:url"), + ]); + + const dir = dirname(fileURLToPath(import.meta.url)); + const projectRoot = resolve(dir, "..", ".."); + const fullPath = resolve(projectRoot, "assets", "samples", relativePath); let fileBuffer: Buffer; try { diff --git a/src/core/__tests__/file-backed-diag.test.ts b/src/core/__tests__/file-backed-diag.test.ts new file mode 100644 index 0000000..3adde04 --- /dev/null +++ b/src/core/__tests__/file-backed-diag.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { RecipeRegistry, discoverFileBackedRecipes } from "../recipe.js"; +import { createRng } from "../rng.js"; +import { resolve } from "node:path"; + +// Use process.cwd() to locate the repository root during tests and then +// point at the presets recipes directory that is part of the repository. +const PRESETS_DIR = resolve(process.cwd(), "presets", "recipes"); + +describe("file-backed recipe diagnostics", () => { + it("derived params differ between seeds and affect rendering", async () => { + const registry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(registry, { recipeDirectory: PRESETS_DIR }); + // Diagnostic if discovery failed in this environment + // (prints list of discovered recipe names to help debug CI/test env paths) + // eslint-disable-next-line no-console + console.log("test: discovered recipes:", discovered); + const reg = registry.getRegistration("ui-scifi-confirm"); + if (!reg) { + throw new Error(`ui-scifi-confirm not discovered; discovered: ${JSON.stringify(discovered)}`); + } + + const params1 = reg!.getParams(createRng(1)); + const params2 = reg!.getParams(createRng(2)); + // Note: file-backed recipes may declare explicit defaults; `getParams` + // returns suggested defaults for interactive UIs. For determinism + // diagnostics we rely on the rendered output differing between seeds + // (below). Print the param sets for debugging but don't require them + // to differ here. + // eslint-disable-next-line no-console + console.log("test: params seed1=", params1, "seed2=", params2); + + // Render both and ensure sample buffers differ (quick 0.05s render) + const r1 = await (async () => { + const dur = reg!.getDuration(createRng(1)); + const { OfflineAudioContext } = await import("node-web-audio-api"); + const frameCount = Math.ceil(44100 * Math.min(0.05, dur)); + const ctx = new OfflineAudioContext(1, frameCount, 44100); + await reg!.buildOfflineGraph(createRng(1), ctx, Math.min(0.05, dur)); + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); + })(); + + const r2 = await (async () => { + const dur = reg!.getDuration(createRng(2)); + const { OfflineAudioContext } = await import("node-web-audio-api"); + const frameCount = Math.ceil(44100 * Math.min(0.05, dur)); + const ctx = new OfflineAudioContext(1, frameCount, 44100); + await reg!.buildOfflineGraph(createRng(2), ctx, Math.min(0.05, dur)); + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); + })(); + + // If the short renders are identical, fail with diagnostic info + const identical = r1.length === r2.length && r1.every((v, i) => v === r2[i]); + expect(identical).toBe(false); + }); +}); diff --git a/src/core/recipe.test.ts b/src/core/recipe.test.ts index 3b93b29..7627ba9 100644 --- a/src/core/recipe.test.ts +++ b/src/core/recipe.test.ts @@ -1,43 +1,44 @@ import { describe, it, expect } from "vitest"; -import { RecipeRegistry } from "./recipe.js"; -import type { - Recipe, - RecipeFactory, - RecipeRegistration, - LazyRecipeRegistration, -} from "./recipe.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { OfflineAudioContext } from "node-web-audio-api"; import { createRng } from "./rng.js"; +import { RecipeRegistry, discoverFileBackedRecipes } from "./recipe.js"; + +function makeRegistration(overrides: Record = {}) { + return { + getDuration: () => 1, + buildOfflineGraph: () => {}, + description: "A test recipe", + category: "weapon", + tags: ["sharp", "bright"], + signalChain: "Oscillator -> Destination", + params: [], + getParams: () => ({}), + ...overrides, + }; +} describe("RecipeRegistry", () => { - it("registers and retrieves a recipe factory", () => { + it("registers and retrieves a recipe registration", () => { const reg = new RecipeRegistry(); - const factory: RecipeFactory = (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }); + const registration = makeRegistration(); - reg.register("test-recipe", factory); - expect(reg.getRecipe("test-recipe")).toBe(factory); + reg.register("test-recipe", registration); + expect(reg.getRegistration("test-recipe")).toBe(registration); }); it("returns undefined for unregistered recipe", () => { const reg = new RecipeRegistry(); - expect(reg.getRecipe("nonexistent")).toBeUndefined(); + expect(reg.getRegistration("nonexistent")).toBeUndefined(); }); it("lists all registered recipe names", () => { const reg = new RecipeRegistry(); - const factory: RecipeFactory = (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }); - reg.register("alpha", factory); - reg.register("beta", factory); + reg.register("alpha", makeRegistration()); + reg.register("beta", makeRegistration()); const names = reg.list(); expect(names).toContain("alpha"); @@ -45,91 +46,17 @@ describe("RecipeRegistry", () => { expect(names).toHaveLength(2); }); - it("overwrites existing factory with same name", () => { + it("overwrites existing registration with same name", () => { const reg = new RecipeRegistry(); - const factory1: RecipeFactory = (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }); - const factory2: RecipeFactory = (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 2, - }); - - reg.register("same", factory1); - reg.register("same", factory2); - expect(reg.getRecipe("same")).toBe(factory2); - }); -}); - -describe("Recipe interface compliance", () => { - it("factory returns an object with start, stop, toDestination, and duration", () => { - const factory: RecipeFactory = (rng) => ({ - start: (_time: number) => {}, - stop: (_time: number) => {}, - toDestination: () => {}, - duration: 0.5, - }); + const registration1 = makeRegistration({ description: "one" }); + const registration2 = makeRegistration({ description: "two" }); - const recipe: Recipe = factory(createRng(42)); - expect(typeof recipe.start).toBe("function"); - expect(typeof recipe.stop).toBe("function"); - expect(typeof recipe.toDestination).toBe("function"); - expect(typeof recipe.duration).toBe("number"); + reg.register("same", registration1); + reg.register("same", registration2); + expect(reg.getRegistration("same")).toBe(registration2); }); }); -/** Helper: create a full RecipeRegistration for tests. */ -function makeRegistration( - overrides: Partial = {}, -): RecipeRegistration { - return { - factory: (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }), - getDuration: () => 1, - buildOfflineGraph: () => {}, - description: overrides.description ?? "A test recipe", - category: overrides.category ?? "weapon", - tags: overrides.tags ?? ["sharp", "bright"], - signalChain: "Oscillator -> Destination", - params: [], - getParams: () => ({}), - ...overrides, - }; -} - -/** Helper: create a LazyRecipeRegistration for tests. */ -function makeLazyRegistration( - overrides: Partial = {}, -): LazyRecipeRegistration { - return { - factoryLoader: async () => - (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }), - getDuration: () => 1, - buildOfflineGraph: () => {}, - description: overrides.description ?? "A lazy test recipe", - category: overrides.category ?? "ui", - tags: overrides.tags ?? ["click", "interface"], - signalChain: "Oscillator -> Destination", - params: [], - getParams: () => ({}), - ...overrides, - }; -} - describe("RecipeRegistry.listDetailed", () => { it("returns all recipes with name, description, category, and tags", () => { const reg = new RecipeRegistry(); @@ -169,46 +96,6 @@ describe("RecipeRegistry.listDetailed", () => { }); }); - it("handles lazy registry entries", () => { - const reg = new RecipeRegistry(); - reg.register( - "lazy-recipe", - makeLazyRegistration({ - description: "A lazy recipe", - category: "Ambient", - tags: ["nature", "wind"], - }), - ); - - const results = reg.listDetailed(); - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - name: "lazy-recipe", - description: "A lazy recipe", - category: "Ambient", - tags: ["nature", "wind"], - matchedTags: [], - }); - }); - - it("handles missing category (treats as empty string)", () => { - const reg = new RecipeRegistry(); - const bareFactory: RecipeFactory = (_rng) => ({ - start: () => {}, - stop: () => {}, - toDestination: () => {}, - duration: 1, - }); - reg.register("bare", bareFactory); - - const results = reg.listDetailed(); - - expect(results).toHaveLength(1); - expect(results[0]!.category).toBe(""); - expect(results[0]!.tags).toEqual([]); - }); - it("handles recipes with undefined tags (treats as empty array)", () => { const reg = new RecipeRegistry(); reg.register( @@ -339,7 +226,6 @@ describe("RecipeRegistry.listDetailed", () => { makeRegistration({ category: "Card Game" }), ); - // All of these should match expect(reg.listDetailed({ category: "card-game" })).toHaveLength(1); expect(reg.listDetailed({ category: "Card Game" })).toHaveLength(1); expect(reg.listDetailed({ category: "card game" })).toHaveLength(1); @@ -417,7 +303,6 @@ describe("RecipeRegistry.listDetailed", () => { makeRegistration({ tags: ["sword"] }), ); - // Empty strings in tags array should be ignored -> returns all const results = reg.listDetailed({ tags: ["", " "] }); expect(results).toHaveLength(2); @@ -593,34 +478,6 @@ describe("RecipeRegistry.listDetailed", () => { expect(results).toHaveLength(2); }); - - it("handles mixed eager and lazy entries", () => { - const reg = new RecipeRegistry(); - reg.register( - "eager-recipe", - makeRegistration({ - description: "Eager", - category: "Weapon", - tags: ["sharp"], - }), - ); - reg.register( - "lazy-recipe", - makeLazyRegistration({ - description: "Lazy", - category: "Weapon", - tags: ["sharp"], - }), - ); - - const results = reg.listDetailed({ category: "weapon" }); - - expect(results).toHaveLength(2); - expect(results.map((r) => r.name)).toEqual([ - "eager-recipe", - "lazy-recipe", - ]); - }); }); describe("matchedTags metadata", () => { @@ -686,7 +543,6 @@ describe("RecipeRegistry.listDetailed", () => { }), ); - // "laser" matches "laser-beam" by substring const results = reg.listDetailed({ search: "laser" }); expect(results).toHaveLength(1); @@ -704,15 +560,12 @@ describe("RecipeRegistry.listDetailed", () => { }), ); - // --tags "sci-fi" matches "sci-fi" exactly - // --search "laser" matches "laser-beam" by substring const results = reg.listDetailed({ search: "laser", tags: ["sci-fi"], }); expect(results).toHaveLength(1); - // Union: laser-beam (from search) + sci-fi (from tags), preserving order expect(results[0]!.matchedTags).toEqual(["laser-beam", "sci-fi"]); }); @@ -727,15 +580,12 @@ describe("RecipeRegistry.listDetailed", () => { }), ); - // --tags "laser" exact matches "laser" - // --search "laser" substring also matches "laser" const results = reg.listDetailed({ search: "laser", tags: ["laser"], }); expect(results).toHaveLength(1); - // "laser" should appear only once despite matching both filters expect(results[0]!.matchedTags).toEqual(["laser"]); }); @@ -767,7 +617,6 @@ describe("RecipeRegistry.listDetailed", () => { }), ); - // search matches by description but no tags to match const results = reg.listDetailed({ search: "laser" }); expect(results).toHaveLength(1); @@ -785,28 +634,123 @@ describe("RecipeRegistry.listDetailed", () => { }), ); - // "powerful" matches description but none of the tags const results = reg.listDetailed({ search: "powerful" }); expect(results).toHaveLength(1); expect(results[0]!.matchedTags).toEqual([]); }); + }); +}); - it("works with lazy registry entries", () => { - const reg = new RecipeRegistry(); - reg.register( - "lazy-recipe", - makeLazyRegistration({ - description: "Lazy", - category: "Weapon", - tags: ["laser", "sci-fi"], - }), - ); - - const results = reg.listDetailed({ tags: ["laser"] }); +describe("discoverFileBackedRecipes", () => { + it("discovers valid JSON/YAML ToneGraph files and skips invalid files with warning", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "toneforge-recipes-")); + const warnMessages: string[] = []; + const logger = { + warn: (message: string) => { + warnMessages.push(message); + }, + }; + + try { + await writeFile(join(tempRoot, "file-backed-json.json"), JSON.stringify({ + version: "0.1", + meta: { + description: "JSON file-backed recipe", + category: "UI", + tags: ["file-backed", "json"], + duration: 0.08, + }, + nodes: { + osc: { + kind: "oscillator", + params: { type: "sine", frequency: 440 }, + parameters: { + frequency: { min: 220, max: 880, unit: "Hz", default: 550, type: "number" }, + }, + }, + amp: { + kind: "gain", + params: { gain: 0.2 }, + parameters: { + gain: { min: 0.1, max: 0.8, unit: "amplitude", default: 0.2, type: "number" }, + }, + }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "amp", "out"] }], + }, null, 2), "utf-8"); + + await writeFile(join(tempRoot, "file-backed-yaml.yaml"), `version: "0.1" +meta: + description: "YAML file-backed recipe" + category: "UI" + tags: ["file-backed", "yaml"] +nodes: + osc: + kind: oscillator + params: + type: triangle + frequency: 330 + env: + kind: envelope + params: + attack: 0.02 + decay: 0.08 + sustain: 0.0 + release: 0.05 + out: + kind: destination +routing: + - chain: [osc, env, out] +`, "utf-8"); + + await writeFile(join(tempRoot, "invalid.json"), JSON.stringify({ + version: "0.2", + nodes: { + bad: { kind: "oscillator" }, + }, + routing: [], + }, null, 2), "utf-8"); + + const registry = new RecipeRegistry(); + registry.register("built-in-stub", makeRegistration()); + + const discovered = await discoverFileBackedRecipes(registry, { + recipeDirectory: tempRoot, + logger, + }); - expect(results).toHaveLength(1); - expect(results[0]!.matchedTags).toEqual(["laser"]); - }); + expect(discovered.sort()).toEqual(["file-backed-json", "file-backed-yaml"]); + expect(registry.list()).toContain("built-in-stub"); + expect(registry.list()).toContain("file-backed-json"); + expect(registry.list()).toContain("file-backed-yaml"); + expect(registry.list()).not.toContain("invalid"); + expect(warnMessages.some((message) => message.includes("invalid.json"))).toBe(true); + + const jsonReg = registry.getRegistration("file-backed-json"); + expect(jsonReg).toBeDefined(); + expect(jsonReg!.params.map((p) => p.name).sort()).toEqual(["frequency", "gain"]); + expect(jsonReg!.getParams(createRng(7))).toEqual({ frequency: 550, gain: 0.2 }); + expect(jsonReg!.getDuration(createRng(1))).toBeCloseTo(0.08, 6); + + const yamlReg = registry.getRegistration("file-backed-yaml"); + expect(yamlReg).toBeDefined(); + expect(yamlReg!.getDuration(createRng(1))).toBeCloseTo(0.15, 6); + + const renderDuration = jsonReg!.getDuration(createRng(42)); + const sampleRate = 44100; + const ctx = new OfflineAudioContext( + 1, + Math.ceil(sampleRate * renderDuration), + sampleRate, + ); + await jsonReg!.buildOfflineGraph(createRng(42), ctx, renderDuration); + const rendered = await ctx.startRendering(); + const samples = new Float32Array(rendered.getChannelData(0)); + expect(samples.some((sample) => sample !== 0)).toBe(true); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } }); }); diff --git a/src/core/recipe.ts b/src/core/recipe.ts index 1955548..5d1c964 100644 --- a/src/core/recipe.ts +++ b/src/core/recipe.ts @@ -1,370 +1,75 @@ /** - * Recipe Interface & Registry + * Recipe Registry * - * A Recipe represents a Tone.js DSP graph that can be started, stopped, - * and connected to a destination. Recipes are created by factory functions - * that accept a seeded RNG for deterministic variation. - * - * The RecipeRegistry supports both simple factory registration (for - * browser-only recipes) and full registration with offline rendering - * capabilities (getDuration, buildOfflineGraph). - * - * Reference: docs/prd/CORE_PRD.md Section 4.1 + * Stores recipe metadata plus deterministic offline graph builders. */ import type { OfflineAudioContext } from "node-web-audio-api"; import type { Rng } from "./rng.js"; +import { normalizeCategory as normalizeCategoryFn } from "./normalize-category.js"; +import type { ToneGraphDocument } from "./tonegraph-schema.js"; -/** - * Describes a single recipe parameter with its name, range, and unit. - * Used by `tf show` to display parameter metadata. - */ export interface ParamDescriptor { - /** Parameter name (must match the key returned by getParams). */ name: string; - /** Minimum value (inclusive). */ min: number; - /** Maximum value (exclusive). */ max: number; - /** Unit of measurement (e.g. "Hz", "s", "amplitude"). */ unit: string; } -/** - * A constructed Tone.js DSP graph ready for rendering or playback. - */ -export interface Recipe { - /** Start the recipe at the given time (seconds). */ - start(time: number): void; - - /** Stop the recipe at the given time (seconds). */ - stop(time: number): void; - - /** Connect the recipe output to the audio destination. */ - toDestination(): void; - - /** Duration of the recipe in seconds. */ - readonly duration: number; -} - -/** - * A factory function that creates a Recipe instance from a seeded RNG. - */ -export type RecipeFactory = (rng: Rng) => Recipe; - -/** - * Full recipe registration entry with offline rendering capabilities. - * - * Recipes registered with this shape can be rendered offline by the - * renderer without hardcoded per-recipe logic. - */ export interface RecipeRegistration { - /** Tone.js factory for browser/interactive playback. */ - factory: RecipeFactory; - - /** - * Compute the natural duration for this recipe from a seeded RNG. - * Called by the renderer to determine the offline buffer length. - */ - getDuration: (rng: Rng) => number; - - /** - * Build the Web Audio API graph for offline rendering directly on - * an OfflineAudioContext. This avoids importing Tone.js in the - * Node.js offline render path. - * - * May return void (synchronous recipes) or Promise (async - * recipes that need to load samples via decodeAudioData). - */ - buildOfflineGraph: ( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, - ) => void | Promise; - - /** One-line human summary of the recipe. */ - description: string; - - /** Sound category (e.g. "UI", "Weapon", "Footstep", "Ambient"). */ - category: string; - - /** Optional tags for filtering/search. */ - tags?: string[]; - - /** - * Human-readable signal chain summary. - * Example: "Sine Oscillator -> Lowpass Filter -> Amplitude Envelope -> Destination" - */ - signalChain: string; - - /** Array of parameter descriptors with name, min, max, and unit. */ - params: ParamDescriptor[]; - - /** - * Extract seed-specific parameter values as a name-value map. - * The keys must match the `name` fields in `params`. - */ - getParams: (rng: Rng) => Record; -} - -/** - * Lazy recipe registration entry. - * - * Stores all metadata and offline rendering capabilities eagerly, but - * defers loading the Tone.js factory (which imports heavy dependencies) - * until it is actually needed. This avoids importing all recipe modules - * at startup when only one recipe is rendered. - */ -export interface LazyRecipeRegistration { - /** - * Async loader that returns the Tone.js factory on demand. - * Called only when `getRecipe()` or `resolveFactory()` is used. - */ - factoryLoader: () => Promise; - - /** @see RecipeRegistration.getDuration */ getDuration: (rng: Rng) => number; - - /** @see RecipeRegistration.buildOfflineGraph */ buildOfflineGraph: ( rng: Rng, ctx: OfflineAudioContext, duration: number, ) => void | Promise; - - /** @see RecipeRegistration.description */ description: string; - - /** @see RecipeRegistration.category */ category: string; - - /** @see RecipeRegistration.tags */ tags?: string[]; - - /** @see RecipeRegistration.signalChain */ signalChain: string; - - /** @see RecipeRegistration.params */ params: ParamDescriptor[]; - - /** @see RecipeRegistration.getParams */ getParams: (rng: Rng) => Record; } -/** - * Filter query for recipe search/filtering. - * - * All specified filters are combined with AND logic. Empty or - * whitespace-only values are ignored (treated as if not provided). - */ export interface RecipeFilterQuery { - /** - * Case-insensitive substring match across name, description, - * category, and tag strings. A recipe matches if any field - * contains the search string. - */ search?: string; - - /** - * Exact category match after normalization. Both sides are - * lowercased and spaces are replaced with hyphens, so - * "Card Game", "card-game", and "card game" all match. - */ category?: string; - - /** - * Exact case-insensitive tag match with AND logic. All specified - * tags must be present on the recipe. "laser" matches tag "laser" - * but NOT "laser-beam". - */ tags?: string[]; } -/** - * Detailed recipe summary including category and tags. - */ export interface RecipeDetailedSummary { name: string; description: string; category: string; tags: string[]; - /** Tags that contributed to the current filter match (empty when unfiltered). */ matchedTags: string[]; } -/** Internal entry type: either a fully resolved registration or a lazy one. */ -type RegistryEntry = - | { kind: "eager"; registration: RecipeRegistration } - | { kind: "lazy"; lazy: LazyRecipeRegistration; resolved?: RecipeFactory }; - -/** - * Registry of named recipe registrations. - * Maps recipe names to their registration entries. - * - * Supports both eager registrations (factory provided up-front) and - * lazy registrations (factory loaded on demand via dynamic import). - * Lazy registration avoids importing heavy Tone.js dependencies at - * module load time, reducing CLI startup latency. - */ export class RecipeRegistry { - private readonly entries = new Map(); - - /** - * Register a recipe under the given name. - * - * Accepts either a full RecipeRegistration object (with offline - * rendering capabilities), a bare RecipeFactory for backward - * compatibility (browser-only recipes without offline support), - * or a LazyRecipeRegistration for deferred factory loading. - * - * Overwrites any existing entry with the same name. - */ - register( - name: string, - entry: RecipeRegistration | RecipeFactory | LazyRecipeRegistration, - ): void { - if (typeof entry === "function") { - // Bare factory — wrap in a registration without offline support. - // getDuration and buildOfflineGraph will throw if called. - this.entries.set(name, { - kind: "eager", - registration: { - factory: entry, - getDuration: () => { - throw new Error( - `Recipe "${name}" was registered without getDuration. ` + - `Use a full RecipeRegistration to enable offline rendering.`, - ); - }, - buildOfflineGraph: () => { - throw new Error( - `Recipe "${name}" was registered without buildOfflineGraph. ` + - `Use a full RecipeRegistration to enable offline rendering.`, - ); - }, - description: "", - category: "", - signalChain: "", - params: [], - getParams: () => ({}), - }, - }); - } else if ("factoryLoader" in entry) { - // Lazy registration — defer factory loading. - this.entries.set(name, { kind: "lazy", lazy: entry }); - } else { - this.entries.set(name, { kind: "eager", registration: entry }); - } - } + private readonly entries = new Map(); - /** - * Retrieve the Tone.js factory for a recipe by name (synchronous). - * - * For eager registrations, returns the factory immediately. - * For lazy registrations, returns undefined unless the factory has - * been previously resolved via `resolveFactory()`. - * - * Returns undefined if no recipe is registered under that name. - */ - getRecipe(name: string): RecipeFactory | undefined { - const entry = this.entries.get(name); - if (!entry) return undefined; - if (entry.kind === "eager") return entry.registration.factory; - return entry.resolved; + register(name: string, entry: RecipeRegistration): void { + this.entries.set(name, entry); } - /** - * Resolve and return the Tone.js factory for a recipe by name. - * - * For lazy registrations, this triggers the dynamic import on first - * call and caches the result for subsequent calls. - * - * Returns undefined if no recipe is registered under that name. - */ - async resolveFactory(name: string): Promise { - const entry = this.entries.get(name); - if (!entry) return undefined; - if (entry.kind === "eager") return entry.registration.factory; - if (entry.resolved) return entry.resolved; - entry.resolved = await entry.lazy.factoryLoader(); - return entry.resolved; - } - - /** - * Retrieve the full registration entry for a recipe by name. - * - * For lazy registrations, the returned object has all metadata and - * offline rendering fields populated. The `factory` field is a - * placeholder that throws; use `resolveFactory()` to get the - * actual Tone.js factory when needed. - * - * Returns undefined if no recipe is registered under that name. - */ getRegistration(name: string): RecipeRegistration | undefined { - const entry = this.entries.get(name); - if (!entry) return undefined; - if (entry.kind === "eager") return entry.registration; - - // Return a view of the lazy registration with a factory placeholder. - // The factory will throw if called directly — callers that need the - // factory should use resolveFactory() instead. - const lazy = entry.lazy; - return { - factory: entry.resolved ?? ((_rng: Rng) => { - throw new Error( - `Recipe "${name}" has a lazy factory. ` + - `Use registry.resolveFactory("${name}") to load it first.`, - ); - }), - getDuration: lazy.getDuration, - buildOfflineGraph: lazy.buildOfflineGraph, - description: lazy.description, - category: lazy.category, - tags: lazy.tags, - signalChain: lazy.signalChain, - params: lazy.params, - getParams: lazy.getParams, - }; + return this.entries.get(name); } - /** - * List all registered recipe names. - */ list(): string[] { return [...this.entries.keys()]; } - /** - * List all registered recipes with their one-line description. - */ listSummaries(): Array<{ name: string; description: string }> { return [...this.entries.entries()].map(([name, entry]) => ({ name, - description: - entry.kind === "eager" - ? entry.registration.description - : entry.lazy.description, + description: entry.description, })); } - /** - * List all registered recipes with detailed metadata (name, description, - * category, tags), optionally filtered by search, category, and/or tags. - * - * All filters combine with AND logic. Empty or whitespace-only filter - * values are ignored (treated as if not provided). - * - * Filter behavior: - * - search: case-insensitive substring match across name, description, - * category, and tag strings (any field match = recipe included) - * - category: exact match after normalization (lowercase + spaces-to-hyphens) - * - tags: exact case-insensitive match with AND logic (all specified tags - * must be present; "laser" matches "laser" but NOT "laser-beam") - */ listDetailed(filter?: RecipeFilterQuery): RecipeDetailedSummary[] { const results: RecipeDetailedSummary[] = []; - // Pre-process filter values (ignore empty/whitespace-only) const searchTerm = filter?.search?.trim() ? filter.search.trim().toLowerCase() : undefined; const categoryTerm = @@ -379,59 +84,42 @@ export class RecipeRegistry { : undefined; for (const [name, entry] of this.entries) { - const description = - entry.kind === "eager" - ? entry.registration.description - : entry.lazy.description; - const category = - entry.kind === "eager" - ? (entry.registration.category ?? "") - : (entry.lazy.category ?? ""); - const tags = - entry.kind === "eager" - ? (entry.registration.tags ?? []) - : (entry.lazy.tags ?? []); - - // Apply search filter: case-insensitive substring across all fields + const description = entry.description; + const category = entry.category ?? ""; + const tags = entry.tags ?? []; + if (searchTerm !== undefined) { const nameLower = name.toLowerCase(); const descLower = description.toLowerCase(); const catLower = category.toLowerCase(); const tagsLower = tags.map((t) => t.toLowerCase()); const matchesSearch = - nameLower.includes(searchTerm) || - descLower.includes(searchTerm) || - catLower.includes(searchTerm) || - tagsLower.some((t) => t.includes(searchTerm)); + nameLower.includes(searchTerm) + || descLower.includes(searchTerm) + || catLower.includes(searchTerm) + || tagsLower.some((t) => t.includes(searchTerm)); if (!matchesSearch) continue; } - // Apply category filter: exact match after normalization if (categoryTerm !== undefined) { if (normalizeCategory(category) !== categoryTerm) continue; } - // Apply tags filter: exact case-insensitive AND logic if (tagTerms !== undefined) { const entryTagsLower = tags.map((t) => t.toLowerCase()); - const allPresent = tagTerms.every((tag) => - entryTagsLower.includes(tag), - ); + const allPresent = tagTerms.every((tag) => entryTagsLower.includes(tag)); if (!allPresent) continue; } - // Compute matchedTags: union of tags matching --tags and --search filters const matchedTags: string[] = []; if (searchTerm !== undefined || tagTerms !== undefined) { const seen = new Set(); for (const tag of tags) { const tagLower = tag.toLowerCase(); let matched = false; - // --tags: exact case-insensitive match if (tagTerms !== undefined && tagTerms.includes(tagLower)) { matched = true; } - // --search: substring case-insensitive match if (searchTerm !== undefined && tagLower.includes(searchTerm)) { matched = true; } @@ -449,13 +137,391 @@ export class RecipeRegistry { } } -/** - * Normalize a category string for comparison: lowercase and - * replace whitespace sequences with hyphens. - * - * e.g. "Card Game" -> "card-game", "card game" -> "card-game" - */ -import { normalizeCategory as normalizeCategoryFn } from "./normalize-category.js"; +interface FileBackedRecipeParam { + name: string; + min: number; + max: number; + unit: string; + defaultValue?: number; + integer?: boolean; +} + +interface DiscoverFileBackedRecipesOptions { + recipeDirectory?: string; + logger?: { + warn: (message: string) => void; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function computeDurationHint(graph: ToneGraphDocument): number { + if (typeof graph.meta?.duration === "number" && Number.isFinite(graph.meta.duration) && graph.meta.duration > 0) { + return graph.meta.duration; + } + + let duration = 0; + for (const def of Object.values(graph.nodes)) { + if (def.kind === "envelope") { + const attack = def.params?.attack ?? 0.01; + const decay = def.params?.decay ?? 0.1; + const release = def.params?.release ?? 0; + duration = Math.max(duration, attack + decay + release); + } + } + + return duration > 0 ? duration : 1; +} + +function extractParamsFromMeta(graph: ToneGraphDocument): FileBackedRecipeParam[] { + const declarations = graph.meta?.parameters ?? []; + const result: FileBackedRecipeParam[] = []; + + for (const declaration of declarations) { + if ((declaration.type !== "number" && declaration.type !== "integer") + || typeof declaration.min !== "number" + || typeof declaration.max !== "number" + || declaration.max <= declaration.min) { + continue; + } + + const defaultValue = typeof declaration.default === "number" + ? declaration.default + : undefined; + + result.push({ + name: declaration.name, + min: declaration.min, + max: declaration.max, + unit: declaration.unit ?? (declaration.type === "integer" ? "int" : "value"), + defaultValue, + integer: declaration.type === "integer", + }); + } + + return result; +} + +function parseNodeParamDeclaration(name: string, value: unknown): FileBackedRecipeParam | undefined { + if (!isRecord(value)) { + return undefined; + } + + const type = value.type; + if (type !== undefined && type !== "number" && type !== "integer") { + return undefined; + } + + const min = value.min; + const max = value.max; + if (typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return undefined; + } + + const declaredName = typeof value.name === "string" && value.name.trim().length > 0 + ? value.name + : name; + const unit = typeof value.unit === "string" && value.unit.trim().length > 0 + ? value.unit + : (type === "integer" ? "int" : "value"); + const defaultValue = typeof value.default === "number" && Number.isFinite(value.default) + ? value.default + : undefined; + + return { + name: declaredName, + min, + max, + unit, + defaultValue, + integer: type === "integer", + }; +} + +function extractParamsFromNodeDeclarations(rawDoc: unknown): FileBackedRecipeParam[] { + if (!isRecord(rawDoc) || !isRecord(rawDoc.nodes)) { + return []; + } + + const declarations: FileBackedRecipeParam[] = []; + + for (const node of Object.values(rawDoc.nodes)) { + if (!isRecord(node)) { + continue; + } + + const directParameters = node.parameters; + if (isRecord(directParameters)) { + for (const [name, value] of Object.entries(directParameters)) { + const parsed = parseNodeParamDeclaration(name, value); + if (parsed) { + declarations.push(parsed); + } + } + } + + const nestedParameters = isRecord(node.params) ? node.params.parameters : undefined; + if (isRecord(nestedParameters)) { + for (const [name, value] of Object.entries(nestedParameters)) { + const parsed = parseNodeParamDeclaration(name, value); + if (parsed) { + declarations.push(parsed); + } + } + } + } + + return declarations; +} + +function extractFileBackedParams(graph: ToneGraphDocument, rawDoc: unknown): FileBackedRecipeParam[] { + const byName = new Map(); + + for (const entry of extractParamsFromNodeDeclarations(rawDoc)) { + if (!byName.has(entry.name)) { + byName.set(entry.name, entry); + } + } + + for (const entry of extractParamsFromMeta(graph)) { + if (!byName.has(entry.name)) { + byName.set(entry.name, entry); + } + } + + return [...byName.values()]; +} + +function buildSignalChain(graph: ToneGraphDocument): string { + if (graph.routing.length === 0) { + return "ToneGraph (no routes)"; + } + + const parts = graph.routing.map((entry) => { + if ("chain" in entry) { + return entry.chain.join(" -> "); + } + return `${entry.from} -> ${entry.to}`; + }); + return parts.join(" | "); +} + +function createFileBackedRegistration( + recipeName: string, + graph: ToneGraphDocument, + rawDoc: unknown, +): RecipeRegistration { + const extractedParams = extractFileBackedParams(graph, rawDoc); + + return { + getDuration: () => computeDurationHint(graph), + buildOfflineGraph: async (rng, ctx, duration) => { + const { loadToneGraph } = await import("./tonegraph.js"); + + // Create a shallow-cloned graph to avoid mutating the canonical + // file-backed ToneGraph loaded from disk. We then inject RNG-derived + // parameter values into the cloned graph so that renders vary with + // the provided seed while keeping the on-disk representation stable. + // JSON round-trip is acceptable here since ToneGraph is JSON-compatible + // (numbers and simple objects). + const cloned = JSON.parse(JSON.stringify(graph)) as ToneGraphDocument; + + // Derive parameter values from the provided RNG. The extractedParams + // list describes parameter names and ranges discovered from the file. + const derived = {} as Record; + for (const p of extractedParams) { + // Use rng to derive a value within declared min/max. + const value = p.min + ((p.max - p.min) * rng()); + derived[p.name] = p.integer ? Math.round(value) : value; + } + + // Apply derived parameters to node params. + // 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. + for (const node of Object.values(cloned.nodes)) { + if (!node.params || typeof node.params !== "object") continue; + for (const [k, v] of Object.entries(node.params)) { + 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) { + (node.params as Record)[k] = derived[p.name]; + applied = true; + break; + } + } + } + + if (applied) continue; + + // Fallback: exact name match between node param key and declared param name + if (Object.prototype.hasOwnProperty.call(derived, k)) { + (node.params as Record)[k] = derived[k]; + continue; + } + } + } + + // 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. + if (process.env.TF_DIAG === "1") { + try { + // Print derived params mapping and example node param values + // (only a few common node ids are shown for readability). + // Also sample a few RNG values to show RNG is being consumed. + const sampleRngValues: number[] = []; + for (let i = 0; i < 5; i++) { + sampleRngValues.push(rng()); + } + + console.log("TF_DIAG: derivedParams=", derived); + const oscParams = (cloned.nodes as Record)?.osc?.params; + const filterParams = (cloned.nodes as Record)?.filter?.params; + const envParams = (cloned.nodes as Record)?.env?.params; + console.log("TF_DIAG: cloned node params: osc=", oscParams, "filter=", filterParams, "env=", envParams); + console.log("TF_DIAG: sampled graphRng values (5):", sampleRngValues); + } catch (e) { + // swallow diagnostics errors to avoid affecting rendering + // in case of unexpected graph shapes + // eslint-disable-next-line no-console + console.warn("TF_DIAG: diagnostics error:", e); + } + } + + const handle = await loadToneGraph(cloned, ctx as unknown as BaseAudioContext, rng); + const stopTime = duration > 0 ? duration : handle.duration; + handle.start(0); + handle.stop(stopTime); + }, + description: graph.meta?.description + ?? `File-backed ToneGraph recipe loaded from ${recipeName}.`, + category: graph.meta?.category ?? "File-backed", + tags: graph.meta?.tags ?? ["file-backed"], + signalChain: buildSignalChain(graph), + params: extractedParams.map((param) => ({ + name: param.name, + min: param.min, + max: param.max, + unit: param.unit, + })), + getParams: (rng) => { + const values: Record = {}; + for (const param of extractedParams) { + // Prefer the declared default value when present: getParams is + // primarily used by interactive UIs to show the recipe's suggested + // defaults. If no default is declared, derive a deterministic value + // from the provided RNG so the recipe can still vary by seed. + if (typeof param.defaultValue === "number") { + values[param.name] = param.defaultValue; + } else { + const value = param.min + ((param.max - param.min) * rng()); + values[param.name] = param.integer ? Math.round(value) : value; + } + } + return values; + }, + }; +} + +function isNodeRuntime(): boolean { + return typeof process !== "undefined" + && process.versions !== undefined + && typeof process.versions.node === "string"; +} + +export async function discoverFileBackedRecipes( + registry: RecipeRegistry, + options: DiscoverFileBackedRecipesOptions = {}, +): Promise { + if (!isNodeRuntime()) { + return []; + } + + const logger = options.logger ?? console; + + const [{ readdir, readFile }, pathModule, urlModule, yamlModule, schemaModule] = await Promise.all([ + import("node:fs/promises"), + import("node:path"), + import("node:url"), + import("js-yaml"), + import("./tonegraph-schema.js"), + ]); + + const { resolve, dirname, extname, basename } = pathModule; + const { fileURLToPath } = urlModule; + const { validateToneGraph } = schemaModule; + 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."); + } + + const defaultRecipeDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "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; + } + + 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 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), + ); + discovered.push(recipeName); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Skipping invalid ToneGraph recipe file ${entry.name}: ${message}`); + } + } + + return discovered; +} function normalizeCategory(category: string): string { return normalizeCategoryFn(category); diff --git a/src/core/tonegraph-schema.test.ts b/src/core/tonegraph-schema.test.ts new file mode 100644 index 0000000..b483acc --- /dev/null +++ b/src/core/tonegraph-schema.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { validateToneGraph } from "./tonegraph-schema.js"; + +describe("validateToneGraph", () => { + it("accepts a valid v0.1 document", () => { + const doc = { + version: "0.1", + engine: { backend: "webaudio" }, + meta: { + name: "ui-scifi-confirm", + description: "Short sci-fi confirmation tone.", + category: "UI", + tags: ["sci-fi", "confirm"], + duration: 0.19, + parameters: [ + { name: "frequency", type: "number", min: 400, max: 1200, unit: "Hz", default: 880 }, + { name: "attack", type: "number", min: 0.001, max: 0.01, unit: "s" }, + ], + }, + random: { algorithm: "xorshift32", seed: 42 }, + transport: { tempo: 120, timeSignature: [4, 4] }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 880 } }, + filter: { kind: "biquadFilter", params: { type: "lowpass", frequency: 2200, Q: 1 } }, + env: { kind: "envelope", params: { attack: 0.005, decay: 0.18, sustain: 0, release: 0 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "filter", "env", "out"] }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.version).toBe("0.1"); + expect(Object.keys(validated.nodes)).toEqual(["osc", "filter", "env", "out"]); + expect(validated.routing).toHaveLength(1); + }); + + it("accepts routing in flat link form", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator", params: { type: "triangle" } }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc", to: "out" }], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([{ from: "osc", to: "out" }]); + }); + + it("accepts routing to AudioParam endpoints", () => { + const doc = { + version: "0.1", + nodes: { + lfo: { kind: "lfo" }, + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [ + { from: "lfo", to: "osc.frequency" }, + { from: "osc", to: "out" }, + ], + }; + + const validated = validateToneGraph(doc); + + expect(validated.routing).toEqual([ + { from: "lfo", to: "osc.frequency" }, + { from: "osc", to: "out" }, + ]); + }); + + it("rejects routing from AudioParam endpoints", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + out: { kind: "destination" }, + }, + routing: [{ from: "osc.frequency", to: "out" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("cannot reference AudioParam endpoint"); + }); + + it("rejects missing nodes", () => { + const doc = { + version: "0.1", + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("nodes is required"); + }); + + it("rejects invalid node kind", () => { + const doc = { + version: "0.1", + nodes: { + bad: { kind: "tone/Oscillator" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("invalid"); + }); + + it("rejects broken routing references", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [{ from: "osc", to: "missing" }], + }; + + expect(() => validateToneGraph(doc)).toThrow("unknown node"); + }); + + it("rejects unsupported version", () => { + const doc = { + version: "0.2", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("Unsupported ToneGraph version"); + }); + + it("rejects sequences as reserved for v0.2", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + sequences: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("reserved for v0.2"); + }); + + it("rejects namespaces as reserved for v0.2", () => { + const doc = { + version: "0.1", + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + namespaces: {}, + }; + + expect(() => validateToneGraph(doc)).toThrow("reserved for v0.2"); + }); + + it("rejects invalid meta.parameters bounds", () => { + const doc = { + version: "0.1", + meta: { + parameters: [ + { name: "frequency", type: "number", min: 1000, max: 10 }, + ], + }, + nodes: { + osc: { kind: "oscillator" }, + }, + routing: [], + }; + + expect(() => validateToneGraph(doc)).toThrow("min must be <= max"); + }); +}); diff --git a/src/core/tonegraph-schema.ts b/src/core/tonegraph-schema.ts new file mode 100644 index 0000000..3a9bf15 --- /dev/null +++ b/src/core/tonegraph-schema.ts @@ -0,0 +1,643 @@ +export type ToneGraphVersion = "0.1"; + +export interface ToneGraphEngine { + backend?: "webaudio"; +} + +export type ToneGraphParameterType = "number" | "integer" | "boolean" | "string"; + +export interface ToneGraphParameterDefinition { + name: string; + type: ToneGraphParameterType; + min?: number; + max?: number; + step?: number; + unit?: string; + default?: number | boolean | string; +} + +export interface ToneGraphMeta { + name?: string; + description?: string; + category?: string; + tags?: string[]; + duration?: number; + parameters?: ToneGraphParameterDefinition[]; +} + +export interface ToneGraphRandom { + algorithm?: "xorshift32"; + seed?: number; +} + +export interface ToneGraphTransport { + tempo?: number; + timeSignature?: [number, number]; +} + +export interface ToneGraphDestinationNode { + kind: "destination"; +} + +export interface ToneGraphGainNode { + kind: "gain"; + params?: { + gain?: number; + }; +} + +export interface ToneGraphOscillatorNode { + kind: "oscillator"; + params?: { + type?: "sine" | "square" | "sawtooth" | "triangle"; + frequency?: number; + detune?: number; + }; +} + +export interface ToneGraphNoiseNode { + kind: "noise"; + params?: { + color?: "white" | "pink" | "brown"; + level?: number; + }; +} + +export interface ToneGraphBiquadFilterNode { + kind: "biquadFilter"; + params?: { + type?: "lowpass" | "highpass" | "bandpass"; + frequency?: number; + Q?: number; + gain?: number; + }; +} + +export interface ToneGraphBufferSourceNode { + kind: "bufferSource"; + params?: { + sample?: string; + loop?: boolean; + playbackRate?: number; + }; +} + +export interface ToneGraphEnvelopeNode { + kind: "envelope"; + params?: { + attack?: number; + decay?: number; + sustain?: number; + release?: number; + }; +} + +export interface ToneGraphLfoNode { + kind: "lfo"; + params?: { + type?: "sine" | "square" | "sawtooth" | "triangle"; + rate?: number; + depth?: number; + offset?: number; + }; +} + +export interface ToneGraphConstantNode { + kind: "constant"; + params?: { + value?: number; + }; +} + +export interface ToneGraphFmPatternNode { + kind: "fmPattern"; + params?: { + carrierFrequency?: number; + modulatorFrequency?: number; + modulationIndex?: number; + }; +} + +export type ToneGraphNodeDefinition = + | ToneGraphDestinationNode + | ToneGraphGainNode + | ToneGraphOscillatorNode + | ToneGraphNoiseNode + | ToneGraphBiquadFilterNode + | ToneGraphBufferSourceNode + | ToneGraphEnvelopeNode + | ToneGraphLfoNode + | ToneGraphConstantNode + | ToneGraphFmPatternNode; + +export interface ToneGraphRoutingLink { + from: string; + to: string; +} + +export interface ToneGraphRoutingChain { + chain: [string, string, ...string[]]; +} + +export type ToneGraphRoutingEntry = ToneGraphRoutingLink | ToneGraphRoutingChain; + +export interface ToneGraphDocument { + version: ToneGraphVersion; + engine?: ToneGraphEngine; + meta?: ToneGraphMeta; + random?: ToneGraphRandom; + transport?: ToneGraphTransport; + nodes: Record; + routing: ToneGraphRoutingEntry[]; +} + +type UnknownRecord = Record; + +const ALLOWED_NODE_KINDS = new Set([ + "destination", + "gain", + "oscillator", + "noise", + "biquadFilter", + "bufferSource", + "envelope", + "lfo", + "constant", + "fmPattern", +]); + +function isToneGraphParameterType(value: string): value is ToneGraphParameterType { + return ["number", "integer", "boolean", "string"].includes(value); +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertRecord(value: unknown, path: string): asserts value is UnknownRecord { + if (!isRecord(value)) { + throw new Error(`${path} must be an object.`); + } +} + +function assertString(value: unknown, path: string): asserts value is string { + if (typeof value !== "string") { + throw new Error(`${path} must be a string.`); + } +} + +function assertNumber(value: unknown, path: string): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${path} must be a finite number.`); + } +} + +function assertBoolean(value: unknown, path: string): asserts value is boolean { + if (typeof value !== "boolean") { + throw new Error(`${path} must be a boolean.`); + } +} + +function assertOptionalRecord(value: unknown, path: string): asserts value is UnknownRecord | undefined { + if (value === undefined) { + return; + } + assertRecord(value, path); +} + +function validateMetaParameters(value: unknown, path: string): ToneGraphParameterDefinition[] { + if (!Array.isArray(value)) { + throw new Error(`${path} must be an array.`); + } + + const names = new Set(); + + return value.map((entry, index) => { + const entryPath = `${path}[${index}]`; + assertRecord(entry, entryPath); + + const name = entry.name; + const typeRaw = entry.type; + + assertString(name, `${entryPath}.name`); + assertString(typeRaw, `${entryPath}.type`); + + if (!isToneGraphParameterType(typeRaw)) { + throw new Error(`${entryPath}.type must be one of: number, integer, boolean, string.`); + } + const type: ToneGraphParameterType = typeRaw; + + if (names.has(name)) { + throw new Error(`${path} contains duplicate parameter name \"${name}\".`); + } + names.add(name); + + const min = entry.min; + const max = entry.max; + const step = entry.step; + const unit = entry.unit; + const defaultValue = entry.default; + + if (min !== undefined) { + assertNumber(min, `${entryPath}.min`); + } + if (max !== undefined) { + assertNumber(max, `${entryPath}.max`); + } + if (step !== undefined) { + assertNumber(step, `${entryPath}.step`); + } + if (unit !== undefined) { + assertString(unit, `${entryPath}.unit`); + } + if (min !== undefined && max !== undefined && min > max) { + throw new Error(`${entryPath} has invalid bounds: min must be <= max.`); + } + + if (defaultValue !== undefined) { + if (type === "boolean") { + assertBoolean(defaultValue, `${entryPath}.default`); + } else if (type === "string") { + assertString(defaultValue, `${entryPath}.default`); + } else { + assertNumber(defaultValue, `${entryPath}.default`); + if (type === "integer" && !Number.isInteger(defaultValue)) { + throw new Error(`${entryPath}.default must be an integer.`); + } + } + + if (typeof defaultValue === "number") { + if (min !== undefined && defaultValue < min) { + throw new Error(`${entryPath}.default must be >= min.`); + } + if (max !== undefined && defaultValue > max) { + throw new Error(`${entryPath}.default must be <= max.`); + } + } + } + + return { + name, + type, + min, + max, + step, + unit, + default: defaultValue as number | boolean | string | undefined, + }; + }); +} + +function validateNodeDefinition(nodeId: string, value: unknown): ToneGraphNodeDefinition { + const path = `nodes.${nodeId}`; + assertRecord(value, path); + + const kind = value.kind; + assertString(kind, `${path}.kind`); + + if (!ALLOWED_NODE_KINDS.has(kind)) { + throw new Error(`${path}.kind \"${kind}\" is invalid. Allowed kinds: ${Array.from(ALLOWED_NODE_KINDS).join(", ")}.`); + } + + const paramsRaw = value.params; + assertOptionalRecord(paramsRaw, `${path}.params`); + const params = paramsRaw ?? undefined; + + switch (kind) { + case "destination": + return { kind }; + case "gain": + if (params?.gain !== undefined) { + assertNumber(params.gain, `${path}.params.gain`); + } + return { kind, params: params as ToneGraphGainNode["params"] }; + case "oscillator": + if (params?.type !== undefined) { + assertString(params.type, `${path}.params.type`); + if (!["sine", "square", "sawtooth", "triangle"].includes(params.type)) { + throw new Error(`${path}.params.type must be one of: sine, square, sawtooth, triangle.`); + } + } + if (params?.frequency !== undefined) { + assertNumber(params.frequency, `${path}.params.frequency`); + } + if (params?.detune !== undefined) { + assertNumber(params.detune, `${path}.params.detune`); + } + return { kind, params: params as ToneGraphOscillatorNode["params"] }; + case "noise": + if (params?.color !== undefined) { + assertString(params.color, `${path}.params.color`); + if (!["white", "pink", "brown"].includes(params.color)) { + throw new Error(`${path}.params.color must be one of: white, pink, brown.`); + } + } + if (params?.level !== undefined) { + assertNumber(params.level, `${path}.params.level`); + } + return { kind, params: params as ToneGraphNoiseNode["params"] }; + case "biquadFilter": + if (params?.type !== undefined) { + assertString(params.type, `${path}.params.type`); + if (!["lowpass", "highpass", "bandpass"].includes(params.type)) { + throw new Error(`${path}.params.type must be one of: lowpass, highpass, bandpass.`); + } + } + if (params?.frequency !== undefined) { + assertNumber(params.frequency, `${path}.params.frequency`); + } + if (params?.Q !== undefined) { + assertNumber(params.Q, `${path}.params.Q`); + } + if (params?.gain !== undefined) { + assertNumber(params.gain, `${path}.params.gain`); + } + return { kind, params: params as ToneGraphBiquadFilterNode["params"] }; + case "bufferSource": + if (params?.sample !== undefined) { + assertString(params.sample, `${path}.params.sample`); + } + if (params?.loop !== undefined) { + assertBoolean(params.loop, `${path}.params.loop`); + } + if (params?.playbackRate !== undefined) { + assertNumber(params.playbackRate, `${path}.params.playbackRate`); + } + return { kind, params: params as ToneGraphBufferSourceNode["params"] }; + case "envelope": + if (params?.attack !== undefined) { + assertNumber(params.attack, `${path}.params.attack`); + } + if (params?.decay !== undefined) { + assertNumber(params.decay, `${path}.params.decay`); + } + if (params?.sustain !== undefined) { + assertNumber(params.sustain, `${path}.params.sustain`); + } + if (params?.release !== undefined) { + assertNumber(params.release, `${path}.params.release`); + } + return { kind, params: params as ToneGraphEnvelopeNode["params"] }; + case "lfo": + if (params?.type !== undefined) { + assertString(params.type, `${path}.params.type`); + if (!["sine", "square", "sawtooth", "triangle"].includes(params.type)) { + throw new Error(`${path}.params.type must be one of: sine, square, sawtooth, triangle.`); + } + } + if (params?.rate !== undefined) { + assertNumber(params.rate, `${path}.params.rate`); + } + if (params?.depth !== undefined) { + assertNumber(params.depth, `${path}.params.depth`); + } + if (params?.offset !== undefined) { + assertNumber(params.offset, `${path}.params.offset`); + } + return { kind, params: params as ToneGraphLfoNode["params"] }; + case "constant": + if (params?.value !== undefined) { + assertNumber(params.value, `${path}.params.value`); + } + return { kind, params: params as ToneGraphConstantNode["params"] }; + case "fmPattern": + if (params?.carrierFrequency !== undefined) { + assertNumber(params.carrierFrequency, `${path}.params.carrierFrequency`); + } + if (params?.modulatorFrequency !== undefined) { + assertNumber(params.modulatorFrequency, `${path}.params.modulatorFrequency`); + } + if (params?.modulationIndex !== undefined) { + assertNumber(params.modulationIndex, `${path}.params.modulationIndex`); + } + return { kind, params: params as ToneGraphFmPatternNode["params"] }; + default: + throw new Error(`${path}.kind is unsupported.`); + } +} + +function validateRoutingEntry(value: unknown, index: number): ToneGraphRoutingEntry { + const path = `routing[${index}]`; + assertRecord(value, path); + + const hasFrom = Object.prototype.hasOwnProperty.call(value, "from"); + const hasTo = Object.prototype.hasOwnProperty.call(value, "to"); + const hasChain = Object.prototype.hasOwnProperty.call(value, "chain"); + + if (hasChain) { + if (hasFrom || hasTo) { + throw new Error(`${path} must use either {from,to} or {chain}, not both.`); + } + + const chain = value.chain; + if (!Array.isArray(chain)) { + throw new Error(`${path}.chain must be an array.`); + } + if (chain.length < 2) { + throw new Error(`${path}.chain must include at least 2 node ids.`); + } + chain.forEach((entry, chainIndex) => { + assertString(entry, `${path}.chain[${chainIndex}]`); + }); + return { chain: chain as [string, string, ...string[]] }; + } + + if (!hasFrom || !hasTo) { + throw new Error(`${path} must contain either {from,to} or {chain}.`); + } + + const from = value.from; + const to = value.to; + assertString(from, `${path}.from`); + assertString(to, `${path}.to`); + return { from, to }; +} + +function parseEndpointReference(ref: string): { nodeId: string; param?: string } { + const dotIndex = ref.indexOf("."); + if (dotIndex < 0) { + return { nodeId: ref }; + } + + 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}".`); + } + + return { nodeId, param }; +} + +export function validateToneGraph(doc: unknown): ToneGraphDocument { + assertRecord(doc, "ToneGraph document"); + + 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."); + } + 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."); + } + + const version = doc.version; + assertString(version, "version"); + if (version !== "0.1") { + throw new Error(`Unsupported ToneGraph version: ${version}. Expected 0.1.`); + } + + if (doc.engine !== undefined) { + assertRecord(doc.engine, "engine"); + if (doc.engine.backend !== undefined) { + assertString(doc.engine.backend, "engine.backend"); + if (doc.engine.backend !== "webaudio") { + throw new Error("engine.backend must be \"webaudio\" for ToneGraph v0.1."); + } + } + } + + if (doc.meta !== undefined) { + assertRecord(doc.meta, "meta"); + if (doc.meta.name !== undefined) { + assertString(doc.meta.name, "meta.name"); + } + if (doc.meta.description !== undefined) { + assertString(doc.meta.description, "meta.description"); + } + if (doc.meta.category !== undefined) { + assertString(doc.meta.category, "meta.category"); + } + if (doc.meta.tags !== undefined) { + if (!Array.isArray(doc.meta.tags)) { + throw new Error("meta.tags must be an array of strings."); + } + doc.meta.tags.forEach((tag, index) => assertString(tag, `meta.tags[${index}]`)); + } + if (doc.meta.duration !== undefined) { + assertNumber(doc.meta.duration, "meta.duration"); + } + if (doc.meta.parameters !== undefined) { + validateMetaParameters(doc.meta.parameters, "meta.parameters"); + } + } + + if (doc.random !== undefined) { + assertRecord(doc.random, "random"); + if (doc.random.algorithm !== undefined) { + assertString(doc.random.algorithm, "random.algorithm"); + if (doc.random.algorithm !== "xorshift32") { + throw new Error("random.algorithm must be \"xorshift32\" for ToneGraph v0.1."); + } + } + if (doc.random.seed !== undefined) { + assertNumber(doc.random.seed, "random.seed"); + if (!Number.isInteger(doc.random.seed)) { + throw new Error("random.seed must be an integer."); + } + } + } + + if (doc.transport !== undefined) { + assertRecord(doc.transport, "transport"); + if (doc.transport.tempo !== undefined) { + assertNumber(doc.transport.tempo, "transport.tempo"); + } + if (doc.transport.timeSignature !== undefined) { + if (!Array.isArray(doc.transport.timeSignature) || doc.transport.timeSignature.length !== 2) { + throw new Error("transport.timeSignature must be a [numerator, denominator] tuple."); + } + const [numerator, denominator] = doc.transport.timeSignature; + assertNumber(numerator, "transport.timeSignature[0]"); + assertNumber(denominator, "transport.timeSignature[1]"); + if (!Number.isInteger(numerator) || !Number.isInteger(denominator) || numerator <= 0 || denominator <= 0) { + throw new Error("transport.timeSignature values must be positive integers."); + } + } + } + + if (doc.nodes === undefined) { + throw new Error("nodes is required."); + } + assertRecord(doc.nodes, "nodes"); + + const nodeEntries = Object.entries(doc.nodes); + if (nodeEntries.length === 0) { + throw new Error("nodes must include at least one node definition."); + } + + const nodes: Record = {}; + for (const [nodeId, nodeDef] of nodeEntries) { + if (nodeId.trim().length === 0) { + throw new Error("nodes contains an empty node id."); + } + nodes[nodeId] = validateNodeDefinition(nodeId, nodeDef); + } + + if (doc.routing === undefined) { + throw new Error("routing is required."); + } + if (!Array.isArray(doc.routing)) { + throw new Error("routing must be an array."); + } + + const routing = doc.routing.map((entry, index) => validateRoutingEntry(entry, index)); + + const nodeIds = new Set(Object.keys(nodes)); + 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}\".`); + } + }); + 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 (!nodeIds.has(fromEndpoint.nodeId)) { + throw new Error(`routing[${index}].from references unknown node \"${entry.from}\".`); + } + if (!nodeIds.has(toEndpoint.nodeId)) { + throw new Error(`routing[${index}].to references unknown node \"${entry.to}\".`); + } + }); + + const validated: ToneGraphDocument = { + version: "0.1", + nodes, + routing, + }; + + if (doc.engine !== undefined) { + validated.engine = { backend: doc.engine.backend as ToneGraphEngine["backend"] }; + } + if (doc.meta !== undefined) { + validated.meta = { + name: doc.meta.name as string | undefined, + description: doc.meta.description as string | undefined, + category: doc.meta.category as string | undefined, + tags: doc.meta.tags as string[] | undefined, + duration: doc.meta.duration as number | undefined, + parameters: doc.meta.parameters + ? validateMetaParameters(doc.meta.parameters, "meta.parameters") + : undefined, + }; + } + if (doc.random !== undefined) { + validated.random = { + algorithm: doc.random.algorithm as ToneGraphRandom["algorithm"], + seed: doc.random.seed as number | undefined, + }; + } + if (doc.transport !== undefined) { + validated.transport = { + tempo: doc.transport.tempo as number | undefined, + timeSignature: doc.transport.timeSignature as [number, number] | undefined, + }; + } + + return validated; +} diff --git a/src/core/tonegraph.integration.test.ts b/src/core/tonegraph.integration.test.ts new file mode 100644 index 0000000..90f2998 --- /dev/null +++ b/src/core/tonegraph.integration.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { OfflineAudioContext } from "node-web-audio-api"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRng } from "./rng.js"; +import { compareBuffers, formatCompareResult } from "../test-utils/buffer-compare.js"; +import { RecipeRegistry, discoverFileBackedRecipes, type RecipeRegistration } from "./recipe.js"; + +const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "presets", "recipes"); +const SAMPLE_RATE = 44100; +const DETERMINISM_RUNS = 3; +const SEED = 42; +const PEAK_FLOOR = 0.01; // -40 dBFS + +const MIGRATED = [ + "ui-scifi-confirm", + "weapon-laser-zap", + "footstep-gravel", + "ambient-wind-gust", + "card-transform", +] as const; + +interface RenderResult { + samples: Float32Array; + duration: number; + peak: number; +} + +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); + await registration.buildOfflineGraph(createRng(seed), ctx, duration); + const rendered = await ctx.startRendering(); + const samples = new Float32Array(rendered.getChannelData(0)); + + let peak = 0; + for (const sample of samples) { + const abs = Math.abs(sample); + if (abs > peak) { + peak = abs; + } + } + + return { samples, duration, peak }; +} + +describe("ToneGraph integration parity", () => { + it("discovers all migrated recipes as file-backed registrations", async () => { + const fileBackedRegistry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + expect(discovered.sort()).toEqual([...MIGRATED].sort()); + }); + + it("all migrated file-backed recipes are deterministic across three renders", async () => { + const fileBackedRegistry = new RecipeRegistry(); + await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + for (const recipeName of MIGRATED) { + const fileBacked = fileBackedRegistry.getRegistration(recipeName); + expect(fileBacked, `${recipeName} should be discoverable from presets/recipes`).toBeDefined(); + + const renders: Float32Array[] = []; + for (let i = 0; i < DETERMINISM_RUNS; i += 1) { + const render = await renderRegistration(fileBacked!, SEED); + renders.push(render.samples); + } + + const reference = renders[0]!; + for (let i = 1; i < renders.length; i += 1) { + const comparison = compareBuffers(reference, renders[i]!); + expect( + comparison.identical, + `${recipeName} run ${i + 1} diverged from run 1:\n${formatCompareResult(comparison)}`, + ).toBe(true); + } + } + }); + + it("migrated recipes remain structurally valid", async () => { + const fileBackedRegistry = new RecipeRegistry(); + await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + for (const recipeName of MIGRATED) { + if (recipeName === "ui-scifi-confirm") { + continue; + } + + const fileBacked = fileBackedRegistry.getRegistration(recipeName); + expect(fileBacked).toBeDefined(); + + const fileRender = await renderRegistration(fileBacked!, SEED); + + expect(fileRender.samples.some((sample) => sample !== 0)).toBe(true); + expect(fileRender.peak).toBeGreaterThan(PEAK_FLOOR); + expect(fileRender.duration).toBeGreaterThan(0); + } + }); +}); diff --git a/src/core/tonegraph.test.ts b/src/core/tonegraph.test.ts new file mode 100644 index 0000000..05c241f --- /dev/null +++ b/src/core/tonegraph.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from "vitest"; +import { OfflineAudioContext } from "node-web-audio-api"; +import { createRng } from "./rng.js"; +import type { ToneGraphDocument } from "./tonegraph-schema.js"; +import { loadToneGraph } from "./tonegraph.js"; + +vi.mock("../audio/sample-loader.js", async () => { + const actual = await vi.importActual("../audio/sample-loader.js"); + return { + ...actual, + loadSample: vi.fn(async (_samplePath: string, ctx: OfflineAudioContext) => { + const sr = ctx.sampleRate; + const length = Math.ceil(sr * 0.1); + const buffer = ctx.createBuffer(1, length, sr); + const channel = buffer.getChannelData(0); + for (let i = 0; i < channel.length; i += 1) { + channel[i] = Math.sin((i / sr) * Math.PI * 2 * 440) * 0.25; + } + return buffer; + }), + }; +}); + +async function renderGraph(graph: ToneGraphDocument, seed = 42): Promise { + const duration = graph.meta?.duration ?? 0.25; + const sampleRate = 44100; + const ctx = new OfflineAudioContext(1, Math.ceil(sampleRate * (duration + 0.1)), sampleRate); + const handle = await loadToneGraph(graph, ctx, createRng(seed)); + handle.start(0); + handle.stop(handle.duration); + const rendered = await ctx.startRendering(); + return new Float32Array(rendered.getChannelData(0)); +} + +describe("loadToneGraph", () => { + it("builds oscillator/filter/gain graph with chain routing", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + osc: { kind: "oscillator", params: { type: "sine", frequency: 660 } }, + filter: { kind: "biquadFilter", params: { type: "lowpass", frequency: 1200, Q: 1 } }, + amp: { kind: "gain", params: { gain: 0.25 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "filter", "amp", "out"] }], + }; + + const samples = await renderGraph(graph, 1); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("supports flat routing and fmPattern", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.15 }, + nodes: { + fm: { kind: "fmPattern", params: { carrierFrequency: 330, modulatorFrequency: 120, modulationIndex: 80 } }, + out: { kind: "destination" }, + }, + routing: [{ from: "fm", to: "out" }], + }; + + const samples = await renderGraph(graph, 2); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("supports envelope scheduling", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + osc: { kind: "oscillator", params: { type: "triangle", frequency: 440 } }, + env: { kind: "envelope", params: { attack: 0.01, decay: 0.08, sustain: 0.2, release: 0.05 } }, + out: { kind: "destination" }, + }, + routing: [{ chain: ["osc", "env", "out"] }], + }; + + const samples = await renderGraph(graph, 3); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("supports automation set, linear ramp, and lfo", async () => { + const graph = { + version: "0.1", + meta: { duration: 0.2 }, + nodes: { + osc: { + kind: "oscillator", + params: { type: "sine", frequency: 220 }, + automation: { + frequency: [ + { kind: "set", time: 0, value: 220 }, + { kind: "linearRamp", time: 0.2, value: 660 }, + { kind: "lfo", rate: 4, depth: 20, offset: 440, start: 0, end: 0.2, step: 1 / 64, wave: "sine" }, + ], + }, + }, + gain: { kind: "gain", params: { gain: 0.2 } }, + 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("uses graph.random.seed deterministically for noise", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.2 }, + random: { algorithm: "xorshift32", seed: 999 }, + nodes: { + noise: { kind: "noise", params: { color: "pink", level: 0.2 } }, + out: { kind: "destination" }, + }, + routing: [{ from: "noise", to: "out" }], + }; + + const a = await renderGraph(graph, 100); + const b = await renderGraph(graph, 200); + expect(a.length).toBe(b.length); + + for (let i = 0; i < Math.min(a.length, 128); i += 1) { + expect(a[i]).toBeCloseTo(b[i] as number, 6); + } + }); + + it("supports bufferSource via loadSample integration", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.12 }, + nodes: { + sample: { kind: "bufferSource", params: { sample: "footstep-gravel/impact.wav", playbackRate: 1.1 } }, + out: { kind: "destination" }, + }, + routing: [{ from: "sample", to: "out" }], + }; + + const samples = await renderGraph(graph, 5); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("supports lfo and constant nodes routing into AudioParams", async () => { + const graph: ToneGraphDocument = { + version: "0.1", + meta: { duration: 0.15 }, + nodes: { + osc: { kind: "oscillator", params: { frequency: 220 } }, + lfo: { kind: "lfo", params: { rate: 3, depth: 40, offset: 0 } }, + c: { kind: "constant", params: { value: 440 } }, + amp: { kind: "gain", params: { gain: 0.2 } }, + out: { kind: "destination" }, + }, + routing: [ + { chain: ["osc", "amp", "out"] }, + { from: "lfo", to: "osc.frequency" }, + { from: "c", to: "osc.frequency" }, + ], + }; + + const samples = await renderGraph(graph, 6); + expect(samples.some((sample) => sample !== 0)).toBe(true); + }); + + it("throws for unsupported node kind values", async () => { + const graph = { + version: "0.1", + meta: { duration: 0.1 }, + nodes: { + bad: { kind: "not-a-real-kind" }, + }, + routing: [], + } as unknown as ToneGraphDocument; + + const ctx = new OfflineAudioContext(1, 4410, 44100); + await expect(loadToneGraph(graph, ctx, createRng(1))).rejects.toThrow("Unsupported node kind"); + }); +}); diff --git a/src/core/tonegraph.ts b/src/core/tonegraph.ts new file mode 100644 index 0000000..93e7b92 --- /dev/null +++ b/src/core/tonegraph.ts @@ -0,0 +1,629 @@ +import { loadSample } from "../audio/sample-loader.js"; +import { createRng, type Rng } from "./rng.js"; +import type { + ToneGraphDocument, + ToneGraphNodeDefinition, + ToneGraphRoutingEntry, +} from "./tonegraph-schema.js"; + +type ModulationWave = "sine" | "square" | "sawtooth" | "triangle"; + +interface AutomationSetEvent { + kind: "set"; + time: number; + value: number; +} + +interface AutomationLinearRampEvent { + kind: "linearRamp"; + time: number; + value: number; +} + +interface AutomationLfoEvent { + kind: "lfo"; + rate: number; + depth: number; + offset?: number; + start?: number; + end?: number; + step?: number; + wave?: ModulationWave; +} + +type AutomationEvent = + | AutomationSetEvent + | AutomationLinearRampEvent + | AutomationLfoEvent; + +interface RuntimeNode { + output: AudioNode; + params: Record; + startables: AudioScheduledSourceNode[]; + stoppables: AudioScheduledSourceNode[]; + internals: AudioNode[]; + envelope?: { + attack: number; + decay: number; + sustain: number; + release: number; + gain: AudioParam; + }; +} + +export interface ToneGraphHandle { + graph: ToneGraphDocument; + nodes: Record; + duration: number; + start: (time?: number) => void; + stop: (time?: number) => void; + dispose: () => void; +} + +type NodeFactory = ( + ctx: BaseAudioContext, + id: string, + def: ToneGraphNodeDefinition, + rng: Rng, +) => Promise; + +const SUPPORTED_OSC_TYPES = new Set(["sine", "square", "sawtooth", "triangle"]); +const SUPPORTED_FILTER_TYPES = new Set(["lowpass", "highpass", "bandpass"]); + +function ensureNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + return value; +} + +function getDurationHint(graph: ToneGraphDocument): number { + if (typeof graph.meta?.duration === "number" && Number.isFinite(graph.meta.duration) && graph.meta.duration > 0) { + return graph.meta.duration; + } + + let duration = 0; + for (const def of Object.values(graph.nodes)) { + if (def.kind === "envelope") { + const attack = def.params?.attack ?? 0.01; + const decay = def.params?.decay ?? 0.1; + const release = def.params?.release ?? 0; + duration = Math.max(duration, attack + decay + release); + } + } + + return duration > 0 ? duration : 1; +} + +function generateNoiseBuffer( + ctx: BaseAudioContext, + durationSeconds: number, + color: "white" | "pink" | "brown", + rng: Rng, +): AudioBuffer { + const frameCount = Math.max(1, Math.ceil(ctx.sampleRate * durationSeconds)); + const buffer = ctx.createBuffer(1, frameCount, ctx.sampleRate); + const data = buffer.getChannelData(0); + + if (color === "white") { + for (let i = 0; i < frameCount; i += 1) { + data[i] = (rng() * 2) - 1; + } + return buffer; + } + + if (color === "brown") { + let last = 0; + for (let i = 0; i < frameCount; i += 1) { + const white = (rng() * 2) - 1; + last = (last + (0.02 * white)) / 1.02; + data[i] = last * 3.5; + } + return buffer; + } + + let b0 = 0; + let b1 = 0; + let b2 = 0; + let b3 = 0; + let b4 = 0; + let b5 = 0; + let b6 = 0; + for (let i = 0; i < frameCount; i += 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 sample = b0 + b1 + b2 + b3 + b4 + b5 + (b6 + (white * 0.5362)); + data[i] = sample * 0.11; + b6 = white * 0.115926; + } + + return buffer; +} + +function parseAutomationList(raw: unknown, nodeId: string, paramName: string): AutomationEvent[] { + if (!Array.isArray(raw)) { + throw new Error(`Node "${nodeId}" automation for param "${paramName}" must be an array.`); + } + + return raw.map((entry, index) => { + if (typeof entry !== "object" || entry === null) { + throw new Error(`Node "${nodeId}" automation[${index}] for param "${paramName}" must be an object.`); + } + + const event = entry as Record; + const kindRaw = event.kind; + if (typeof kindRaw !== "string") { + throw new Error(`Node "${nodeId}" automation[${index}] for param "${paramName}" requires string kind.`); + } + + if (kindRaw === "set") { + return { + kind: "set", + time: ensureNumber(event.time, `Node "${nodeId}" automation[${index}].time`), + value: ensureNumber(event.value, `Node "${nodeId}" automation[${index}].value`), + }; + } + + if (kindRaw === "linearRamp") { + return { + kind: "linearRamp", + time: ensureNumber(event.time, `Node "${nodeId}" automation[${index}].time`), + value: ensureNumber(event.value, `Node "${nodeId}" automation[${index}].value`), + }; + } + + if (kindRaw === "lfo") { + const waveRaw = event.wave; + const wave: ModulationWave = + waveRaw === "square" || waveRaw === "sawtooth" || waveRaw === "triangle" ? waveRaw : "sine"; + + const lfoEvent: AutomationLfoEvent = { + kind: "lfo", + rate: ensureNumber(event.rate, `Node "${nodeId}" automation[${index}].rate`), + depth: ensureNumber(event.depth, `Node "${nodeId}" automation[${index}].depth`), + wave, + }; + + if (event.offset !== undefined) { + lfoEvent.offset = ensureNumber(event.offset, `Node "${nodeId}" automation[${index}].offset`); + } + if (event.start !== undefined) { + lfoEvent.start = ensureNumber(event.start, `Node "${nodeId}" automation[${index}].start`); + } + if (event.end !== undefined) { + lfoEvent.end = ensureNumber(event.end, `Node "${nodeId}" automation[${index}].end`); + } + if (event.step !== undefined) { + lfoEvent.step = ensureNumber(event.step, `Node "${nodeId}" automation[${index}].step`); + } + + return lfoEvent; + } + + throw new Error( + `Node "${nodeId}" automation[${index}] for param "${paramName}" has unsupported kind "${kindRaw}".`, + ); + }); +} + +function extractNodeAutomation(nodeId: string, def: ToneGraphNodeDefinition): Map { + const result = new Map(); + + const raw = (def as unknown as { automation?: unknown }).automation; + if (raw === undefined) { + return result; + } + + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`Node "${nodeId}" automation must be an object map of param name -> events.`); + } + + for (const [paramName, events] of Object.entries(raw as Record)) { + result.set(paramName, parseAutomationList(events, nodeId, paramName)); + } + + return result; +} + +function waveformSample(wave: ModulationWave, phase: number): number { + const wrapped = phase - Math.floor(phase); + if (wave === "sine") { + return Math.sin(wrapped * Math.PI * 2); + } + if (wave === "square") { + return wrapped < 0.5 ? 1 : -1; + } + if (wave === "sawtooth") { + return (2 * wrapped) - 1; + } + return 1 - (4 * Math.abs(wrapped - 0.5)); +} + +function applyAutomationEvents( + param: AudioParam, + events: AutomationEvent[], + durationHint: number, + baseTime = 0, +): void { + for (const event of events) { + if (event.kind === "set") { + param.setValueAtTime(event.value, baseTime + event.time); + continue; + } + + if (event.kind === "linearRamp") { + param.linearRampToValueAtTime(event.value, baseTime + event.time); + continue; + } + + const start = baseTime + (event.start ?? 0); + const end = baseTime + (event.end ?? durationHint); + const step = event.step ?? (1 / 128); + const offset = event.offset ?? 0; + const dt = Math.max(1 / 2048, step); + + for (let t = start; t <= end; t += dt) { + const phase = (t - start) * event.rate; + const value = offset + (event.depth * waveformSample(event.wave ?? "sine", phase)); + param.setValueAtTime(value, t); + } + } +} + +function resolveEndpoint( + ref: string, + nodes: Map, +): { output?: AudioNode; param?: AudioParam } { + const dotIndex = ref.indexOf("."); + if (dotIndex < 0) { + const node = nodes.get(ref); + return { output: node?.output }; + } + + const nodeId = ref.slice(0, dotIndex); + const paramName = ref.slice(dotIndex + 1); + const node = nodes.get(nodeId); + if (!node) { + return {}; + } + return { param: node.params[paramName] }; +} + +async function createRuntimeNode( + ctx: BaseAudioContext, + id: string, + def: ToneGraphNodeDefinition, + rng: Rng, + durationHint: number, +): Promise { + const factoryMap: Record = { + destination: async (context) => ({ + output: context.destination, + params: {}, + startables: [], + stoppables: [], + internals: [context.destination], + }), + gain: async (context, _id, nodeDef) => { + const gainNode = context.createGain(); + gainNode.gain.value = nodeDef.kind === "gain" ? (nodeDef.params?.gain ?? 1) : 1; + return { + output: gainNode, + params: { gain: gainNode.gain }, + startables: [], + stoppables: [], + internals: [gainNode], + }; + }, + oscillator: async (context, _id, nodeDef) => { + const oscillator = context.createOscillator(); + const type = nodeDef.kind === "oscillator" ? (nodeDef.params?.type ?? "sine") : "sine"; + if (!SUPPORTED_OSC_TYPES.has(type)) { + throw new Error(`Node "${id}" oscillator type "${type}" is unsupported.`); + } + oscillator.type = type; + oscillator.frequency.value = nodeDef.kind === "oscillator" ? (nodeDef.params?.frequency ?? 440) : 440; + oscillator.detune.value = nodeDef.kind === "oscillator" ? (nodeDef.params?.detune ?? 0) : 0; + return { + output: oscillator, + params: { frequency: oscillator.frequency, detune: oscillator.detune }, + startables: [oscillator], + stoppables: [oscillator], + internals: [oscillator], + }; + }, + biquadFilter: async (context, _id, nodeDef) => { + const filter = context.createBiquadFilter(); + const type = nodeDef.kind === "biquadFilter" ? (nodeDef.params?.type ?? "lowpass") : "lowpass"; + if (!SUPPORTED_FILTER_TYPES.has(type)) { + throw new Error(`Node "${id}" filter type "${type}" is unsupported.`); + } + filter.type = type; + filter.frequency.value = nodeDef.kind === "biquadFilter" ? (nodeDef.params?.frequency ?? 1000) : 1000; + filter.Q.value = nodeDef.kind === "biquadFilter" ? (nodeDef.params?.Q ?? 1) : 1; + filter.gain.value = nodeDef.kind === "biquadFilter" ? (nodeDef.params?.gain ?? 0) : 0; + return { + output: filter, + params: { frequency: filter.frequency, Q: filter.Q, gain: filter.gain, detune: filter.detune }, + startables: [], + stoppables: [], + internals: [filter], + }; + }, + noise: async (context, _id, nodeDef, activeRng) => { + const color = nodeDef.kind === "noise" ? (nodeDef.params?.color ?? "white") : "white"; + const level = nodeDef.kind === "noise" ? (nodeDef.params?.level ?? 1) : 1; + const source = context.createBufferSource(); + source.buffer = generateNoiseBuffer(context, durationHint, color, activeRng); + source.loop = false; + const levelGain = context.createGain(); + levelGain.gain.value = level; + source.connect(levelGain); + return { + output: levelGain, + params: { level: levelGain.gain }, + startables: [source], + stoppables: [source], + internals: [source, levelGain], + }; + }, + bufferSource: async (context, nodeId, nodeDef) => { + const source = context.createBufferSource(); + if (nodeDef.kind !== "bufferSource") { + throw new Error(`Node "${nodeId}" must be bufferSource.`); + } + + if (!nodeDef.params?.sample) { + throw new Error(`Node "${nodeId}" of kind bufferSource requires params.sample.`); + } + + const sampleCtx = context as Parameters[1]; + source.buffer = await loadSample(nodeDef.params.sample, sampleCtx); + source.loop = nodeDef.params.loop ?? false; + source.playbackRate.value = nodeDef.params.playbackRate ?? 1; + return { + output: source, + params: { playbackRate: source.playbackRate, detune: source.detune }, + startables: [source], + stoppables: [source], + internals: [source], + }; + }, + envelope: async (context, _id, nodeDef) => { + const gainNode = context.createGain(); + gainNode.gain.value = 0; + const attack = nodeDef.kind === "envelope" ? (nodeDef.params?.attack ?? 0.01) : 0.01; + const decay = nodeDef.kind === "envelope" ? (nodeDef.params?.decay ?? 0.1) : 0.1; + const sustain = nodeDef.kind === "envelope" ? (nodeDef.params?.sustain ?? 0) : 0; + const release = nodeDef.kind === "envelope" ? (nodeDef.params?.release ?? 0) : 0; + return { + output: gainNode, + params: { gain: gainNode.gain }, + startables: [], + stoppables: [], + internals: [gainNode], + envelope: { attack, decay, sustain, release, gain: gainNode.gain }, + }; + }, + lfo: async (context, _id, nodeDef) => { + const osc = context.createOscillator(); + const depthGain = context.createGain(); + const offset = context.createConstantSource(); + const output = context.createGain(); + + const rate = nodeDef.kind === "lfo" ? (nodeDef.params?.rate ?? 1) : 1; + const depth = nodeDef.kind === "lfo" ? (nodeDef.params?.depth ?? 1) : 1; + const offsetValue = nodeDef.kind === "lfo" ? (nodeDef.params?.offset ?? 0) : 0; + const type = nodeDef.kind === "lfo" ? (nodeDef.params?.type ?? "sine") : "sine"; + if (!SUPPORTED_OSC_TYPES.has(type)) { + throw new Error(`Node "${id}" lfo type "${type}" is unsupported.`); + } + + osc.type = type; + osc.frequency.value = rate; + depthGain.gain.value = depth; + offset.offset.value = offsetValue; + + osc.connect(depthGain); + depthGain.connect(output); + offset.connect(output); + + return { + output, + params: { rate: osc.frequency, depth: depthGain.gain, offset: offset.offset }, + startables: [osc, offset], + stoppables: [osc, offset], + internals: [osc, depthGain, offset, output], + }; + }, + constant: async (context, _id, nodeDef) => { + const source = context.createConstantSource(); + source.offset.value = nodeDef.kind === "constant" ? (nodeDef.params?.value ?? 0) : 0; + return { + output: source, + params: { value: source.offset }, + startables: [source], + stoppables: [source], + internals: [source], + }; + }, + fmPattern: async (context, _id, nodeDef) => { + const carrier = context.createOscillator(); + const modulator = context.createOscillator(); + const modulationGain = context.createGain(); + const output = context.createGain(); + + const carrierFrequency = nodeDef.kind === "fmPattern" + ? (nodeDef.params?.carrierFrequency ?? 440) + : 440; + const modulatorFrequency = nodeDef.kind === "fmPattern" + ? (nodeDef.params?.modulatorFrequency ?? 220) + : 220; + const modulationIndex = nodeDef.kind === "fmPattern" + ? (nodeDef.params?.modulationIndex ?? 1) + : 1; + + carrier.type = "sine"; + modulator.type = "sine"; + carrier.frequency.value = carrierFrequency; + modulator.frequency.value = modulatorFrequency; + modulationGain.gain.value = modulationIndex; + + modulator.connect(modulationGain); + modulationGain.connect(carrier.frequency); + carrier.connect(output); + + return { + output, + params: { + carrierFrequency: carrier.frequency, + modulatorFrequency: modulator.frequency, + modulationIndex: modulationGain.gain, + }, + startables: [carrier, modulator], + stoppables: [carrier, modulator], + internals: [carrier, modulator, modulationGain, output], + }; + }, + }; + + const factory = factoryMap[def.kind]; + if (!factory) { + throw new Error(`Unsupported node kind "${def.kind}" for node "${id}".`); + } + return factory(ctx, id, def, rng); +} + +function expandRouting(entries: ToneGraphRoutingEntry[]): Array<{ from: string; to: string }> { + const links: Array<{ from: string; to: string }> = []; + for (const entry of entries) { + if ("chain" in entry) { + for (let i = 0; i < entry.chain.length - 1; i += 1) { + links.push({ from: entry.chain[i]!, to: entry.chain[i + 1]! }); + } + continue; + } + links.push(entry); + } + return links; +} + +export async function loadToneGraph( + graph: ToneGraphDocument, + ctx: BaseAudioContext, + rng: Rng, +): Promise { + const duration = getDurationHint(graph); + const activeRng = graph.random?.seed !== undefined ? createRng(graph.random.seed) : rng; + + const runtimeNodes = new Map(); + for (const [id, def] of Object.entries(graph.nodes)) { + runtimeNodes.set(id, await createRuntimeNode(ctx, id, def, activeRng, duration)); + } + + for (const route of expandRouting(graph.routing)) { + const from = resolveEndpoint(route.from, runtimeNodes); + const to = resolveEndpoint(route.to, runtimeNodes); + + if (from.param) { + throw new Error(`Invalid route ${route.from} -> ${route.to}: routing from AudioParam is not supported.`); + } + if (!from.output) { + throw new Error(`Invalid route ${route.from} -> ${route.to}: source node is missing.`); + } + if (!to.output && !to.param) { + throw new Error(`Invalid route ${route.from} -> ${route.to}: destination is missing.`); + } + + if (to.param) { + from.output.connect(to.param); + } else { + from.output.connect(to.output as AudioNode); + } + } + + for (const [id, def] of Object.entries(graph.nodes)) { + const runtime = runtimeNodes.get(id); + if (!runtime) { + continue; + } + + const automation = extractNodeAutomation(id, def); + for (const [paramName, events] of automation) { + const param = runtime.params[paramName]; + if (!param) { + throw new Error(`Node "${id}" automation targets unknown AudioParam "${paramName}".`); + } + applyAutomationEvents(param, events, duration); + } + } + + const started = new Set(); + const nodes: Record = {}; + for (const [id, runtime] of runtimeNodes) { + nodes[id] = runtime.output; + } + + const start = (time = 0): void => { + for (const runtime of runtimeNodes.values()) { + if (runtime.envelope) { + const attackEnd = time + runtime.envelope.attack; + const decayEnd = attackEnd + runtime.envelope.decay; + runtime.envelope.gain.cancelScheduledValues(time); + runtime.envelope.gain.setValueAtTime(0, time); + runtime.envelope.gain.linearRampToValueAtTime(1, attackEnd); + runtime.envelope.gain.linearRampToValueAtTime(runtime.envelope.sustain, decayEnd); + } + + for (const source of runtime.startables) { + if (started.has(source)) { + continue; + } + source.start(time); + started.add(source); + } + } + }; + + 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); + } + + for (const source of runtime.stoppables) { + if (!started.has(source)) { + continue; + } + source.stop(time); + } + } + }; + + const dispose = (): void => { + for (const runtime of runtimeNodes.values()) { + for (const node of runtime.internals) { + node.disconnect(); + } + } + }; + + return { + graph, + nodes, + duration, + start, + stop, + dispose, + }; +} + +export default loadToneGraph; diff --git a/src/recipes/ambient-wind-gust.ts b/src/recipes/ambient-wind-gust.ts deleted file mode 100644 index 698fe4f..0000000 --- a/src/recipes/ambient-wind-gust.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Ambient Wind Gust Recipe - * - * An environmental wind burst using filtered noise with LFO modulation. - * The filter cutoff sweeps slowly to create natural-sounding wind - * movement, while the amplitude envelope controls the overall swell - * and fade of the gust. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * ambient-wind-gust-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getAmbientWindGustParams } from "./ambient-wind-gust-params.js"; - -export { getAmbientWindGustParams } from "./ambient-wind-gust-params.js"; -export type { AmbientWindGustParams } from "./ambient-wind-gust-params.js"; - -/** - * Creates an ambient wind gust Recipe. - * - * Noise source through a bandpass filter with LFO-modulated cutoff, - * shaped by a swell-sustain-fade amplitude envelope. - */ -export function createAmbientWindGust(rng: Rng): Recipe { - const params = getAmbientWindGustParams(rng); - - // Noise source (pink noise for more natural wind character) - const noise = new Tone.Noise("pink"); - - // Bandpass filter with LFO-modulated cutoff - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - - // LFO for filter cutoff modulation - const lfo = new Tone.LFO(params.lfoRate, params.filterFreq - params.lfoDepth * 0.5, params.filterFreq + params.lfoDepth * 0.5); - lfo.connect(filter.frequency); - - // Overall level - const level = new Tone.Gain(params.level); - - // Amplitude envelope: swell up, sustain, fade out - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0.01, // negligible decay - sustain: 1.0, - release: params.release, - }); - - noise.chain(filter, level, env); - - const duration = params.attack + params.sustain + params.release; - - return { - start(time: number): void { - lfo.start(time); - noise.start(time); - env.triggerAttack(time); - // Schedule release after sustain period - env.triggerRelease(time + params.attack + params.sustain); - }, - stop(time: number): void { - lfo.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-burn.ts b/src/recipes/card-burn.ts deleted file mode 100644 index dbd94bb..0000000 --- a/src/recipes/card-burn.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Card Burn Recipe - * - * Destructive synthesis: lowpass-swept noise with descending filter - * sweep for a dissolve/fire effect. Includes a crackle layer and - * a low rumble for body. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardBurnParams } from "./card-burn-params.js"; - -export { getCardBurnParams } from "./card-burn-params.js"; -export type { CardBurnParams } from "./card-burn-params.js"; - -/** - * Creates a card-burn Recipe. - * - * Lowpass Noise (sweeping down) + Highpass Crackle + Sine Rumble -> Envelope -> Destination - */ -export function createCardBurn(rng: Rng): Recipe { - const params = getCardBurnParams(rng); - - // Main noise layer with descending lowpass sweep - const noise = new Tone.Noise("white"); - const lpf = new Tone.Filter(params.filterStart, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(lpf, noiseGain, noiseEnv); - - // Crackle layer: highpass-filtered noise for fire texture - const crackle = new Tone.Noise("pink"); - const hpf = new Tone.Filter(5000, "highpass"); - const crackleGain = new Tone.Gain(params.crackleLevel); - const crackleEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.7, - sustain: 0, - release: 0, - }); - - crackle.chain(hpf, crackleGain, crackleEnv); - - // Low rumble for body - const rumble = new Tone.Oscillator(params.rumbleFreq, "sine"); - const rumbleGain = new Tone.Gain(params.rumbleLevel); - const rumbleEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - rumble.chain(rumbleGain, rumbleEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - crackle.start(time); - rumble.start(time); - noiseEnv.triggerAttack(time); - crackleEnv.triggerAttack(time); - rumbleEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - crackle.stop(time); - rumble.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - crackleEnv.toDestination(); - rumbleEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-chip-stack.ts b/src/recipes/card-chip-stack.ts deleted file mode 100644 index abb5759..0000000 --- a/src/recipes/card-chip-stack.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Card Chip Stack Recipe - * - * Pure synthesis: layered percussive click with brief tonal ring - * for stacking poker chips or game tokens. Bandpass noise burst - * for the "clack" impact and a damped sine for the ring-out. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardChipStackParams } from "./card-chip-stack-params.js"; - -export { getCardChipStackParams } from "./card-chip-stack-params.js"; -export type { CardChipStackParams } from "./card-chip-stack-params.js"; - -/** - * Creates a card-chip-stack Recipe. - * - * Bandpass Noise (click) + Sine (ring) -> Envelope -> Destination - */ -export function createCardChipStack(rng: Rng): Recipe { - const params = getCardChipStackParams(rng); - - // Percussive click: bandpass-filtered noise - const noise = new Tone.Noise("white"); - const clickFilter = new Tone.Filter(params.clickFreq, "bandpass"); - clickFilter.Q.value = params.clickQ; - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.clickDecay, - sustain: 0, - release: 0, - }); - - noise.chain(clickFilter, clickGain, clickEnv); - - // Ring-out tone: damped sine - const osc = new Tone.Oscillator(params.ringFreq, "sine"); - const ringGain = new Tone.Gain(params.ringLevel); - const ringEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.ringDecay, - sustain: 0, - release: 0, - }); - - osc.chain(ringGain, ringEnv); - - const duration = params.attack + Math.max(params.clickDecay, params.ringDecay); - - return { - start(time: number): void { - noise.start(time); - osc.start(time); - clickEnv.triggerAttack(time); - ringEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - osc.stop(time); - }, - toDestination(): void { - clickEnv.toDestination(); - ringEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-coin-collect-hybrid.ts b/src/recipes/card-coin-collect-hybrid.ts deleted file mode 100644 index d0e38b7..0000000 --- a/src/recipes/card-coin-collect-hybrid.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Card Coin Collect Hybrid Recipe - * - * Sample-hybrid: layers a CC0 metallic coin sample with procedurally - * varied synthesis. The sample provides realistic metallic texture - * while a sine oscillator adds tonal richness per seed. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * card-coin-collect-hybrid-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardCoinCollectHybridParams } from "./card-coin-collect-hybrid-params.js"; - -export { getCardCoinCollectHybridParams } from "./card-coin-collect-hybrid-params.js"; -export type { CardCoinCollectHybridParams } from "./card-coin-collect-hybrid-params.js"; - -/** - * Creates a card-coin-collect-hybrid Recipe for browser/interactive playback. - * - * Layers a Tone.Player (coin sample) with synthesized tonal + shimmer layers. - */ -export function createCardCoinCollectHybrid(rng: Rng): Recipe { - const params = getCardCoinCollectHybridParams(rng); - - // Sample layer: CC0 metallic coin clink - const player = new Tone.Player("/assets/samples/card-coin-collect/clink.wav"); - const sampleGain = new Tone.Gain(params.mixLevel); - - player.chain(sampleGain); - - // Synthesis tonal layer - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - const synthGain = new Tone.Gain(params.synthLevel); - const synthEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(synthGain, synthEnv); - - // Shimmer layer: highpass-filtered noise for metallic texture - const noise = new Tone.Noise("white"); - const shimmerFilter = new Tone.Filter(params.filterCutoff, "highpass"); - const shimmerGain = new Tone.Gain(params.shimmerLevel); - const shimmerEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.attack + 0.03, - sustain: 0, - release: 0, - }); - - noise.chain(shimmerFilter, shimmerGain, shimmerEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - player.start(time); - osc.start(time); - noise.start(time); - synthEnv.triggerAttack(time); - shimmerEnv.triggerAttack(time); - }, - stop(time: number): void { - player.stop(time); - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - sampleGain.toDestination(); - synthEnv.toDestination(); - shimmerEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-coin-collect.ts b/src/recipes/card-coin-collect.ts deleted file mode 100644 index 06e7f3b..0000000 --- a/src/recipes/card-coin-collect.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Card Coin Collect Recipe - * - * Pure synthesis: bright metallic ascending ping for coin/token - * collection. A sine oscillator with upward pitch sweep plus a - * harmonic overtone and highpass noise transient for metallic attack. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardCoinCollectParams } from "./card-coin-collect-params.js"; - -export { getCardCoinCollectParams } from "./card-coin-collect-params.js"; -export type { CardCoinCollectParams } from "./card-coin-collect-params.js"; - -/** - * Creates a card-coin-collect Recipe. - * - * Sine Oscillator (sweep) + Harmonic Oscillator + Highpass Noise -> Envelope -> Destination - */ -export function createCardCoinCollect(rng: Rng): Recipe { - const params = getCardCoinCollectParams(rng); - - // Primary tone with pitch sweep - const osc1 = new Tone.Oscillator(params.baseFreq, "sine"); - const gain1 = new Tone.Gain(params.toneLevel); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - // Harmonic overtone for metallic shimmer - const osc2 = new Tone.Oscillator(params.baseFreq * 2.5, "sine"); - const gain2 = new Tone.Gain(params.harmonicLevel); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.7, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - // Noise transient for metallic clink attack - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(4000, "highpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.attack + 0.02, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc1.start(time); - osc2.start(time); - noise.start(time); - env1.triggerAttack(time); - env2.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc1.stop(time); - osc2.stop(time); - noise.stop(time); - }, - toDestination(): void { - env1.toDestination(); - env2.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-coin-spend.ts b/src/recipes/card-coin-spend.ts deleted file mode 100644 index f2b3dd6..0000000 --- a/src/recipes/card-coin-spend.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Card Coin Spend Recipe - * - * Pure synthesis: muted descending tone for coin/token spend events. - * A filtered sine oscillator with downward pitch sweep and soft noise - * layer creates a "dropping" feel. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardCoinSpendParams } from "./card-coin-spend-params.js"; - -export { getCardCoinSpendParams } from "./card-coin-spend-params.js"; -export type { CardCoinSpendParams } from "./card-coin-spend-params.js"; - -/** - * Creates a card-coin-spend Recipe. - * - * Sine Oscillator (descending) -> Lowpass Filter + Noise -> Envelope -> Destination - */ -export function createCardCoinSpend(rng: Rng): Recipe { - const params = getCardCoinSpendParams(rng); - - // Primary descending tone - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - const filter = new Tone.Filter(params.filterCutoff, "lowpass"); - const toneGain = new Tone.Gain(params.toneLevel); - const toneEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(filter, toneGain, toneEnv); - - // Soft noise layer for texture - const noise = new Tone.Noise("pink"); - const noiseLpf = new Tone.Filter(params.filterCutoff * 0.5, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.6, - sustain: 0, - release: 0, - }); - - noise.chain(noiseLpf, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - noise.start(time); - toneEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - toneEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-combo-break.ts b/src/recipes/card-combo-break.ts deleted file mode 100644 index f157440..0000000 --- a/src/recipes/card-combo-break.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Card Combo Break Recipe - * - * Descending dissonant tone with noise burst for interruption. - * Clear negative feedback when a combo chain is broken. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardComboBreakParams } from "./card-combo-break-params.js"; - -export { getCardComboBreakParams } from "./card-combo-break-params.js"; -export type { CardComboBreakParams } from "./card-combo-break-params.js"; - -/** - * Creates a card-combo-break Recipe. - * - * Descending Sine + Dissonant Sine + Noise Burst -> Envelopes -> Destination - */ -export function createCardComboBreak(rng: Rng): Recipe { - const params = getCardComboBreakParams(rng); - - // Main descending tone - const osc = new Tone.Oscillator(params.freqStart, "sawtooth"); - const gain = new Tone.Gain(params.toneLevel); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(gain, env); - - // Dissonant second tone (slightly detuned) - const dissonant = new Tone.Oscillator(params.freqStart * params.dissonanceRatio, "sawtooth"); - const disGain = new Tone.Gain(params.toneLevel * 0.6); - const disEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - dissonant.chain(disGain, disEnv); - - // Noise burst for impact - const noise = new Tone.Noise("white"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.3, - sustain: 0, - release: 0, - }); - - noise.chain(noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - dissonant.start(time); - noise.start(time); - env.triggerAttack(time); - disEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - dissonant.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - disEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-combo-hit.ts b/src/recipes/card-combo-hit.ts deleted file mode 100644 index 80d8c9f..0000000 --- a/src/recipes/card-combo-hit.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Card Combo Hit Recipe - * - * Bright transient with harmonic reinforcement. Positive, punchy - * impact for successful combo hits. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardComboHitParams } from "./card-combo-hit-params.js"; - -export { getCardComboHitParams } from "./card-combo-hit-params.js"; -export type { CardComboHitParams } from "./card-combo-hit-params.js"; - -/** - * Creates a card-combo-hit Recipe. - * - * Sine Fundamental + Harmonic + Highpass Sparkle -> Envelope -> Destination - */ -export function createCardComboHit(rng: Rng): Recipe { - const params = getCardComboHitParams(rng); - - // Fundamental - const osc = new Tone.Oscillator(params.freq, "sine"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(gain, env); - - // Harmonic overtone - const harm = new Tone.Oscillator(params.freq * params.harmonicRatio, "sine"); - const harmGain = new Tone.Gain(params.harmonicLevel); - const harmEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.7, - sustain: 0, - release: 0, - }); - - harm.chain(harmGain, harmEnv); - - // Brightness sparkle: highpass noise burst - const noise = new Tone.Noise("white"); - const hpf = new Tone.Filter(params.brightnessFreq, "highpass"); - const sparkleGain = new Tone.Gain(params.harmonicLevel * 0.5); - const sparkleEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.4, - sustain: 0, - release: 0, - }); - - noise.chain(hpf, sparkleGain, sparkleEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - harm.start(time); - noise.start(time); - env.triggerAttack(time); - harmEnv.triggerAttack(time); - sparkleEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - harm.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - harmEnv.toDestination(); - sparkleEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-deck-presence.ts b/src/recipes/card-deck-presence.ts deleted file mode 100644 index 582aefc..0000000 --- a/src/recipes/card-deck-presence.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Card Deck Presence Recipe - * - * Quiet tonal hum with harmonic shimmer. A subtle ambient texture - * giving the deck a "living" quality. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardDeckPresenceParams } from "./card-deck-presence-params.js"; - -export { getCardDeckPresenceParams } from "./card-deck-presence-params.js"; -export type { CardDeckPresenceParams } from "./card-deck-presence-params.js"; - -/** - * Creates a card-deck-presence Recipe. - * - * Sine Hum + Shimmer Sine (amplitude-tremolo via LFO) -> Gain -> Envelope -> Destination - */ -export function createCardDeckPresence(rng: Rng): Recipe { - const params = getCardDeckPresenceParams(rng); - - // Fundamental hum - const hum = new Tone.Oscillator(params.humFreq, "sine"); - const humGain = new Tone.Gain(params.level); - - // Shimmer harmonic with amplitude tremolo - const shimmer = new Tone.Oscillator(params.humFreq * params.shimmerRatio, "sine"); - const shimmerGain = new Tone.Gain(params.shimmerLevel); - - // LFO for shimmer tremolo - const lfo = new Tone.LFO(params.shimmerRate, 0, params.shimmerLevel); - lfo.connect(shimmerGain.gain); - - // Master envelope - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0.01, - sustain: 1.0, - release: params.release, - }); - - hum.connect(humGain); - humGain.connect(env); - shimmer.connect(shimmerGain); - shimmerGain.connect(env); - - const duration = params.attack + params.sustain + params.release; - - return { - start(time: number): void { - hum.start(time); - shimmer.start(time); - lfo.start(time); - env.triggerAttack(time); - env.triggerRelease(time + params.attack + params.sustain); - }, - stop(time: number): void { - hum.stop(time); - shimmer.stop(time); - lfo.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-defeat-sting.ts b/src/recipes/card-defeat-sting.ts deleted file mode 100644 index 612a5bc..0000000 --- a/src/recipes/card-defeat-sting.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Card Defeat Sting Recipe - * - * Tonal synthesis: descending minor-interval sting for major defeat - * moments. A sine oscillator plays two descending notes through a - * lowpass filter that sweeps downward during the tail decay, creating - * a somber, muffled conclusion. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardDefeatStingParams } from "./card-defeat-sting-params.js"; - -export { getCardDefeatStingParams } from "./card-defeat-sting-params.js"; -export type { CardDefeatStingParams } from "./card-defeat-sting-params.js"; - -/** - * Creates a card-defeat-sting Recipe. - * - * Sine Oscillator (Descending Steps) -> Lowpass Filter (Sweeping) -> Gain Envelope -> Destination - */ -export function createCardDefeatSting(rng: Rng): Recipe { - const params = getCardDefeatStingParams(rng); - - const osc = new Tone.Oscillator(params.startFreq, "sine"); - const filter = new Tone.Filter(params.filterStart, "lowpass"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.noteAttack, - decay: params.noteDuration * 2 + params.tailDecay, - sustain: 0, - release: 0, - }); - - osc.chain(filter, gain, env); - - const totalDuration = params.noteDuration * 2 + params.tailDecay; - - return { - start(time: number): void { - // First note - osc.frequency.setValueAtTime(params.startFreq, time); - // Second note (minor interval drop) - osc.frequency.setValueAtTime( - params.startFreq * params.dropRatio, - time + params.noteDuration, - ); - - // Filter sweep downward during tail - filter.frequency.setValueAtTime(params.filterStart, time); - filter.frequency.linearRampToValueAtTime( - params.filterEnd, - time + totalDuration, - ); - - osc.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return totalDuration; - }, - }; -} diff --git a/src/recipes/card-discard.ts b/src/recipes/card-discard.ts deleted file mode 100644 index a414edd..0000000 --- a/src/recipes/card-discard.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Card Discard Recipe - * - * Short bandpass noise burst with brief tonal thud for discarding a card. - * Subtle, neutral action. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardDiscardParams } from "./card-discard-params.js"; - -export { getCardDiscardParams } from "./card-discard-params.js"; -export type { CardDiscardParams } from "./card-discard-params.js"; - -/** - * Creates a card-discard Recipe. - * - * Bandpass Noise Burst + Sine Thud -> Envelope -> Destination - */ -export function createCardDiscard(rng: Rng): Recipe { - const params = getCardDiscardParams(rng); - - // Noise burst for the flick/toss - const noise = new Tone.Noise("white"); - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(filter, noiseGain, noiseEnv); - - // Low thud for impact - const thud = new Tone.Oscillator(params.thudFreq, "sine"); - const thudGain = new Tone.Gain(params.thudLevel); - const thudEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.decay * 0.5, - sustain: 0, - release: 0, - }); - - thud.chain(thudGain, thudEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - thud.start(time); - noiseEnv.triggerAttack(time); - thudEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - thud.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - thudEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-draw.ts b/src/recipes/card-draw.ts deleted file mode 100644 index ed4df3e..0000000 --- a/src/recipes/card-draw.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Card Draw Recipe - * - * Noise-based with tonal accent: quick upward swipe sound using - * highpass-filtered noise with a brief ascending sine sweep. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardDrawParams } from "./card-draw-params.js"; - -export { getCardDrawParams } from "./card-draw-params.js"; -export type { CardDrawParams } from "./card-draw-params.js"; - -/** - * Creates a card-draw Recipe. - * - * Highpass Noise + Sine Sweep (Ascending) -> Amplitude Envelope -> Destination - */ -export function createCardDraw(rng: Rng): Recipe { - const params = getCardDrawParams(rng); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterFreq, "highpass"); - noiseFilter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const sweep = new Tone.Oscillator(params.sweepBaseFreq, "sine"); - const sweepGain = new Tone.Gain(params.sweepLevel); - const sweepEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - sweep.chain(sweepGain, sweepEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - sweep.frequency.setValueAtTime(params.sweepBaseFreq, time); - sweep.frequency.linearRampToValueAtTime( - params.sweepBaseFreq + params.sweepRange, - time + duration * 0.7, - ); - noise.start(time); - sweep.start(time); - noiseEnv.triggerAttack(time); - sweepEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - sweep.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - sweepEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-failure.ts b/src/recipes/card-failure.ts deleted file mode 100644 index 0cc0ad7..0000000 --- a/src/recipes/card-failure.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Card Failure Recipe - * - * Tonal synthesis: descending dissonant tone for negative game outcomes. - * A sine oscillator sweeps downward while a detuned secondary oscillator - * adds dissonant beating for a "wrong" feeling. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardFailureParams } from "./card-failure-params.js"; - -export { getCardFailureParams } from "./card-failure-params.js"; -export type { CardFailureParams } from "./card-failure-params.js"; - -/** - * Creates a card-failure Recipe. - * - * Sine Oscillator (Descending Sweep) + Detuned Sine -> Amplitude Envelope -> Destination - */ -export function createCardFailure(rng: Rng): Recipe { - const params = getCardFailureParams(rng); - - const osc1 = new Tone.Oscillator(params.startFreq, "sine"); - const gain1 = new Tone.Gain(params.primaryLevel); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - const osc2 = new Tone.Oscillator(params.startFreq + params.detuneOffset, "sine"); - const gain2 = new Tone.Gain(params.secondaryLevel); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc1.frequency.setValueAtTime(params.startFreq, time); - osc1.frequency.linearRampToValueAtTime( - params.startFreq - params.sweepDrop, - time + duration, - ); - osc2.frequency.setValueAtTime(params.startFreq + params.detuneOffset, time); - osc2.frequency.linearRampToValueAtTime( - params.startFreq - params.sweepDrop + params.detuneOffset, - time + duration, - ); - osc1.start(time); - osc2.start(time); - env1.triggerAttack(time); - env2.triggerAttack(time); - }, - stop(time: number): void { - osc1.stop(time); - osc2.stop(time); - }, - toDestination(): void { - env1.toDestination(); - env2.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-fan.ts b/src/recipes/card-fan.ts deleted file mode 100644 index 7b5172a..0000000 --- a/src/recipes/card-fan.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Card Fan Recipe - * - * Tonal/oscillator-based synthesis: ascending pitch sweep representing - * cards fanning out, with a gentle filtered noise bed for texture. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardFanParams } from "./card-fan-params.js"; - -export { getCardFanParams } from "./card-fan-params.js"; -export type { CardFanParams } from "./card-fan-params.js"; - -/** - * Creates a card-fan Recipe. - * - * Sine Oscillator (Ascending Sweep) + Lowpass Noise -> Amplitude Envelope -> Destination - */ -export function createCardFan(rng: Rng): Recipe { - const params = getCardFanParams(rng); - - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - const oscGain = new Tone.Gain(params.sweepLevel); - const oscEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(oscGain, oscEnv); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterCutoff, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.frequency.setValueAtTime(params.baseFreq, time); - osc.frequency.linearRampToValueAtTime( - params.baseFreq + params.sweepRange, - time + duration, - ); - osc.start(time); - noise.start(time); - oscEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - oscEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-flip.ts b/src/recipes/card-flip.ts deleted file mode 100644 index c21e919..0000000 --- a/src/recipes/card-flip.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Flip Recipe - * - * Stylized card flip using bandpass-filtered noise burst with a brief - * sine click for the snap transient. Noise-based primary synthesis. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardFlipParams } from "./card-flip-params.js"; - -export { getCardFlipParams } from "./card-flip-params.js"; -export type { CardFlipParams } from "./card-flip-params.js"; - -/** - * Creates a card-flip Recipe. - * - * Bandpass Noise Burst + Sine Click -> Amplitude Envelope -> Destination - */ -export function createCardFlip(rng: Rng): Recipe { - const params = getCardFlipParams(rng); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterFreq, "bandpass"); - noiseFilter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const click = new Tone.Oscillator(params.clickFreq, "sine"); - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.attack + 0.01, - sustain: 0, - release: 0, - }); - - click.chain(clickGain, clickEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - click.start(time); - noiseEnv.triggerAttack(time); - clickEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - click.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - clickEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-glow.ts b/src/recipes/card-glow.ts deleted file mode 100644 index 9c37437..0000000 --- a/src/recipes/card-glow.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Card Glow Recipe - * - * Sustained filtered oscillator with shimmer/vibrato LFO. Atmospheric - * hum suggesting a card radiating energy or highlight state. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardGlowParams } from "./card-glow-params.js"; - -export { getCardGlowParams } from "./card-glow-params.js"; -export type { CardGlowParams } from "./card-glow-params.js"; - -/** - * Creates a card-glow Recipe. - * - * Sine Oscillator -> LFO Vibrato -> Bandpass Filter -> Envelope -> Destination - */ -export function createCardGlow(rng: Rng): Recipe { - const params = getCardGlowParams(rng); - - // Base tone with LFO vibrato - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - const lfo = new Tone.LFO(params.lfoRate, -params.lfoDepth, params.lfoDepth); - lfo.connect(osc.frequency); - - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0, - sustain: 1, - release: params.release, - }); - - osc.chain(filter, gain, env); - - const duration = params.attack + params.sustain + params.release; - - return { - start(time: number): void { - lfo.start(time); - osc.start(time); - env.triggerAttackRelease(params.attack + params.sustain, time); - }, - stop(time: number): void { - osc.stop(time); - lfo.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-lock.ts b/src/recipes/card-lock.ts deleted file mode 100644 index 74f45a8..0000000 --- a/src/recipes/card-lock.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Card Lock Recipe - * - * Mechanical click transient + lowpass filter sweep downward on noise. - * Suggests a card being locked, sealed, or constrained. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardLockParams } from "./card-lock-params.js"; - -export { getCardLockParams } from "./card-lock-params.js"; -export type { CardLockParams } from "./card-lock-params.js"; - -/** - * Creates a card-lock Recipe. - * - * Click Sine + Lowpass-Swept Noise -> Envelopes -> Destination - */ -export function createCardLock(rng: Rng): Recipe { - const params = getCardLockParams(rng); - - // Mechanical click transient - const click = new Tone.Oscillator(params.clickFreq, "square"); - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: 0.01, - sustain: 0, - release: 0, - }); - - click.chain(clickGain, clickEnv); - - // Noise body with descending lowpass sweep - const noise = new Tone.Noise("white"); - const lpf = new Tone.Filter(params.filterStart, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(lpf, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - click.start(time); - noise.start(time); - clickEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - click.stop(time); - noise.stop(time); - }, - toDestination(): void { - clickEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-match.ts b/src/recipes/card-match.ts deleted file mode 100644 index dd96772..0000000 --- a/src/recipes/card-match.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Match Recipe - * - * Dual-tone confirmation — satisfying "ding-ding" for successful - * card matches in matching/memory games. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardMatchParams } from "./card-match-params.js"; - -export { getCardMatchParams } from "./card-match-params.js"; -export type { CardMatchParams } from "./card-match-params.js"; - -/** - * Creates a card-match Recipe. - * - * Sine Tone 1 + Delayed Sine Tone 2 (harmonic above) -> Envelopes -> Destination - */ -export function createCardMatch(rng: Rng): Recipe { - const params = getCardMatchParams(rng); - - // First tone - const osc1 = new Tone.Oscillator(params.tone1Freq, "sine"); - const gain1 = new Tone.Gain(params.level); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - // Second tone (higher, delayed) - const osc2 = new Tone.Oscillator(params.tone1Freq * params.tone2Ratio, "sine"); - const gain2 = new Tone.Gain(params.level * 0.85); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - const duration = params.tone2Delay + params.attack + params.decay; - - return { - start(time: number): void { - osc1.start(time); - env1.triggerAttack(time); - osc2.start(time + params.tone2Delay); - env2.triggerAttack(time + params.tone2Delay); - }, - stop(time: number): void { - osc1.stop(time); - osc2.stop(time); - }, - toDestination(): void { - env1.toDestination(); - env2.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-multiplier-up.ts b/src/recipes/card-multiplier-up.ts deleted file mode 100644 index 25c0fb0..0000000 --- a/src/recipes/card-multiplier-up.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Multiplier Up Recipe - * - * Rising arpeggio with accelerating pitch steps. Escalating positive - * feedback for multiplier increases. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardMultiplierUpParams } from "./card-multiplier-up-params.js"; - -export { getCardMultiplierUpParams } from "./card-multiplier-up-params.js"; -export type { CardMultiplierUpParams } from "./card-multiplier-up-params.js"; - -/** - * Creates a card-multiplier-up Recipe. - * - * Rising Arpeggio: N Sine Notes (ascending intervals) -> Envelope -> Destination - */ -export function createCardMultiplierUp(rng: Rng): Recipe { - const params = getCardMultiplierUpParams(rng); - - const oscillators: Tone.Oscillator[] = []; - const envelopes: Tone.AmplitudeEnvelope[] = []; - const gains: Tone.Gain[] = []; - - for (let i = 0; i < params.noteCount; i++) { - const freq = params.baseFreq * Math.pow(params.intervalRatio, i); - const osc = new Tone.Oscillator(freq, "sine"); - const g = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.noteDuration, - sustain: 0, - release: 0, - }); - - osc.chain(g, env); - oscillators.push(osc); - gains.push(g); - envelopes.push(env); - } - - const totalDuration = params.noteCount * params.noteDuration + params.attack; - - return { - start(time: number): void { - for (let i = 0; i < params.noteCount; i++) { - const noteTime = time + i * params.noteDuration; - oscillators[i].start(noteTime); - envelopes[i].triggerAttack(noteTime); - } - }, - stop(time: number): void { - for (const osc of oscillators) { - osc.stop(time); - } - }, - toDestination(): void { - for (const env of envelopes) { - env.toDestination(); - } - }, - get duration(): number { - return totalDuration; - }, - }; -} diff --git a/src/recipes/card-place.ts b/src/recipes/card-place.ts deleted file mode 100644 index f7cbbe0..0000000 --- a/src/recipes/card-place.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Place Recipe - * - * Noise-based synthesis: short lowpass-filtered thud for a card landing - * on a surface, with a subtle sine click accent. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardPlaceParams } from "./card-place-params.js"; - -export { getCardPlaceParams } from "./card-place-params.js"; -export type { CardPlaceParams } from "./card-place-params.js"; - -/** - * Creates a card-place Recipe. - * - * Lowpass Noise (Thud) + Sine Click -> Amplitude Envelope -> Destination - */ -export function createCardPlace(rng: Rng): Recipe { - const params = getCardPlaceParams(rng); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterFreq, "lowpass"); - noiseFilter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.bodyLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.bodyDecay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const click = new Tone.Oscillator(params.clickFreq, "sine"); - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.attack + 0.015, - sustain: 0, - release: 0, - }); - - click.chain(clickGain, clickEnv); - - const duration = params.attack + params.bodyDecay; - - return { - start(time: number): void { - noise.start(time); - click.start(time); - noiseEnv.triggerAttack(time); - clickEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - click.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - clickEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-power-down.ts b/src/recipes/card-power-down.ts deleted file mode 100644 index 607eafa..0000000 --- a/src/recipes/card-power-down.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Card Power-Down Recipe - * - * Descending pitch sweep with lowpass filter decay and subtle noise - * grit. Dark, deflating motion for card ability deactivation or power loss. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardPowerDownParams } from "./card-power-down-params.js"; - -export { getCardPowerDownParams } from "./card-power-down-params.js"; -export type { CardPowerDownParams } from "./card-power-down-params.js"; - -/** - * Creates a card-power-down Recipe. - * - * Descending Sine -> Lowpass Filter -> Envelope + Noise grit -> Destination - */ -export function createCardPowerDown(rng: Rng): Recipe { - const params = getCardPowerDownParams(rng); - - // Main oscillator: descending pitch - const osc = new Tone.Oscillator(params.freqStart, "sine"); - const lpf = new Tone.Filter(params.filterCutoff, "lowpass"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(lpf, gain, env); - - // Noise grit layer - const noise = new Tone.Noise("white"); - const noiseLpf = new Tone.Filter(params.filterCutoff * 0.5, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.6, - sustain: 0, - release: 0, - }); - - noise.chain(noiseLpf, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - noise.start(time); - env.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-power-up.ts b/src/recipes/card-power-up.ts deleted file mode 100644 index 28dc9f9..0000000 --- a/src/recipes/card-power-up.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Power-Up Recipe - * - * Ascending pitch sweep with harmonic reinforcement. Bright, energetic - * upward motion for card ability activation or power gain. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardPowerUpParams } from "./card-power-up-params.js"; - -export { getCardPowerUpParams } from "./card-power-up-params.js"; -export type { CardPowerUpParams } from "./card-power-up-params.js"; - -/** - * Creates a card-power-up Recipe. - * - * Ascending Sine + Harmonic Overtone -> Envelope -> Destination - */ -export function createCardPowerUp(rng: Rng): Recipe { - const params = getCardPowerUpParams(rng); - - // Fundamental: ascending pitch sweep - const osc = new Tone.Oscillator(params.freqStart, "sine"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(gain, env); - - // Harmonic overtone - const harm = new Tone.Oscillator(params.freqStart * params.harmonicRatio, "sine"); - const harmGain = new Tone.Gain(params.harmonicLevel); - const harmEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - harm.chain(harmGain, harmEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - harm.start(time); - env.triggerAttack(time); - harmEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - harm.stop(time); - }, - toDestination(): void { - env.toDestination(); - harmEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-return-to-deck.ts b/src/recipes/card-return-to-deck.ts deleted file mode 100644 index 3b71988..0000000 --- a/src/recipes/card-return-to-deck.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Card Return-to-Deck Recipe - * - * Subtle swoosh with ascending tonal accent — conceptual inverse - * of card-draw. Bandpass noise for the slide and ascending sine - * for the "slot back in" feel. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardReturnToDeckParams } from "./card-return-to-deck-params.js"; - -export { getCardReturnToDeckParams } from "./card-return-to-deck-params.js"; -export type { CardReturnToDeckParams } from "./card-return-to-deck-params.js"; - -/** - * Creates a card-return-to-deck Recipe. - * - * Bandpass Noise (swoosh) + Ascending Sine -> Envelope -> Destination - */ -export function createCardReturnToDeck(rng: Rng): Recipe { - const params = getCardReturnToDeckParams(rng); - - // Swoosh noise layer - const noise = new Tone.Noise("white"); - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(filter, noiseGain, noiseEnv); - - // Ascending tonal accent - const osc = new Tone.Oscillator(params.toneStart, "sine"); - const toneGain = new Tone.Gain(params.toneLevel); - const toneEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(toneGain, toneEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - osc.start(time); - noiseEnv.triggerAttack(time); - toneEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - osc.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - toneEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-round-complete.ts b/src/recipes/card-round-complete.ts deleted file mode 100644 index d50e291..0000000 --- a/src/recipes/card-round-complete.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Card Round Complete Recipe - * - * Tonal synthesis: neutral completion tone signaling round/turn end. - * A single sine oscillator with a clean envelope and lowpass filter - * produces a satisfying, non-directional "done" sound. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardRoundCompleteParams } from "./card-round-complete-params.js"; - -export { getCardRoundCompleteParams } from "./card-round-complete-params.js"; -export type { CardRoundCompleteParams } from "./card-round-complete-params.js"; - -/** - * Creates a card-round-complete Recipe. - * - * Sine Oscillator -> Lowpass Filter -> Gain -> Amplitude Envelope -> Destination - */ -export function createCardRoundComplete(rng: Rng): Recipe { - const params = getCardRoundCompleteParams(rng); - - const osc = new Tone.Oscillator(params.frequency, "sine"); - const filter = new Tone.Filter(params.filterCutoff, "lowpass"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(filter, gain, env); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-shuffle.ts b/src/recipes/card-shuffle.ts deleted file mode 100644 index 9f2b3cf..0000000 --- a/src/recipes/card-shuffle.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Card Shuffle Recipe - * - * Noise-based synthesis: rapid granular noise burst representing a card - * riffle/shuffle. Bandpass-filtered white noise with amplitude modulation - * at a grain rate to create the rapid flutter texture. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardShuffleParams } from "./card-shuffle-params.js"; - -export { getCardShuffleParams } from "./card-shuffle-params.js"; -export type { CardShuffleParams } from "./card-shuffle-params.js"; - -/** - * Creates a card-shuffle Recipe. - * - * Bandpass Noise -> Grain Modulation -> Amplitude Envelope -> Destination - */ -export function createCardShuffle(rng: Rng): Recipe { - const params = getCardShuffleParams(rng); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterFreq, "bandpass"); - noiseFilter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-slide.ts b/src/recipes/card-slide.ts deleted file mode 100644 index d0e9c83..0000000 --- a/src/recipes/card-slide.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Card Slide Recipe - * - * Tonal/oscillator-based: smooth downward pitch sweep with filtered - * noise undertone for surface friction. Uses sine oscillator with - * descending frequency ramp. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardSlideParams } from "./card-slide-params.js"; - -export { getCardSlideParams } from "./card-slide-params.js"; -export type { CardSlideParams } from "./card-slide-params.js"; - -/** - * Creates a card-slide Recipe. - * - * Sine Oscillator (Pitch Sweep) + Lowpass Noise -> Amplitude Envelope -> Destination - */ -export function createCardSlide(rng: Rng): Recipe { - const params = getCardSlideParams(rng); - - const osc = new Tone.Oscillator(params.startFreq, "sine"); - const oscGain = new Tone.Gain(1); - const oscEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(oscGain, oscEnv); - - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterCutoff, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.frequency.setValueAtTime(params.startFreq, time); - osc.frequency.linearRampToValueAtTime( - params.startFreq - params.sweepRange, - time + duration, - ); - osc.start(time); - noise.start(time); - oscEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - oscEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-success.ts b/src/recipes/card-success.ts deleted file mode 100644 index c8067b1..0000000 --- a/src/recipes/card-success.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Success Recipe - * - * Tonal synthesis: bright ascending dual-tone confirmation sound - * for positive game outcomes. Two sine oscillators at a consonant - * interval create a satisfying "ding" effect. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardSuccessParams } from "./card-success-params.js"; - -export { getCardSuccessParams } from "./card-success-params.js"; -export type { CardSuccessParams } from "./card-success-params.js"; - -/** - * Creates a card-success Recipe. - * - * Sine Oscillator (Base) + Sine Oscillator (Interval) -> Amplitude Envelope -> Destination - */ -export function createCardSuccess(rng: Rng): Recipe { - const params = getCardSuccessParams(rng); - - const osc1 = new Tone.Oscillator(params.baseFreq, "sine"); - const gain1 = new Tone.Gain(params.primaryLevel); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - const osc2Freq = params.baseFreq * params.intervalRatio; - const osc2 = new Tone.Oscillator(osc2Freq, "sine"); - const gain2 = new Tone.Gain(params.secondaryLevel); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc1.start(time); - osc2.start(time); - env1.triggerAttack(time); - env2.triggerAttack(time); - }, - stop(time: number): void { - osc1.stop(time); - osc2.stop(time); - }, - toDestination(): void { - env1.toDestination(); - env2.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-table-ambience.ts b/src/recipes/card-table-ambience.ts deleted file mode 100644 index b85a3a7..0000000 --- a/src/recipes/card-table-ambience.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Card Table Ambience Recipe - * - * Warm filtered noise bed with subtle LFO modulation. Evokes the - * atmosphere of a card table — unobtrusive background layer. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTableAmbienceParams } from "./card-table-ambience-params.js"; - -export { getCardTableAmbienceParams } from "./card-table-ambience-params.js"; -export type { CardTableAmbienceParams } from "./card-table-ambience-params.js"; - -/** - * Creates a card-table-ambience Recipe. - * - * Pink Noise -> Bandpass Filter (LFO-modulated) -> Gain -> Envelope -> Destination - */ -export function createCardTableAmbience(rng: Rng): Recipe { - const params = getCardTableAmbienceParams(rng); - - // Pink noise for warm ambient character - const noise = new Tone.Noise("pink"); - - // Bandpass filter with LFO-modulated cutoff - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - - const lfo = new Tone.LFO( - params.lfoRate, - params.filterFreq - params.lfoDepth * 0.5, - params.filterFreq + params.lfoDepth * 0.5, - ); - lfo.connect(filter.frequency); - - const level = new Tone.Gain(params.level); - - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0.01, - sustain: 1.0, - release: params.release, - }); - - noise.chain(filter, level, env); - - const duration = params.attack + params.sustain + params.release; - - return { - start(time: number): void { - lfo.start(time); - noise.start(time); - env.triggerAttack(time); - env.triggerRelease(time + params.attack + params.sustain); - }, - stop(time: number): void { - lfo.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-timer-tick.ts b/src/recipes/card-timer-tick.ts deleted file mode 100644 index b045a41..0000000 --- a/src/recipes/card-timer-tick.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Card Timer Tick Recipe - * - * Sharp, clean click/tick for metronome-like timer beats. - * Very short and repeatable. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTimerTickParams } from "./card-timer-tick-params.js"; - -export { getCardTimerTickParams } from "./card-timer-tick-params.js"; -export type { CardTimerTickParams } from "./card-timer-tick-params.js"; - -/** - * Creates a card-timer-tick Recipe. - * - * Sine Click + Highpass Noise Transient -> Gain -> Envelope -> Destination - */ -export function createCardTimerTick(rng: Rng): Recipe { - const params = getCardTimerTickParams(rng); - - // Tonal click - const osc = new Tone.Oscillator(params.freq, "sine"); - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(gain, env); - - // Highpass noise for click crispness - const noise = new Tone.Noise("white"); - const hpf = new Tone.Filter(params.clickCutoff, "highpass"); - const noiseGain = new Tone.Gain(params.level * 0.3); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.5, - sustain: 0, - release: 0, - }); - - noise.chain(hpf, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - noise.start(time); - env.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-timer-warning.ts b/src/recipes/card-timer-warning.ts deleted file mode 100644 index 7413337..0000000 --- a/src/recipes/card-timer-warning.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Card Timer Warning Recipe - * - * Escalating/urgent tick with higher pitch, vibrato modulation, - * and dual-tone urgency chord. Conveys time pressure. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTimerWarningParams } from "./card-timer-warning-params.js"; - -export { getCardTimerWarningParams } from "./card-timer-warning-params.js"; -export type { CardTimerWarningParams } from "./card-timer-warning-params.js"; - -/** - * Creates a card-timer-warning Recipe. - * - * Sine (Vibrato-modulated) + Urgency Sine (higher) -> Gain -> Envelope -> Destination - */ -export function createCardTimerWarning(rng: Rng): Recipe { - const params = getCardTimerWarningParams(rng); - - // Primary tone with vibrato - const osc = new Tone.Oscillator(params.freq, "sine"); - const vibrato = new Tone.LFO(params.vibratoRate, params.freq - params.vibratoDepth, params.freq + params.vibratoDepth); - vibrato.connect(osc.frequency); - - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(gain, env); - - // Urgency tone (higher) - const urgOsc = new Tone.Oscillator(params.freq * params.urgencyRatio, "sine"); - const urgGain = new Tone.Gain(params.level * 0.6); - const urgEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.7, - sustain: 0, - release: 0, - }); - - urgOsc.chain(urgGain, urgEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - vibrato.start(time); - osc.start(time); - urgOsc.start(time); - env.triggerAttack(time); - urgEnv.triggerAttack(time); - }, - stop(time: number): void { - vibrato.stop(time); - osc.stop(time); - urgOsc.stop(time); - }, - toDestination(): void { - env.toDestination(); - urgEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-token-earn.ts b/src/recipes/card-token-earn.ts deleted file mode 100644 index 1f2534b..0000000 --- a/src/recipes/card-token-earn.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Card Token Earn Recipe - * - * Pure synthesis: bright ascending multi-harmonic chime for earning - * tokens/rewards. Stacked sine oscillators at harmonic intervals - * with a quick attack and medium decay. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTokenEarnParams } from "./card-token-earn-params.js"; - -export { getCardTokenEarnParams } from "./card-token-earn-params.js"; -export type { CardTokenEarnParams } from "./card-token-earn-params.js"; - -/** - * Creates a card-token-earn Recipe. - * - * Sine (fundamental) + Sine (h2) + Sine (h3) -> Envelope -> Destination - */ -export function createCardTokenEarn(rng: Rng): Recipe { - const params = getCardTokenEarnParams(rng); - - // Fundamental - const osc1 = new Tone.Oscillator(params.baseFreq, "sine"); - const gain1 = new Tone.Gain(params.fundamentalLevel); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - // Second harmonic - const osc2 = new Tone.Oscillator(params.baseFreq * params.harmonic2Ratio, "sine"); - const gain2 = new Tone.Gain(params.harmonic2Level); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.8, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - // Third harmonic - const osc3 = new Tone.Oscillator(params.baseFreq * params.harmonic3Ratio, "sine"); - const gain3 = new Tone.Gain(params.harmonic3Level); - const env3 = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.6, - sustain: 0, - release: 0, - }); - - osc3.chain(gain3, env3); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc1.start(time); - osc2.start(time); - osc3.start(time); - env1.triggerAttack(time); - env2.triggerAttack(time); - env3.triggerAttack(time); - }, - stop(time: number): void { - osc1.stop(time); - osc2.stop(time); - osc3.stop(time); - }, - toDestination(): void { - env1.toDestination(); - env2.toDestination(); - env3.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-transform.ts b/src/recipes/card-transform.ts deleted file mode 100644 index c40d652..0000000 --- a/src/recipes/card-transform.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Card Transform Recipe - * - * Morphing FM synthesis with modulation depth crossfade. Suggests a card - * changing form with shifting timbres. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTransformParams } from "./card-transform-params.js"; - -export { getCardTransformParams } from "./card-transform-params.js"; -export type { CardTransformParams } from "./card-transform-params.js"; - -/** - * Creates a card-transform Recipe. - * - * FM Synthesis: Modulator -> Carrier (frequency modulation depth sweep) -> Envelope -> Destination - */ -export function createCardTransform(rng: Rng): Recipe { - const params = getCardTransformParams(rng); - - // Carrier oscillator - const carrier = new Tone.Oscillator(params.carrierFreq, "sine"); - - // Modulator oscillator for FM - const modFreq = params.carrierFreq * params.modRatio; - const modulator = new Tone.Oscillator(modFreq, "sine"); - const modGain = new Tone.Gain(params.modDepthStart); - - modulator.connect(modGain); - modGain.connect(carrier.frequency); - - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0, - sustain: 1, - release: params.release, - }); - - carrier.chain(gain, env); - - const duration = params.attack + params.sustain + params.release; - - return { - start(time: number): void { - modulator.start(time); - carrier.start(time); - env.triggerAttackRelease(params.attack + params.sustain, time); - }, - stop(time: number): void { - carrier.stop(time); - modulator.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-treasure-reveal.ts b/src/recipes/card-treasure-reveal.ts deleted file mode 100644 index 3d0816c..0000000 --- a/src/recipes/card-treasure-reveal.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Card Treasure Reveal Recipe - * - * Pure synthesis: dramatic shimmer-into-tone reveal sound. Starts - * with highpass-filtered noise shimmer that fades as a bright tonal - * chord swells in, creating a "sparkling reveal" effect. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardTreasureRevealParams } from "./card-treasure-reveal-params.js"; - -export { getCardTreasureRevealParams } from "./card-treasure-reveal-params.js"; -export type { CardTreasureRevealParams } from "./card-treasure-reveal-params.js"; - -/** - * Creates a card-treasure-reveal Recipe. - * - * Highpass Noise (shimmer) + Sine Chord (reveal) -> Envelope -> Destination - */ -export function createCardTreasureReveal(rng: Rng): Recipe { - const params = getCardTreasureRevealParams(rng); - - // Shimmer layer: highpass-filtered white noise - const noise = new Tone.Noise("white"); - const shimmerFilter = new Tone.Filter(params.shimmerCutoff, "highpass"); - const shimmerGain = new Tone.Gain(params.shimmerLevel); - const shimmerEnv = new Tone.AmplitudeEnvelope({ - attack: 0.005, - decay: params.shimmerDecay, - sustain: 0, - release: 0, - }); - - noise.chain(shimmerFilter, shimmerGain, shimmerEnv); - - // Reveal tone: base frequency - const osc1 = new Tone.Oscillator(params.toneFreq, "sine"); - const gain1 = new Tone.Gain(params.toneLevel); - const env1 = new Tone.AmplitudeEnvelope({ - attack: params.toneAttack, - decay: params.toneDecay, - sustain: 0, - release: 0, - }); - - osc1.chain(gain1, env1); - - // Reveal tone: interval (major third-ish) - const osc2 = new Tone.Oscillator(params.toneFreq * params.intervalRatio, "sine"); - const gain2 = new Tone.Gain(params.toneLevel * 0.7); - const env2 = new Tone.AmplitudeEnvelope({ - attack: params.toneAttack, - decay: params.toneDecay * 0.85, - sustain: 0, - release: 0, - }); - - osc2.chain(gain2, env2); - - const duration = Math.max( - 0.005 + params.shimmerDecay, - params.toneAttack + params.toneDecay, - ); - - return { - start(time: number): void { - noise.start(time); - osc1.start(time); - osc2.start(time); - shimmerEnv.triggerAttack(time); - env1.triggerAttack(time); - env2.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - osc1.stop(time); - osc2.stop(time); - }, - toDestination(): void { - shimmerEnv.toDestination(); - env1.toDestination(); - env2.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-unlock.ts b/src/recipes/card-unlock.ts deleted file mode 100644 index 9cb381b..0000000 --- a/src/recipes/card-unlock.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Card Unlock Recipe - * - * Click transient + highpass filter sweep upward on noise. Suggests - * a card being unlocked, released, or freed. Paired inverse of card-lock. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardUnlockParams } from "./card-unlock-params.js"; - -export { getCardUnlockParams } from "./card-unlock-params.js"; -export type { CardUnlockParams } from "./card-unlock-params.js"; - -/** - * Creates a card-unlock Recipe. - * - * Click Sine + Highpass-Swept Noise -> Envelopes -> Destination - */ -export function createCardUnlock(rng: Rng): Recipe { - const params = getCardUnlockParams(rng); - - // Click transient - const click = new Tone.Oscillator(params.clickFreq, "square"); - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: 0.01, - sustain: 0, - release: 0, - }); - - click.chain(clickGain, clickEnv); - - // Noise body with ascending highpass sweep - const noise = new Tone.Noise("white"); - const hpf = new Tone.Filter(params.filterStart, "highpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(hpf, noiseGain, noiseEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - click.start(time); - noise.start(time); - clickEnv.triggerAttack(time); - noiseEnv.triggerAttack(time); - }, - stop(time: number): void { - click.stop(time); - noise.stop(time); - }, - toDestination(): void { - clickEnv.toDestination(); - noiseEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/card-victory-fanfare.ts b/src/recipes/card-victory-fanfare.ts deleted file mode 100644 index 10a8d1f..0000000 --- a/src/recipes/card-victory-fanfare.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Card Victory Fanfare Recipe - * - * Tonal synthesis: ascending multi-note arpeggio with harmonic - * reinforcement. Each arpeggio step uses a sine oscillator at - * ascending frequency intervals, with a triangle harmonic layer - * for richness. The final note sustains with a tail decay. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCardVictoryFanfareParams } from "./card-victory-fanfare-params.js"; - -export { getCardVictoryFanfareParams } from "./card-victory-fanfare-params.js"; -export type { CardVictoryFanfareParams } from "./card-victory-fanfare-params.js"; - -/** - * Creates a card-victory-fanfare Recipe. - * - * Sine Oscillator (Arpeggio Steps) + Triangle Harmonic -> Gain -> Destination - */ -export function createCardVictoryFanfare(rng: Rng): Recipe { - const params = getCardVictoryFanfareParams(rng); - - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - const oscGain = new Tone.Gain(params.primaryLevel); - - const harmOsc = new Tone.Oscillator(params.baseFreq * 2, "triangle"); - const harmGain = new Tone.Gain(params.harmonicLevel); - - const masterGain = new Tone.Gain(1); - - osc.chain(oscGain, masterGain); - harmOsc.chain(harmGain, masterGain); - - const totalDuration = - params.noteCount * params.noteDuration + params.tailDecay; - - return { - start(time: number): void { - osc.start(time); - harmOsc.start(time); - - // Schedule ascending arpeggio steps - let freq = params.baseFreq; - for (let i = 0; i < params.noteCount; i++) { - const noteTime = time + i * params.noteDuration; - osc.frequency.setValueAtTime(freq, noteTime); - harmOsc.frequency.setValueAtTime(freq * 2, noteTime); - freq *= params.stepRatio; - } - - // Envelope: attack at start, sustain through notes, decay tail - masterGain.gain.setValueAtTime(0, time); - masterGain.gain.linearRampToValueAtTime(1, time + params.noteAttack); - - const tailStart = time + params.noteCount * params.noteDuration; - masterGain.gain.setValueAtTime(1, tailStart); - masterGain.gain.linearRampToValueAtTime(0, tailStart + params.tailDecay); - }, - stop(time: number): void { - osc.stop(time); - harmOsc.stop(time); - }, - toDestination(): void { - masterGain.toDestination(); - }, - get duration(): number { - return totalDuration; - }, - }; -} diff --git a/src/recipes/character-jump-step1.ts b/src/recipes/character-jump-step1.ts deleted file mode 100644 index 0ae1a07..0000000 --- a/src/recipes/character-jump-step1.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Character Jump Step 1 -- Oscillator Only - * - * The simplest possible sound: a sine oscillator at a seed-derived - * frequency, playing for a fixed 0.2 s at constant volume. No envelope, - * no sweep, no noise. Demonstrates what a raw oscillator sounds like. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCharacterJumpStep1Params } from "./character-jump-step1-params.js"; - -// Re-export params API -export { getCharacterJumpStep1Params } from "./character-jump-step1-params.js"; -export type { CharacterJumpStep1Params } from "./character-jump-step1-params.js"; - -/** Fixed duration for the oscillator-only step. */ -const FIXED_DURATION = 0.2; - -/** - * Creates a character-jump-step1 Recipe (oscillator only). - */ -export function createCharacterJumpStep1(rng: Rng): Recipe { - const params = getCharacterJumpStep1Params(rng); - - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - - return { - start(time: number): void { - osc.start(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - osc.toDestination(); - }, - get duration(): number { - return FIXED_DURATION; - }, - }; -} diff --git a/src/recipes/character-jump-step2.ts b/src/recipes/character-jump-step2.ts deleted file mode 100644 index 78bd9ab..0000000 --- a/src/recipes/character-jump-step2.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Character Jump Step 2 -- Oscillator + Amplitude Envelope - * - * Adds attack/decay envelope shaping to the sine oscillator from step 1. - * The sound now starts quickly and fades out naturally instead of playing - * at constant volume. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCharacterJumpStep2Params } from "./character-jump-step2-params.js"; - -// Re-export params API -export { getCharacterJumpStep2Params } from "./character-jump-step2-params.js"; -export type { CharacterJumpStep2Params } from "./character-jump-step2-params.js"; - -/** - * Creates a character-jump-step2 Recipe (oscillator + envelope). - */ -export function createCharacterJumpStep2(rng: Rng): Recipe { - const params = getCharacterJumpStep2Params(rng); - - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.connect(amp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - amp.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - amp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/character-jump-step3.ts b/src/recipes/character-jump-step3.ts deleted file mode 100644 index 6985df9..0000000 --- a/src/recipes/character-jump-step3.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Character Jump Step 3 -- Oscillator + Envelope + Pitch Sweep - * - * Adds a rising frequency sweep to the enveloped oscillator from step 2. - * The frequency starts at baseFreq and rises to baseFreq + sweepRange - * over sweepDuration seconds, creating the classic "boing" upward motion. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCharacterJumpStep3Params } from "./character-jump-step3-params.js"; - -// Re-export params API -export { getCharacterJumpStep3Params } from "./character-jump-step3-params.js"; -export type { CharacterJumpStep3Params } from "./character-jump-step3-params.js"; - -/** - * Creates a character-jump-step3 Recipe (oscillator + envelope + sweep). - */ -export function createCharacterJumpStep3(rng: Rng): Recipe { - const params = getCharacterJumpStep3Params(rng); - - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.connect(amp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - // Schedule rising pitch sweep - osc.frequency.setValueAtTime(params.baseFreq, time); - osc.frequency.linearRampToValueAtTime( - params.baseFreq + params.sweepRange, - time + params.sweepDuration, - ); - osc.start(time); - amp.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - amp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/character-jump-step4.ts b/src/recipes/character-jump-step4.ts deleted file mode 100644 index df456b4..0000000 --- a/src/recipes/character-jump-step4.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Character Jump Step 4 -- Oscillator + Envelope + Sweep + Noise - * - * Adds an unfiltered white noise burst to the swept, enveloped oscillator - * from step 3. The noise provides impact texture but sounds harsh without - * the lowpass filter added in the final character-jump recipe. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCharacterJumpStep4Params } from "./character-jump-step4-params.js"; - -// Re-export params API -export { getCharacterJumpStep4Params } from "./character-jump-step4-params.js"; -export type { CharacterJumpStep4Params } from "./character-jump-step4-params.js"; - -/** - * Creates a character-jump-step4 Recipe (osc + envelope + sweep + noise). - */ -export function createCharacterJumpStep4(rng: Rng): Recipe { - const params = getCharacterJumpStep4Params(rng); - - // Sine oscillator with rising pitch sweep - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - - // Amplitude envelope for the tonal sweep - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.connect(amp); - - // Noise burst for impact texture (no filter -- raw white noise) - const noise = new Tone.Noise("white"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseAmp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.noiseDecay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseGain, noiseAmp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - // Schedule rising pitch sweep - osc.frequency.setValueAtTime(params.baseFreq, time); - osc.frequency.linearRampToValueAtTime( - params.baseFreq + params.sweepRange, - time + params.sweepDuration, - ); - osc.start(time); - amp.triggerAttack(time); - noise.start(time); - noiseAmp.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - amp.toDestination(); - noiseAmp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/character-jump.ts b/src/recipes/character-jump.ts deleted file mode 100644 index 9bc6d25..0000000 --- a/src/recipes/character-jump.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Character Jump Recipe - * - * A pure-synthesis jump/hop sound effect using a rising pitch sweep, - * noise burst for impact, and filtered amplitude envelope. Produces - * the kind of springy upward sound heard in platformer games when a - * character jumps. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * character-jump-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCharacterJumpParams } from "./character-jump-params.js"; - -// Re-export params API so existing consumers don't break -export { getCharacterJumpParams } from "./character-jump-params.js"; -export type { CharacterJumpParams } from "./character-jump-params.js"; - -/** - * Creates a character jump Recipe. - * - * Pure synthesis: Sine Oscillator (pitch sweep) + White Noise -> - * Lowpass Filter -> Amplitude Envelope - */ -export function createCharacterJump(rng: Rng): Recipe { - const params = getCharacterJumpParams(rng); - - // Sine oscillator with rising pitch sweep - const osc = new Tone.Oscillator(params.baseFreq, "sine"); - - // Amplitude envelope for the tonal sweep - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.connect(amp); - - // Noise burst for impact texture - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.filterCutoff, "lowpass"); - const noiseGain = new Tone.Gain(params.noiseLevel); - const noiseAmp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.noiseDecay, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseAmp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - // Schedule rising pitch sweep - osc.frequency.setValueAtTime(params.baseFreq, time); - osc.frequency.linearRampToValueAtTime( - params.baseFreq + params.sweepRange, - time + params.sweepDuration, - ); - osc.start(time); - amp.triggerAttack(time); - noise.start(time); - noiseAmp.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - noise.stop(time); - }, - toDestination(): void { - amp.toDestination(); - noiseAmp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/creature-vocal.ts b/src/recipes/creature-vocal.ts deleted file mode 100644 index 8a9e80f..0000000 --- a/src/recipes/creature-vocal.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Creature Vocal Recipe - * - * A sample-hybrid creature vocalization that layers a CC0 growl - * sample with FM synthesis and formant-style bandpass filtering. - * - * The sound has two layers: - * 1. Sample: A real growl transient played identically every render - * 2. FM Synthesis: An FM oscillator (carrier + modulator) filtered - * through a bandpass for formant-like resonance - * - * The sample provides organic texture while FM synthesis parameters - * (carrier frequency, modulation index, filter cutoff) vary by seed, - * producing diverse creature variants from a single base sound. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * creature-vocal-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.4 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getCreatureVocalParams } from "./creature-vocal-params.js"; - -export { getCreatureVocalParams } from "./creature-vocal-params.js"; -export type { CreatureVocalParams } from "./creature-vocal-params.js"; - -/** - * Creates a creature vocal Recipe for browser/interactive playback. - * - * Layers a Tone.Player (growl sample) with FM synthesis through - * a formant-style bandpass filter. - */ -export function createCreatureVocal(rng: Rng): Recipe { - const params = getCreatureVocalParams(rng); - - // Sample layer: CC0 growl sample - const player = new Tone.Player("/assets/samples/creature-vocal/growl.wav"); - const sampleGain = new Tone.Gain(params.mixLevel); - - player.chain(sampleGain); - - // FM synthesis layer - const fmOsc = new Tone.FMOscillator({ - frequency: params.carrierFreq, - modulationIndex: params.modIndex, - }); - - const filter = new Tone.Filter(params.filterCutoff, "bandpass"); - filter.Q.value = params.filterQ; - - const synthGain = new Tone.Gain(1 - params.mixLevel); - - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - fmOsc.chain(filter, synthGain, env); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - player.start(time); - fmOsc.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - player.stop(time); - fmOsc.stop(time); - }, - toDestination(): void { - sampleGain.toDestination(); - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/debris-tail.ts b/src/recipes/debris-tail.ts deleted file mode 100644 index eaeac41..0000000 --- a/src/recipes/debris-tail.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Debris Tail Recipe - * - * Scattered debris/crackle sounds with decreasing density for the - * tail phase of an explosion. Uses granular noise bursts that thin - * out over time to simulate falling debris. - * - * The sound consists of filtered noise modulated by a rapid grain - * envelope, with the grain density decreasing exponentially. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * debris-tail-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getDebrisTailParams } from "./debris-tail-params.js"; - -export { getDebrisTailParams } from "./debris-tail-params.js"; -export type { DebrisTailParams } from "./debris-tail-params.js"; - -/** - * Creates a debris tail Recipe. - * - * Filtered noise with granular envelope that thins out over time, - * simulating scattered debris after an explosion. - */ -export function createDebrisTail(rng: Rng): Recipe { - const params = getDebrisTailParams(rng); - - const noise = new Tone.Noise("white"); - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.durationEnvelope, - sustain: 0, - release: 0, - }); - - noise.chain(filter, gain, env); - - const duration = params.durationEnvelope; - - return { - start(time: number): void { - noise.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/footstep-gravel.ts b/src/recipes/footstep-gravel.ts deleted file mode 100644 index b97ef0e..0000000 --- a/src/recipes/footstep-gravel.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Footstep Gravel Recipe - * - * A sample-hybrid footstep on gravel that layers a CC0 impact - * transient sample with procedurally varied noise synthesis. - * - * The sound has three layers: - * 1. Sample: A real impact transient played identically every render - * 2. Body: bandpass-filtered white noise for the gravel crunch - * 3. Tail: lowpass-filtered brown noise for residual scatter - * - * The sample provides realism while procedural parameters (filter - * frequency, decay times, mix level) vary by seed, ensuring each - * footstep sounds unique but authentic. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * footstep-gravel-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.1 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getFootstepGravelParams } from "./footstep-gravel-params.js"; - -export { getFootstepGravelParams } from "./footstep-gravel-params.js"; -export type { FootstepGravelParams } from "./footstep-gravel-params.js"; - -/** - * Creates a footstep gravel Recipe for browser/interactive playback. - * - * Layers a Tone.Player (sample) with procedural noise synthesis. - * The sample URL is resolved by the browser from the Vite dev server. - */ -export function createFootstepGravel(rng: Rng): Recipe { - const params = getFootstepGravelParams(rng); - - // Sample layer: CC0 impact transient - const player = new Tone.Player("/assets/samples/footstep-gravel/impact.wav"); - const sampleGain = new Tone.Gain(params.mixLevel); - - player.chain(sampleGain); - - // Body layer: bandpass-filtered white noise for gravel crunch - const bodyNoise = new Tone.Noise("white"); - const bodyFilter = new Tone.Filter(params.filterFreq, "bandpass"); - const bodyGain = new Tone.Gain(params.bodyLevel); - const bodyEnv = new Tone.AmplitudeEnvelope({ - attack: params.transientAttack, - decay: params.bodyDecay, - sustain: 0, - release: 0, - }); - - bodyNoise.chain(bodyFilter, bodyGain, bodyEnv); - - // Tail layer: lowpass-filtered brown noise for residual scatter - const tailNoise = new Tone.Noise("brown"); - const tailFilter = new Tone.Filter(params.filterFreq * 0.5, "lowpass"); - const tailGain = new Tone.Gain(params.tailLevel); - const tailEnv = new Tone.AmplitudeEnvelope({ - attack: params.transientAttack, - decay: params.tailDecay, - sustain: 0, - release: 0, - }); - - tailNoise.chain(tailFilter, tailGain, tailEnv); - - const duration = params.transientAttack + Math.max(params.bodyDecay, params.tailDecay); - - return { - start(time: number): void { - player.start(time); - bodyNoise.start(time); - bodyEnv.triggerAttack(time); - tailNoise.start(time); - tailEnv.triggerAttack(time); - }, - stop(time: number): void { - player.stop(time); - bodyNoise.stop(time); - tailNoise.stop(time); - }, - toDestination(): void { - sampleGain.toDestination(); - bodyEnv.toDestination(); - tailEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/footstep-stone.ts b/src/recipes/footstep-stone.ts deleted file mode 100644 index 4d771e6..0000000 --- a/src/recipes/footstep-stone.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Footstep Stone Recipe - * - * A short, percussive footstep impact on a hard stone/concrete surface. - * Uses filtered noise with transient shaping to create a realistic - * percussive impact without samples. - * - * The sound has two layers: - * 1. Body: bandpass-filtered noise with a fast attack and medium decay - * 2. Tail: lowpass-filtered noise with a slower decay for surface resonance - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * footstep-stone-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getFootstepStoneParams } from "./footstep-stone-params.js"; - -export { getFootstepStoneParams } from "./footstep-stone-params.js"; -export type { FootstepStoneParams } from "./footstep-stone-params.js"; - -/** - * Creates a footstep stone Recipe. - * - * Two noise layers with different filters and envelopes simulate the - * initial transient impact and the trailing surface resonance. - */ -export function createFootstepStone(rng: Rng): Recipe { - const params = getFootstepStoneParams(rng); - - // Body layer: bandpass-filtered noise for the main impact - const bodyNoise = new Tone.Noise("white"); - const bodyFilter = new Tone.Filter(params.filterFreq, "bandpass"); - bodyFilter.Q.value = params.filterQ; - const bodyGain = new Tone.Gain(params.bodyLevel); - const bodyEnv = new Tone.AmplitudeEnvelope({ - attack: params.transientAttack, - decay: params.bodyDecay, - sustain: 0, - release: 0, - }); - - bodyNoise.chain(bodyFilter, bodyGain, bodyEnv); - - // Tail layer: lowpass-filtered noise for surface resonance - const tailNoise = new Tone.Noise("brown"); - const tailFilter = new Tone.Filter(params.filterFreq * 0.5, "lowpass"); - const tailGain = new Tone.Gain(params.tailLevel); - const tailEnv = new Tone.AmplitudeEnvelope({ - attack: params.transientAttack, - decay: params.tailDecay, - sustain: 0, - release: 0, - }); - - tailNoise.chain(tailFilter, tailGain, tailEnv); - - const duration = params.transientAttack + Math.max(params.bodyDecay, params.tailDecay); - - return { - start(time: number): void { - bodyNoise.start(time); - bodyEnv.triggerAttack(time); - tailNoise.start(time); - tailEnv.triggerAttack(time); - }, - stop(time: number): void { - bodyNoise.stop(time); - tailNoise.stop(time); - }, - toDestination(): void { - bodyEnv.toDestination(); - tailEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/impact-crack.ts b/src/recipes/impact-crack.ts deleted file mode 100644 index 53400b1..0000000 --- a/src/recipes/impact-crack.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Impact Crack Recipe - * - * A short, sharp transient crack for the attack phase of an explosion. - * Uses filtered noise with a fast decay envelope to create a percussive - * crack without samples. - * - * The sound consists of white noise through a highpass filter with - * a very fast attack and short decay, producing a bright snap. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * impact-crack-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getImpactCrackParams } from "./impact-crack-params.js"; - -export { getImpactCrackParams } from "./impact-crack-params.js"; -export type { ImpactCrackParams } from "./impact-crack-params.js"; - -/** - * Creates an impact crack Recipe. - * - * White noise through a highpass filter with a fast attack/decay - * envelope to produce a sharp transient crack. - */ -export function createImpactCrack(rng: Rng): Recipe { - const params = getImpactCrackParams(rng); - - const noise = new Tone.Noise("white"); - const filter = new Tone.Filter(params.filterFreq, "highpass"); - filter.Q.value = params.filterQ; - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - noise.chain(filter, gain, env); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - noise.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/index.ts b/src/recipes/index.ts index e683710..a48cd34 100644 --- a/src/recipes/index.ts +++ b/src/recipes/index.ts @@ -2,26 +2,14 @@ * Recipe Registry Index * * Registers all built-in recipes and exports the shared registry instance. - * Each recipe provides a LazyRecipeRegistration with: - * - factoryLoader: deferred Tone.js factory import (avoids loading - * heavy dependencies at module load time) - * - getDuration: compute natural duration from a seeded RNG - * - buildOfflineGraph: build Web Audio API graph on OfflineAudioContext - * - * Only the lightweight param modules are imported eagerly. Recipe factory - * modules (which import Tone.js) are loaded on demand via dynamic import() - * when the factory is actually needed (browser/interactive contexts). + * Each recipe provides deterministic duration and an offline graph builder. */ import type { OfflineAudioContext } from "node-web-audio-api"; -import { RecipeRegistry } from "../core/recipe.js"; +import { RecipeRegistry, discoverFileBackedRecipes } from "../core/recipe.js"; import type { Rng } from "../core/rng.js"; -import { getUiSciFiConfirmParams } from "./ui-scifi-confirm-params.js"; -import { getWeaponLaserZapParams } from "./weapon-laser-zap-params.js"; import { getFootstepStoneParams } from "./footstep-stone-params.js"; import { getUiNotificationChimeParams } from "./ui-notification-chime-params.js"; -import { getAmbientWindGustParams } from "./ambient-wind-gust-params.js"; -import { getFootstepGravelParams } from "./footstep-gravel-params.js"; import { getCreatureVocalParams } from "./creature-vocal-params.js"; import { getVehicleEngineParams } from "./vehicle-engine-params.js"; import { getCharacterJumpStep1Params } from "./character-jump-step1-params.js"; @@ -61,7 +49,6 @@ import { getCardPowerDownParams } from "./card-power-down-params.js"; import { getCardLockParams } from "./card-lock-params.js"; import { getCardUnlockParams } from "./card-unlock-params.js"; import { getCardGlowParams } from "./card-glow-params.js"; -import { getCardTransformParams } from "./card-transform-params.js"; import { getCardComboHitParams } from "./card-combo-hit-params.js"; import { getCardComboBreakParams } from "./card-combo-break-params.js"; import { getCardMultiplierUpParams } from "./card-multiplier-up-params.js"; @@ -73,179 +60,7 @@ import { getCardTimerWarningParams } from "./card-timer-warning-params.js"; /** The global recipe registry instance with all built-in recipes registered. */ export const registry = new RecipeRegistry(); - -// ── ui-scifi-confirm ────────────────────────────────────────────── - -function uiSciFiConfirmDuration(rng: Rng): number { - const params = getUiSciFiConfirmParams(rng); - return params.attack + params.decay; -} - -function uiSciFiConfirmOfflineGraph( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, -): void { - const params = getUiSciFiConfirmParams(rng); - - // Create oscillator - const osc = ctx.createOscillator(); - osc.type = "sine"; - osc.frequency.value = params.frequency; - - // Create lowpass filter - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = params.filterCutoff; - - // Create gain node for amplitude envelope - const gain = ctx.createGain(); - gain.gain.setValueAtTime(0, 0); - // Attack: ramp up - gain.gain.linearRampToValueAtTime(1, params.attack); - // Decay: ramp down - gain.gain.linearRampToValueAtTime(0, params.attack + params.decay); - - // Connect: osc -> filter -> gain -> destination - osc.connect(filter); - filter.connect(gain); - gain.connect(ctx.destination); - - // Schedule - osc.start(0); - osc.stop(duration); -} - -registry.register("ui-scifi-confirm", { - factoryLoader: async () => (await import("./ui-scifi-confirm.js")).createUiSciFiConfirm, - getDuration: uiSciFiConfirmDuration, - buildOfflineGraph: uiSciFiConfirmOfflineGraph, - description: "Short sci-fi confirmation tone using sine synthesis with a filtered sweep.", - category: "UI", - tags: ["sci-fi", "confirm", "ui"], - signalChain: "Sine Oscillator -> Lowpass Filter -> Amplitude Envelope -> Destination", - params: [ - { name: "frequency", min: 400, max: 1200, unit: "Hz" }, - { name: "attack", min: 0.001, max: 0.01, unit: "s" }, - { name: "decay", min: 0.05, max: 0.3, unit: "s" }, - { name: "filterCutoff", min: 800, max: 4000, unit: "Hz" }, - ], - getParams: (rng) => { - const p = getUiSciFiConfirmParams(rng); - return { frequency: p.frequency, attack: p.attack, decay: p.decay, filterCutoff: p.filterCutoff }; - }, -}); - -// ── weapon-laser-zap ────────────────────────────────────────────── - -function weaponLaserZapDuration(rng: Rng): number { - const params = getWeaponLaserZapParams(rng); - return params.attack + params.decay; -} - -function weaponLaserZapOfflineGraph( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, -): void { - const params = getWeaponLaserZapParams(rng); - - // FM carrier oscillator - const carrier = ctx.createOscillator(); - carrier.type = "sine"; - carrier.frequency.value = params.carrierFreq; - - // FM modulator oscillator - const modulator = ctx.createOscillator(); - modulator.type = "sine"; - modulator.frequency.value = params.modulatorFreq; - - // Modulation depth: modIndex * modulatorFreq - const modGain = ctx.createGain(); - modGain.gain.value = params.modIndex * params.modulatorFreq; - - // Connect modulator -> modGain -> carrier.frequency (FM) - modulator.connect(modGain); - modGain.connect(carrier.frequency); - - // Carrier amplitude envelope - const carrierGain = ctx.createGain(); - carrierGain.gain.setValueAtTime(0, 0); - carrierGain.gain.linearRampToValueAtTime(1, params.attack); - carrierGain.gain.linearRampToValueAtTime(0, params.attack + params.decay); - - carrier.connect(carrierGain); - carrierGain.connect(ctx.destination); - - // Noise burst for texture - const noiseBufferSize = Math.ceil(ctx.sampleRate * duration); - const noiseBuffer = ctx.createBuffer(1, noiseBufferSize, ctx.sampleRate); - const noiseData = noiseBuffer.getChannelData(0); - - // Use a simple deterministic noise fill. The RNG has already been advanced - // by getWeaponLaserZapParams, so subsequent calls produce deterministic - // values unique to this seed. - for (let i = 0; i < noiseData.length; i++) { - noiseData[i] = rng() * 2 - 1; - } - - const noiseSrc = ctx.createBufferSource(); - noiseSrc.buffer = noiseBuffer; - - // Bandpass filter centred at 2x carrier frequency - const noiseFilter = ctx.createBiquadFilter(); - noiseFilter.type = "bandpass"; - noiseFilter.frequency.value = params.carrierFreq * 2; - - // Noise level - const noiseLevel = ctx.createGain(); - noiseLevel.gain.value = params.noiseBurstLevel; - - // Noise amplitude envelope (shorter than carrier) - const noiseAmpGain = ctx.createGain(); - noiseAmpGain.gain.setValueAtTime(0, 0); - noiseAmpGain.gain.linearRampToValueAtTime(1, params.attack); - noiseAmpGain.gain.linearRampToValueAtTime(0, params.attack + params.decay * 0.5); - - noiseSrc.connect(noiseFilter); - noiseFilter.connect(noiseLevel); - noiseLevel.connect(noiseAmpGain); - noiseAmpGain.connect(ctx.destination); - - // Schedule - modulator.start(0); - carrier.start(0); - noiseSrc.start(0); - modulator.stop(duration); - carrier.stop(duration); - noiseSrc.stop(duration); -} - -registry.register("weapon-laser-zap", { - factoryLoader: async () => (await import("./weapon-laser-zap.js")).createWeaponLaserZap, - getDuration: weaponLaserZapDuration, - buildOfflineGraph: weaponLaserZapOfflineGraph, - description: "Punchy laser zap using FM synthesis with a bandpass-filtered noise burst.", - category: "Weapon", - tags: ["laser", "zap", "sci-fi", "weapon"], - signalChain: "FM Oscillator (Carrier + Modulator) + Bandpass Noise Burst -> Amplitude Envelope -> Destination", - params: [ - { name: "carrierFreq", min: 200, max: 2000, unit: "Hz" }, - { name: "modulatorFreq", min: 50, max: 500, unit: "Hz" }, - { name: "modIndex", min: 1, max: 10, unit: "ratio" }, - { name: "noiseBurstLevel", min: 0.1, max: 0.5, unit: "amplitude" }, - { name: "attack", min: 0.001, max: 0.005, unit: "s" }, - { name: "decay", min: 0.03, max: 0.25, unit: "s" }, - ], - getParams: (rng) => { - const p = getWeaponLaserZapParams(rng); - return { - carrierFreq: p.carrierFreq, modulatorFreq: p.modulatorFreq, - modIndex: p.modIndex, noiseBurstLevel: p.noiseBurstLevel, - attack: p.attack, decay: p.decay, - }; - }, -}); +await discoverFileBackedRecipes(registry); // ── footstep-stone ──────────────────────────────────────────────── @@ -332,7 +147,6 @@ function footstepStoneOfflineGraph( } registry.register("footstep-stone", { - factoryLoader: async () => (await import("./footstep-stone.js")).createFootstepStone, getDuration: footstepStoneDuration, buildOfflineGraph: footstepStoneOfflineGraph, description: "Percussive stone footstep impact using bandpass-filtered noise with transient shaping.", @@ -358,127 +172,6 @@ registry.register("footstep-stone", { }, }); -// ── footstep-gravel (sample-hybrid) ─────────────────────────────── - -function footstepGravelDuration(rng: Rng): number { - const params = getFootstepGravelParams(rng); - return params.transientAttack + Math.max(params.bodyDecay, params.tailDecay); -} - -async function footstepGravelOfflineGraph( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, -): Promise { - const params = getFootstepGravelParams(rng); - - // Load the CC0 impact sample - const sampleBuffer = await loadSample("footstep-gravel/impact.wav", ctx); - - // Sample layer: play the impact transient identically every render - const sampleSrc = ctx.createBufferSource(); - sampleSrc.buffer = sampleBuffer; - - const sampleGain = ctx.createGain(); - sampleGain.gain.value = params.mixLevel; - - sampleSrc.connect(sampleGain); - sampleGain.connect(ctx.destination); - - // Procedural body layer: bandpass-filtered white noise for gravel crunch - const bufferSize = Math.ceil(ctx.sampleRate * duration); - - const bodyBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const bodyData = bodyBuffer.getChannelData(0); - for (let i = 0; i < bodyData.length; i++) { - bodyData[i] = rng() * 2 - 1; // white noise - } - - const bodySrc = ctx.createBufferSource(); - bodySrc.buffer = bodyBuffer; - - const bodyFilter = ctx.createBiquadFilter(); - bodyFilter.type = "bandpass"; - bodyFilter.frequency.value = params.filterFreq; - - const bodyGain = ctx.createGain(); - bodyGain.gain.value = params.bodyLevel; - - const bodyEnv = ctx.createGain(); - bodyEnv.gain.setValueAtTime(0, 0); - bodyEnv.gain.linearRampToValueAtTime(1, params.transientAttack); - bodyEnv.gain.linearRampToValueAtTime(0, params.transientAttack + params.bodyDecay); - - bodySrc.connect(bodyFilter); - bodyFilter.connect(bodyGain); - bodyGain.connect(bodyEnv); - bodyEnv.connect(ctx.destination); - - // Procedural tail layer: lowpass-filtered brown noise for scatter - const tailBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const tailData = tailBuffer.getChannelData(0); - let brownState = 0; - for (let i = 0; i < tailData.length; i++) { - brownState += rng() * 2 - 1; - brownState *= 0.998; // leaky integrator to prevent drift - tailData[i] = brownState * 0.1; - } - - const tailSrc = ctx.createBufferSource(); - tailSrc.buffer = tailBuffer; - - const tailFilter = ctx.createBiquadFilter(); - tailFilter.type = "lowpass"; - tailFilter.frequency.value = params.filterFreq * 0.5; - - const tailGain = ctx.createGain(); - tailGain.gain.value = params.tailLevel; - - const tailEnv = ctx.createGain(); - tailEnv.gain.setValueAtTime(0, 0); - tailEnv.gain.linearRampToValueAtTime(1, params.transientAttack); - tailEnv.gain.linearRampToValueAtTime(0, params.transientAttack + params.tailDecay); - - tailSrc.connect(tailFilter); - tailFilter.connect(tailGain); - tailGain.connect(tailEnv); - tailEnv.connect(ctx.destination); - - // Schedule all sources - sampleSrc.start(0); - bodySrc.start(0); - tailSrc.start(0); - sampleSrc.stop(duration); - bodySrc.stop(duration); - tailSrc.stop(duration); -} - -registry.register("footstep-gravel", { - factoryLoader: async () => (await import("./footstep-gravel.js")).createFootstepGravel, - getDuration: footstepGravelDuration, - buildOfflineGraph: footstepGravelOfflineGraph, - description: "Sample-hybrid gravel footstep layering a CC0 impact transient with procedurally varied noise synthesis.", - category: "Footstep", - tags: ["footstep", "gravel", "impact", "foley", "sample-hybrid"], - signalChain: "CC0 Impact Sample + White Noise -> Bandpass Filter (Body) + Brown Noise -> Lowpass Filter (Tail) -> Amplitude Envelope -> Destination", - params: [ - { name: "filterFreq", min: 300, max: 1800, unit: "Hz" }, - { name: "transientAttack", min: 0.001, max: 0.005, unit: "s" }, - { name: "bodyDecay", min: 0.05, max: 0.25, unit: "s" }, - { name: "tailDecay", min: 0.04, max: 0.15, unit: "s" }, - { name: "mixLevel", min: 0.3, max: 0.7, unit: "amplitude" }, - { name: "bodyLevel", min: 0.4, max: 0.9, unit: "amplitude" }, - { name: "tailLevel", min: 0.1, max: 0.4, unit: "amplitude" }, - ], - getParams: (rng) => { - const p = getFootstepGravelParams(rng); - return { - filterFreq: p.filterFreq, transientAttack: p.transientAttack, - bodyDecay: p.bodyDecay, tailDecay: p.tailDecay, - mixLevel: p.mixLevel, bodyLevel: p.bodyLevel, tailLevel: p.tailLevel, - }; - }, -}); // ── creature-vocal (sample-hybrid) ──────────────────────────────── @@ -553,7 +246,6 @@ async function creatureVocalOfflineGraph( } registry.register("creature-vocal", { - factoryLoader: async () => (await import("./creature-vocal.js")).createCreatureVocal, getDuration: creatureVocalDuration, buildOfflineGraph: creatureVocalOfflineGraph, description: "Sample-hybrid creature vocalization layering a CC0 growl sample with FM synthesis and formant filtering.", @@ -657,7 +349,6 @@ async function vehicleEngineOfflineGraph( } registry.register("vehicle-engine", { - factoryLoader: async () => (await import("./vehicle-engine.js")).createVehicleEngine, getDuration: vehicleEngineDuration, buildOfflineGraph: vehicleEngineOfflineGraph, description: "Sample-hybrid vehicle engine layering a CC0 engine loop with sawtooth oscillator and LFO-modulated lowpass filter.", @@ -732,7 +423,6 @@ function uiNotificationChimeOfflineGraph( } registry.register("ui-notification-chime", { - factoryLoader: async () => (await import("./ui-notification-chime.js")).createUiNotificationChime, getDuration: uiNotificationChimeDuration, buildOfflineGraph: uiNotificationChimeOfflineGraph, description: "Pleasant musical chime using harmonic series synthesis with ADSR envelope.", @@ -758,112 +448,6 @@ registry.register("ui-notification-chime", { }, }); -// ── ambient-wind-gust ───────────────────────────────────────────── - -function ambientWindGustDuration(rng: Rng): number { - const params = getAmbientWindGustParams(rng); - return params.attack + params.sustain + params.release; -} - -function ambientWindGustOfflineGraph( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, -): void { - const params = getAmbientWindGustParams(rng); - - const bufferSize = Math.ceil(ctx.sampleRate * duration); - - // Pink noise approximation: filter white noise with -3dB/octave slope - // Use a lowpass at a moderate frequency to approximate pink spectrum - const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const noiseData = noiseBuffer.getChannelData(0); - for (let i = 0; i < noiseData.length; i++) { - noiseData[i] = rng() * 2 - 1; - } - - const noiseSrc = ctx.createBufferSource(); - noiseSrc.buffer = noiseBuffer; - - // Pink noise approximation filter (lowpass to roll off highs) - const pinkFilter = ctx.createBiquadFilter(); - pinkFilter.type = "lowpass"; - pinkFilter.frequency.value = 2000; - - // Main bandpass filter - const filter = ctx.createBiquadFilter(); - filter.type = "bandpass"; - filter.frequency.value = params.filterFreq; - filter.Q.value = params.filterQ; - - // LFO for filter cutoff modulation (manual via automation) - // Approximate sine LFO by scheduling value changes - const lfoMin = params.filterFreq - params.lfoDepth * 0.5; - const lfoMax = params.filterFreq + params.lfoDepth * 0.5; - const lfoSafeMin = Math.max(20, lfoMin); // prevent negative/zero freq - const lfoPeriod = 1 / params.lfoRate; - const lfoSteps = Math.ceil(duration / (lfoPeriod / 16)); // 16 steps per cycle - const lfoStepTime = duration / lfoSteps; - - for (let i = 0; i <= lfoSteps; i++) { - const t = i * lfoStepTime; - const phase = (t * params.lfoRate * 2 * Math.PI); - const value = lfoSafeMin + (lfoMax - lfoSafeMin) * (0.5 + 0.5 * Math.sin(phase)); - filter.frequency.setValueAtTime(value, t); - } - - // Level control - const levelGain = ctx.createGain(); - levelGain.gain.value = params.level; - - // Amplitude envelope: swell, sustain, fade - const envGain = ctx.createGain(); - envGain.gain.setValueAtTime(0, 0); - // Attack: swell up - envGain.gain.linearRampToValueAtTime(1, params.attack); - // Sustain: hold at 1 - envGain.gain.setValueAtTime(1, params.attack + params.sustain); - // Release: fade out - envGain.gain.linearRampToValueAtTime(0, duration); - - noiseSrc.connect(pinkFilter); - pinkFilter.connect(filter); - filter.connect(levelGain); - levelGain.connect(envGain); - envGain.connect(ctx.destination); - - noiseSrc.start(0); - noiseSrc.stop(duration); -} - -registry.register("ambient-wind-gust", { - factoryLoader: async () => (await import("./ambient-wind-gust.js")).createAmbientWindGust, - getDuration: ambientWindGustDuration, - buildOfflineGraph: ambientWindGustOfflineGraph, - description: "Environmental wind burst with filtered noise and LFO-modulated bandpass sweep.", - category: "Ambient", - tags: ["wind", "ambient", "environment", "nature"], - signalChain: "Pink Noise Approximation -> Bandpass Filter (LFO Modulated) -> Level Control -> Swell Envelope -> Destination", - params: [ - { name: "filterFreq", min: 200, max: 1500, unit: "Hz" }, - { name: "filterQ", min: 0.5, max: 3.0, unit: "Q" }, - { name: "lfoRate", min: 0.5, max: 4.0, unit: "Hz" }, - { name: "lfoDepth", min: 100, max: 800, unit: "Hz" }, - { name: "attack", min: 0.1, max: 0.5, unit: "s" }, - { name: "sustain", min: 0.2, max: 1.0, unit: "s" }, - { name: "release", min: 0.2, max: 0.8, unit: "s" }, - { name: "level", min: 0.3, max: 0.8, unit: "amplitude" }, - ], - getParams: (rng) => { - const p = getAmbientWindGustParams(rng); - return { - filterFreq: p.filterFreq, filterQ: p.filterQ, - lfoRate: p.lfoRate, lfoDepth: p.lfoDepth, - attack: p.attack, sustain: p.sustain, - release: p.release, level: p.level, - }; - }, -}); // ── character-jump-step1 (oscillator only) ──────────────────────── @@ -896,7 +480,6 @@ function characterJumpStep1OfflineGraph( } registry.register("character-jump-step1", { - factoryLoader: async () => (await import("./character-jump-step1.js")).createCharacterJumpStep1, getDuration: characterJumpStep1Duration, buildOfflineGraph: characterJumpStep1OfflineGraph, description: "Raw sine oscillator at a seed-derived frequency. No envelope, fixed 0.2 s duration.", @@ -945,7 +528,6 @@ function characterJumpStep2OfflineGraph( } registry.register("character-jump-step2", { - factoryLoader: async () => (await import("./character-jump-step2.js")).createCharacterJumpStep2, getDuration: characterJumpStep2Duration, buildOfflineGraph: characterJumpStep2OfflineGraph, description: "Sine oscillator with attack/decay amplitude envelope. Sound starts quickly and fades naturally.", @@ -1000,7 +582,6 @@ function characterJumpStep3OfflineGraph( } registry.register("character-jump-step3", { - factoryLoader: async () => (await import("./character-jump-step3.js")).createCharacterJumpStep3, getDuration: characterJumpStep3Duration, buildOfflineGraph: characterJumpStep3OfflineGraph, description: "Sine oscillator with amplitude envelope and rising pitch sweep for upward motion.", @@ -1089,7 +670,6 @@ function characterJumpStep4OfflineGraph( } registry.register("character-jump-step4", { - factoryLoader: async () => (await import("./character-jump-step4.js")).createCharacterJumpStep4, getDuration: characterJumpStep4Duration, buildOfflineGraph: characterJumpStep4OfflineGraph, description: "Sine oscillator with envelope, pitch sweep, and unfiltered white noise burst.", @@ -1189,7 +769,6 @@ function characterJumpOfflineGraph( } registry.register("character-jump", { - factoryLoader: async () => (await import("./character-jump.js")).createCharacterJump, getDuration: characterJumpDuration, buildOfflineGraph: characterJumpOfflineGraph, description: "Springy jump sound using a rising pitch sweep with a filtered noise burst for impact.", @@ -1271,7 +850,6 @@ function impactCrackOfflineGraph( } registry.register("impact-crack", { - factoryLoader: async () => (await import("./impact-crack.js")).createImpactCrack, getDuration: impactCrackDuration, buildOfflineGraph: impactCrackOfflineGraph, description: "Short, sharp transient crack for explosion attack layers using highpass-filtered noise with fast decay.", @@ -1372,7 +950,6 @@ function rumbleBodyOfflineGraph( } registry.register("rumble-body", { - factoryLoader: async () => (await import("./rumble-body.js")).createRumbleBody, getDuration: rumbleBodyDuration, buildOfflineGraph: rumbleBodyOfflineGraph, description: "Low-frequency rumble body for explosion layers using filtered brown noise with sub-bass oscillator.", @@ -1466,7 +1043,6 @@ function debrisTailOfflineGraph( } registry.register("debris-tail", { - factoryLoader: async () => (await import("./debris-tail.js")).createDebrisTail, getDuration: debrisTailDuration, buildOfflineGraph: debrisTailOfflineGraph, description: "Scattered debris crackle tail for explosion layers using granular noise bursts with decreasing density.", @@ -1572,7 +1148,6 @@ function slamTransientOfflineGraph( } registry.register("slam-transient", { - factoryLoader: async () => (await import("./slam-transient.js")).createSlamTransient, getDuration: slamTransientDuration, buildOfflineGraph: slamTransientOfflineGraph, description: "Short door impact transient using bandpass-filtered noise thud with highpass click component.", @@ -1655,7 +1230,6 @@ function resonanceBodyOfflineGraph( } registry.register("resonance-body", { - factoryLoader: async () => (await import("./resonance-body.js")).createResonanceBody, getDuration: resonanceBodyDuration, buildOfflineGraph: resonanceBodyOfflineGraph, description: "Woody/metallic resonance body for door slam layers using damped sine oscillators at harmonic frequencies.", @@ -1746,7 +1320,6 @@ function rattleDecayOfflineGraph( } registry.register("rattle-decay", { - factoryLoader: async () => (await import("./rattle-decay.js")).createRattleDecay, getDuration: rattleDecayDuration, buildOfflineGraph: rattleDecayOfflineGraph, description: "Rattling/settling decay for door slam tail layers using granular noise bursts with irregular timing.", @@ -1838,7 +1411,6 @@ function cardFlipOfflineGraph( } registry.register("card-flip", { - factoryLoader: async () => (await import("./card-flip.js")).createCardFlip, getDuration: cardFlipDuration, buildOfflineGraph: cardFlipOfflineGraph, description: "Stylized card flip sound using bandpass-filtered noise burst with a sine click transient.", @@ -1940,7 +1512,6 @@ function cardSlideOfflineGraph( } registry.register("card-slide", { - factoryLoader: async () => (await import("./card-slide.js")).createCardSlide, getDuration: cardSlideDuration, buildOfflineGraph: cardSlideOfflineGraph, description: "Smooth card slide sound using sine sweep with filtered noise undertone for surface friction.", @@ -2030,7 +1601,6 @@ function cardPlaceOfflineGraph( } registry.register("card-place", { - factoryLoader: async () => (await import("./card-place.js")).createCardPlace, getDuration: cardPlaceDuration, buildOfflineGraph: cardPlaceOfflineGraph, description: "Soft card placement thud using lowpass-filtered noise with a subtle sine tap accent.", @@ -2127,7 +1697,6 @@ function cardDrawOfflineGraph( } registry.register("card-draw", { - factoryLoader: async () => (await import("./card-draw.js")).createCardDraw, getDuration: cardDrawDuration, buildOfflineGraph: cardDrawOfflineGraph, description: "Quick card draw sound with highpass-filtered noise swipe and ascending sine sweep accent.", @@ -2229,7 +1798,6 @@ function cardShuffleOfflineGraph( } registry.register("card-shuffle", { - factoryLoader: async () => (await import("./card-shuffle.js")).createCardShuffle, getDuration: cardShuffleDuration, buildOfflineGraph: cardShuffleOfflineGraph, description: "Rapid card shuffle riffle using bandpass-filtered noise with amplitude-modulated grain flutter.", @@ -2331,7 +1899,6 @@ function cardFanOfflineGraph( } registry.register("card-fan", { - factoryLoader: async () => (await import("./card-fan.js")).createCardFan, getDuration: cardFanDuration, buildOfflineGraph: cardFanOfflineGraph, description: "Smooth card fanning sound using ascending sine sweep with gentle filtered noise bed texture.", @@ -2414,7 +1981,6 @@ function cardSuccessOfflineGraph( } registry.register("card-success", { - factoryLoader: async () => (await import("./card-success.js")).createCardSuccess, getDuration: cardSuccessDuration, buildOfflineGraph: cardSuccessOfflineGraph, description: "Bright ascending dual-tone confirmation for positive card game outcomes.", @@ -2503,7 +2069,6 @@ function cardFailureOfflineGraph( } registry.register("card-failure", { - factoryLoader: async () => (await import("./card-failure.js")).createCardFailure, getDuration: cardFailureDuration, buildOfflineGraph: cardFailureOfflineGraph, description: "Descending dissonant tone for negative card game outcomes with detuned beating.", @@ -2592,7 +2157,6 @@ function cardVictoryFanfareOfflineGraph( } registry.register("card-victory-fanfare", { - factoryLoader: async () => (await import("./card-victory-fanfare.js")).createCardVictoryFanfare, getDuration: cardVictoryFanfareDuration, buildOfflineGraph: cardVictoryFanfareOfflineGraph, description: "Ascending multi-note arpeggio fanfare with harmonic reinforcement for card game victories.", @@ -2672,7 +2236,6 @@ function cardDefeatStingOfflineGraph( } registry.register("card-defeat-sting", { - factoryLoader: async () => (await import("./card-defeat-sting.js")).createCardDefeatSting, getDuration: cardDefeatStingDuration, buildOfflineGraph: cardDefeatStingOfflineGraph, description: "Descending minor-interval sting with lowpass filter sweep for card game defeat moments.", @@ -2745,7 +2308,6 @@ function cardRoundCompleteOfflineGraph( } registry.register("card-round-complete", { - factoryLoader: async () => (await import("./card-round-complete.js")).createCardRoundComplete, getDuration: cardRoundCompleteDuration, buildOfflineGraph: cardRoundCompleteOfflineGraph, description: "Neutral completion tone for round/turn end events in card games.", @@ -2857,7 +2419,6 @@ function cardCoinCollectOfflineGraph( } registry.register("card-coin-collect", { - factoryLoader: async () => (await import("./card-coin-collect.js")).createCardCoinCollect, getDuration: cardCoinCollectDuration, buildOfflineGraph: cardCoinCollectOfflineGraph, description: "Bright metallic ascending ping for coin/token collection events in card games.", @@ -2966,7 +2527,6 @@ async function cardCoinCollectHybridOfflineGraph( } registry.register("card-coin-collect-hybrid", { - factoryLoader: async () => (await import("./card-coin-collect-hybrid.js")).createCardCoinCollectHybrid, getDuration: cardCoinCollectHybridDuration, buildOfflineGraph: cardCoinCollectHybridOfflineGraph, description: "Sample-hybrid metallic coin collect layering a CC0 coin clink sample with procedurally varied synthesis.", @@ -3072,7 +2632,6 @@ function cardCoinSpendOfflineGraph( } registry.register("card-coin-spend", { - factoryLoader: async () => (await import("./card-coin-spend.js")).createCardCoinSpend, getDuration: cardCoinSpendDuration, buildOfflineGraph: cardCoinSpendOfflineGraph, description: "Muted descending tone for coin/token spend events in card games.", @@ -3167,7 +2726,6 @@ function cardChipStackOfflineGraph( } registry.register("card-chip-stack", { - factoryLoader: async () => (await import("./card-chip-stack.js")).createCardChipStack, getDuration: cardChipStackDuration, buildOfflineGraph: cardChipStackOfflineGraph, description: "Percussive click with brief tonal ring for stacking chips/tokens in card games.", @@ -3270,7 +2828,6 @@ function cardTokenEarnOfflineGraph( } registry.register("card-token-earn", { - factoryLoader: async () => (await import("./card-token-earn.js")).createCardTokenEarn, getDuration: cardTokenEarnDuration, buildOfflineGraph: cardTokenEarnOfflineGraph, description: "Bright ascending multi-harmonic chime for earning tokens/rewards in card games.", @@ -3387,7 +2944,6 @@ function cardTreasureRevealOfflineGraph( } registry.register("card-treasure-reveal", { - factoryLoader: async () => (await import("./card-treasure-reveal.js")).createCardTreasureReveal, getDuration: cardTreasureRevealDuration, buildOfflineGraph: cardTreasureRevealOfflineGraph, description: "Dramatic shimmer-into-tone reveal sound for treasure/rare card reveals in card games.", @@ -3484,7 +3040,6 @@ function cardDiscardOfflineGraph( } registry.register("card-discard", { - factoryLoader: async () => (await import("./card-discard.js")).createCardDiscard, getDuration: cardDiscardDuration, buildOfflineGraph: cardDiscardOfflineGraph, description: "Short noise burst with tonal thud for discarding a card — subtle, neutral removal action.", @@ -3609,7 +3164,6 @@ function cardBurnOfflineGraph( } registry.register("card-burn", { - factoryLoader: async () => (await import("./card-burn.js")).createCardBurn, getDuration: cardBurnDuration, buildOfflineGraph: cardBurnOfflineGraph, description: "Destructive dissolve/fire effect with descending filter sweep, crackle, and rumble for permanently burning a card.", @@ -3707,7 +3261,6 @@ function cardReturnToDeckOfflineGraph( } registry.register("card-return-to-deck", { - factoryLoader: async () => (await import("./card-return-to-deck.js")).createCardReturnToDeck, getDuration: cardReturnToDeckDuration, buildOfflineGraph: cardReturnToDeckOfflineGraph, description: "Subtle swoosh with ascending tonal accent for returning a card to the deck — conceptual inverse of card-draw.", @@ -3793,7 +3346,6 @@ function cardPowerUpOfflineGraph( } registry.register("card-power-up", { - factoryLoader: async () => (await import("./card-power-up.js")).createCardPowerUp, getDuration: cardPowerUpDuration, buildOfflineGraph: cardPowerUpOfflineGraph, description: "Ascending pitch sweep with harmonic reinforcement for card ability activation or power gain.", @@ -3893,7 +3445,6 @@ function cardPowerDownOfflineGraph( } registry.register("card-power-down", { - factoryLoader: async () => (await import("./card-power-down.js")).createCardPowerDown, getDuration: cardPowerDownDuration, buildOfflineGraph: cardPowerDownOfflineGraph, description: "Descending pitch sweep with lowpass filter decay and noise grit for card ability deactivation or power loss.", @@ -3982,7 +3533,6 @@ function cardLockOfflineGraph( } registry.register("card-lock", { - factoryLoader: async () => (await import("./card-lock.js")).createCardLock, getDuration: cardLockDuration, buildOfflineGraph: cardLockOfflineGraph, description: "Mechanical click with descending lowpass filter sweep for locking or sealing a card.", @@ -4071,7 +3621,6 @@ function cardUnlockOfflineGraph( } registry.register("card-unlock", { - factoryLoader: async () => (await import("./card-unlock.js")).createCardUnlock, getDuration: cardUnlockDuration, buildOfflineGraph: cardUnlockOfflineGraph, description: "Click transient with ascending highpass filter sweep for unlocking or releasing a card.", @@ -4150,7 +3699,6 @@ function cardGlowOfflineGraph( } registry.register("card-glow", { - factoryLoader: async () => (await import("./card-glow.js")).createCardGlow, getDuration: cardGlowDuration, buildOfflineGraph: cardGlowOfflineGraph, description: "Sustained filtered oscillator with LFO vibrato shimmer for a card radiating energy or highlight state.", @@ -4179,89 +3727,6 @@ registry.register("card-glow", { }, }); -// ── card-transform ─────────────────────────────────────────────── - -function cardTransformDuration(rng: Rng): number { - const params = getCardTransformParams(rng); - return params.attack + params.sustain + params.release; -} - -function cardTransformOfflineGraph( - rng: Rng, - ctx: OfflineAudioContext, - duration: number, -): void { - const params = getCardTransformParams(rng); - - // Modulator oscillator for FM synthesis - const modFreq = params.carrierFreq * params.modRatio; - const modulator = ctx.createOscillator(); - modulator.type = "sine"; - modulator.frequency.value = modFreq; - - // FM depth sweep from start to end - const modGain = ctx.createGain(); - modGain.gain.setValueAtTime(params.modDepthStart, 0); - modGain.gain.linearRampToValueAtTime(params.modDepthEnd, duration); - - // Carrier oscillator - const carrier = ctx.createOscillator(); - carrier.type = "sine"; - carrier.frequency.value = params.carrierFreq; - - modulator.connect(modGain); - modGain.connect(carrier.frequency); - - const gain = ctx.createGain(); - gain.gain.value = params.level; - - // Envelope: attack -> sustain -> release - const env = ctx.createGain(); - env.gain.setValueAtTime(0, 0); - env.gain.linearRampToValueAtTime(1, params.attack); - env.gain.setValueAtTime(1, params.attack + params.sustain); - env.gain.linearRampToValueAtTime(0, duration); - - carrier.connect(gain); - gain.connect(env); - env.connect(ctx.destination); - - // Schedule - modulator.start(0); - modulator.stop(duration); - carrier.start(0); - carrier.stop(duration); -} - -registry.register("card-transform", { - factoryLoader: async () => (await import("./card-transform.js")).createCardTransform, - getDuration: cardTransformDuration, - buildOfflineGraph: cardTransformOfflineGraph, - description: "Morphing FM synthesis with modulation depth sweep for card transformation or shape-shifting.", - category: "Card Game", - tags: ["card", "transform", "card-game", "state", "fm", "arcade", "morphing", "dramatic"], - signalChain: "FM Synthesis: Modulator -> Depth Sweep -> Carrier Frequency + Carrier Sine -> Gain -> Envelope -> Destination", - params: [ - { name: "carrierFreq", min: 300, max: 700, unit: "Hz" }, - { name: "modRatio", min: 1, max: 4, unit: "ratio" }, - { name: "modDepthStart", min: 50, max: 200, unit: "Hz" }, - { name: "modDepthEnd", min: 300, max: 800, unit: "Hz" }, - { name: "attack", min: 0.02, max: 0.08, unit: "s" }, - { name: "sustain", min: 0.2, max: 0.5, unit: "s" }, - { name: "release", min: 0.1, max: 0.3, unit: "s" }, - { name: "level", min: 0.5, max: 0.9, unit: "amplitude" }, - ], - getParams: (rng) => { - const p = getCardTransformParams(rng); - return { - carrierFreq: p.carrierFreq, modRatio: p.modRatio, - modDepthStart: p.modDepthStart, modDepthEnd: p.modDepthEnd, - attack: p.attack, sustain: p.sustain, - release: p.release, level: p.level, - }; - }, -}); - // ── card-combo-hit ──────────────────────────────────────────────── function cardComboHitDuration(rng: Rng): number { @@ -4349,7 +3814,6 @@ function cardComboHitOfflineGraph( } registry.register("card-combo-hit", { - factoryLoader: async () => (await import("./card-combo-hit.js")).createCardComboHit, getDuration: cardComboHitDuration, buildOfflineGraph: cardComboHitOfflineGraph, description: "Bright transient with harmonic reinforcement and highpass sparkle for successful combo hits.", @@ -4460,7 +3924,6 @@ function cardComboBreakOfflineGraph( } registry.register("card-combo-break", { - factoryLoader: async () => (await import("./card-combo-break.js")).createCardComboBreak, getDuration: cardComboBreakDuration, buildOfflineGraph: cardComboBreakOfflineGraph, description: "Descending dissonant tone with noise burst for combo chain interruption feedback.", @@ -4535,7 +3998,6 @@ function cardMultiplierUpOfflineGraph( } registry.register("card-multiplier-up", { - factoryLoader: async () => (await import("./card-multiplier-up.js")).createCardMultiplierUp, getDuration: cardMultiplierUpDuration, buildOfflineGraph: cardMultiplierUpOfflineGraph, description: "Rising arpeggio with ascending frequency steps for multiplier increase feedback.", @@ -4617,7 +4079,6 @@ function cardMatchOfflineGraph( } registry.register("card-match", { - factoryLoader: async () => (await import("./card-match.js")).createCardMatch, getDuration: cardMatchDuration, buildOfflineGraph: cardMatchOfflineGraph, description: "Dual-tone confirmation sound with delayed second tone for satisfying card match feedback.", @@ -4715,7 +4176,6 @@ function cardTableAmbienceOfflineGraph( } registry.register("card-table-ambience", { - factoryLoader: async () => (await import("./card-table-ambience.js")).createCardTableAmbience, getDuration: cardTableAmbienceDuration, buildOfflineGraph: cardTableAmbienceOfflineGraph, description: "Warm filtered noise bed with LFO modulation evoking a card table atmosphere.", @@ -4808,7 +4268,6 @@ function cardDeckPresenceOfflineGraph( } registry.register("card-deck-presence", { - factoryLoader: async () => (await import("./card-deck-presence.js")).createCardDeckPresence, getDuration: cardDeckPresenceDuration, buildOfflineGraph: cardDeckPresenceOfflineGraph, description: "Quiet tonal hum with harmonic shimmer giving the card deck a subtle ambient presence.", @@ -4904,7 +4363,6 @@ function cardTimerTickOfflineGraph( } registry.register("card-timer-tick", { - factoryLoader: async () => (await import("./card-timer-tick.js")).createCardTimerTick, getDuration: cardTimerTickDuration, buildOfflineGraph: cardTimerTickOfflineGraph, description: "Sharp, clean click/tick for metronome-like card game timer beats.", @@ -4997,7 +4455,6 @@ function cardTimerWarningOfflineGraph( } registry.register("card-timer-warning", { - factoryLoader: async () => (await import("./card-timer-warning.js")).createCardTimerWarning, getDuration: cardTimerWarningDuration, buildOfflineGraph: cardTimerWarningOfflineGraph, description: "Escalating urgent tick with vibrato and dual-tone chord for timer warning feedback.", diff --git a/src/recipes/rattle-decay.ts b/src/recipes/rattle-decay.ts deleted file mode 100644 index a9889f1..0000000 --- a/src/recipes/rattle-decay.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Rattle Decay Recipe - * - * Rattling/settling decay for the tail phase of a door slam. - * Uses small noise bursts with irregular timing to simulate hardware - * or loose components vibrating after a door impact. - * - * The sound consists of bandpass-filtered noise with a rapid grain - * envelope that decays in both amplitude and density. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * rattle-decay-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getRattleDecayParams } from "./rattle-decay-params.js"; - -export { getRattleDecayParams } from "./rattle-decay-params.js"; -export type { RattleDecayParams } from "./rattle-decay-params.js"; - -/** - * Creates a rattle decay Recipe. - * - * Bandpass-filtered noise with decaying amplitude to simulate - * rattling hardware after a door slam impact. - */ -export function createRattleDecay(rng: Rng): Recipe { - const params = getRattleDecayParams(rng); - - const noise = new Tone.Noise("white"); - const filter = new Tone.Filter(params.filterFreq, "bandpass"); - filter.Q.value = params.filterQ; - const gain = new Tone.Gain(params.level); - const env = new Tone.AmplitudeEnvelope({ - attack: 0.001, - decay: params.duration, - sustain: 0, - release: 0, - }); - - noise.chain(filter, gain, env); - - const duration = params.duration; - - return { - start(time: number): void { - noise.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/resonance-body.ts b/src/recipes/resonance-body.ts deleted file mode 100644 index d3629d5..0000000 --- a/src/recipes/resonance-body.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Resonance Body Recipe - * - * Woody/metallic resonance for the body phase of a door slam. - * Uses damped sine oscillators at a fundamental and overtone frequency - * to produce a resonant, decaying tone similar to a wooden panel - * vibrating after impact. - * - * The sound has two layers: - * 1. Fundamental: sine oscillator at the main resonant frequency - * 2. Overtone: sine oscillator at a harmonic ratio above fundamental - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * resonance-body-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getResonanceBodyParams } from "./resonance-body-params.js"; - -export { getResonanceBodyParams } from "./resonance-body-params.js"; -export type { ResonanceBodyParams } from "./resonance-body-params.js"; - -/** - * Creates a resonance body Recipe. - * - * Two damped sine oscillators at fundamental and overtone frequencies - * to produce a woody/metallic resonance. - */ -export function createResonanceBody(rng: Rng): Recipe { - const params = getResonanceBodyParams(rng); - - // Fundamental layer - const fundOsc = new Tone.Oscillator(params.fundamentalFreq, "sine"); - const fundGain = new Tone.Gain(params.level); - const fundEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.fundamentalDecay, - sustain: 0, - release: 0, - }); - - fundOsc.chain(fundGain, fundEnv); - - // Overtone layer - const overtoneOsc = new Tone.Oscillator( - params.fundamentalFreq * params.overtoneRatio, - "sine", - ); - const overtoneGain = new Tone.Gain(params.overtoneLevel); - const overtoneEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.overtoneDecay, - sustain: 0, - release: 0, - }); - - overtoneOsc.chain(overtoneGain, overtoneEnv); - - const duration = params.attack + Math.max(params.fundamentalDecay, params.overtoneDecay); - - return { - start(time: number): void { - fundOsc.start(time); - fundEnv.triggerAttack(time); - overtoneOsc.start(time); - overtoneEnv.triggerAttack(time); - }, - stop(time: number): void { - fundOsc.stop(time); - overtoneOsc.stop(time); - }, - toDestination(): void { - fundEnv.toDestination(); - overtoneEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/rumble-body.ts b/src/recipes/rumble-body.ts deleted file mode 100644 index 49d40c3..0000000 --- a/src/recipes/rumble-body.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Rumble Body Recipe - * - * A sustained low-frequency rumble for the body phase of an explosion. - * Uses lowpass-filtered noise combined with a sub-bass sine oscillator - * to produce a deep, resonant rumble. - * - * The sound has two layers: - * 1. Noise body: brown noise through a lowpass filter with slow decay - * 2. Sub bass: sine oscillator at very low frequency for weight - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * rumble-body-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getRumbleBodyParams } from "./rumble-body-params.js"; - -export { getRumbleBodyParams } from "./rumble-body-params.js"; -export type { RumbleBodyParams } from "./rumble-body-params.js"; - -/** - * Creates a rumble body Recipe. - * - * Brown noise through lowpass filter plus sub-bass sine oscillator - * for a deep explosion body rumble. - */ -export function createRumbleBody(rng: Rng): Recipe { - const params = getRumbleBodyParams(rng); - - // Noise body layer - const noise = new Tone.Noise("brown"); - const filter = new Tone.Filter(params.filterFreq, "lowpass"); - filter.Q.value = params.filterQ; - const noiseGain = new Tone.Gain(params.level); - const noiseEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.sustainDecay, - sustain: 0, - release: params.tailDecay, - }); - - noise.chain(filter, noiseGain, noiseEnv); - - // Sub bass layer - const subOsc = new Tone.Oscillator(params.subBassFreq, "sine"); - const subGain = new Tone.Gain(params.subBassLevel); - const subEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.sustainDecay + params.tailDecay, - sustain: 0, - release: 0, - }); - - subOsc.chain(subGain, subEnv); - - const duration = params.attack + params.sustainDecay + params.tailDecay; - - return { - start(time: number): void { - noise.start(time); - noiseEnv.triggerAttack(time); - subOsc.start(time); - subEnv.triggerAttack(time); - }, - stop(time: number): void { - noise.stop(time); - subOsc.stop(time); - }, - toDestination(): void { - noiseEnv.toDestination(); - subEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/slam-transient.ts b/src/recipes/slam-transient.ts deleted file mode 100644 index 752ccd4..0000000 --- a/src/recipes/slam-transient.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Slam Transient Recipe - * - * A short door impact transient. Uses bandpass-filtered noise burst - * with a sharp attack for the initial thud, plus a high-frequency - * click component for the latch/hardware impact. - * - * The sound has two layers: - * 1. Thud: bandpass-filtered noise at mid frequencies - * 2. Click: highpass-filtered noise at high frequencies - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * slam-transient-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md (Demo 3: Sound Stacking) - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getSlamTransientParams } from "./slam-transient-params.js"; - -export { getSlamTransientParams } from "./slam-transient-params.js"; -export type { SlamTransientParams } from "./slam-transient-params.js"; - -/** - * Creates a slam transient Recipe. - * - * Bandpass-filtered noise for the thud plus highpass-filtered noise - * for the click, producing a percussive door impact. - */ -export function createSlamTransient(rng: Rng): Recipe { - const params = getSlamTransientParams(rng); - - // Thud layer: bandpass-filtered noise - const thudNoise = new Tone.Noise("white"); - const thudFilter = new Tone.Filter(params.filterFreq, "bandpass"); - thudFilter.Q.value = params.filterQ; - const thudGain = new Tone.Gain(params.level); - const thudEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - thudNoise.chain(thudFilter, thudGain, thudEnv); - - // Click layer: highpass-filtered noise - const clickNoise = new Tone.Noise("white"); - const clickFilter = new Tone.Filter(params.clickFreq, "highpass"); - const clickGain = new Tone.Gain(params.clickLevel); - const clickEnv = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.5, - sustain: 0, - release: 0, - }); - - clickNoise.chain(clickFilter, clickGain, clickEnv); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - thudNoise.start(time); - thudEnv.triggerAttack(time); - clickNoise.start(time); - clickEnv.triggerAttack(time); - }, - stop(time: number): void { - thudNoise.stop(time); - clickNoise.stop(time); - }, - toDestination(): void { - thudEnv.toDestination(); - clickEnv.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/tonegraph-migration.test.ts b/src/recipes/tonegraph-migration.test.ts new file mode 100644 index 0000000..4d87817 --- /dev/null +++ b/src/recipes/tonegraph-migration.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { OfflineAudioContext } from "node-web-audio-api"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { RecipeRegistry, discoverFileBackedRecipes, type RecipeRegistration } from "../core/recipe.js"; +import { createRng } from "../core/rng.js"; + +const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "presets", "recipes"); +const SAMPLE_RATE = 44100; + +const MIGRATED = [ + "ui-scifi-confirm", + "weapon-laser-zap", + "footstep-gravel", + "ambient-wind-gust", + "card-transform", +] as const; + +interface RenderResult { + samples: Float32Array; + duration: number; + peak: number; +} + +async function renderRegistration(registration: RecipeRegistration, seed: number): Promise { + const duration = registration.getDuration(createRng(seed)); + const frameCount = Math.ceil(SAMPLE_RATE * duration); + const ctx = new OfflineAudioContext(1, frameCount, SAMPLE_RATE); + await registration.buildOfflineGraph(createRng(seed), ctx, duration); + const rendered = await ctx.startRendering(); + const samples = new Float32Array(rendered.getChannelData(0)); + + let peak = 0; + for (const sample of samples) { + const abs = Math.abs(sample); + if (abs > peak) { + peak = abs; + } + } + + return { samples, duration, peak }; +} + +describe("ToneGraph recipe migrations", () => { + it("discovers all migrated YAML recipes", async () => { + const fileBackedRegistry = new RecipeRegistry(); + const discovered = await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + expect(discovered.sort()).toEqual([...MIGRATED].sort()); + + for (const recipeName of MIGRATED) { + const fileBacked = fileBackedRegistry.getRegistration(recipeName); + expect(fileBacked, `${recipeName} should be discovered from presets/recipes`).toBeDefined(); + expect(fileBacked!.params.length).toBeGreaterThan(0); + } + }); + + it("renders file-backed recipes with expected structure", async () => { + const fileBackedRegistry = new RecipeRegistry(); + await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + for (const recipeName of MIGRATED) { + const fileBacked = fileBackedRegistry.getRegistration(recipeName)!; + + const fileRender = await renderRegistration(fileBacked, 42); + + expect(fileRender.samples.some((sample) => sample !== 0)).toBe(true); + expect(fileRender.peak).toBeGreaterThan(0.01); + expect(fileRender.duration).toBeGreaterThan(0); + } + }); + + it("keeps ui-scifi-confirm deterministic at seed 42", async () => { + const fileBackedRegistry = new RecipeRegistry(); + await discoverFileBackedRecipes(fileBackedRegistry, { recipeDirectory: PRESETS_DIR }); + + const fileBacked = fileBackedRegistry.getRegistration("ui-scifi-confirm")!; + const runA = await renderRegistration(fileBacked, 42); + const runB = await renderRegistration(fileBacked, 42); + + expect(runA.samples.length).toBe(runB.samples.length); + for (let i = 0; i < runA.samples.length; i += 1) { + expect(runA.samples[i]).toBe(runB.samples[i]); + } + }); +}); diff --git a/src/recipes/ui-notification-chime.ts b/src/recipes/ui-notification-chime.ts deleted file mode 100644 index 52f0d35..0000000 --- a/src/recipes/ui-notification-chime.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * UI Notification Chime Recipe - * - * A pleasant musical chime tone using a harmonic series with gentle - * amplitude envelope. Each harmonic is progressively quieter, creating - * a bright but warm notification sound. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * ui-notification-chime-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getUiNotificationChimeParams } from "./ui-notification-chime-params.js"; - -export { getUiNotificationChimeParams } from "./ui-notification-chime-params.js"; -export type { UiNotificationChimeParams } from "./ui-notification-chime-params.js"; - -/** - * Creates a UI notification chime Recipe. - * - * Builds a harmonic series of sine oscillators, each with progressively - * decreasing amplitude, through a shared gentle amplitude envelope. - */ -export function createUiNotificationChime(rng: Rng): Recipe { - const params = getUiNotificationChimeParams(rng); - - const oscillators: Tone.Oscillator[] = []; - const gains: Tone.Gain[] = []; - - // Create harmonics: fundamental + overtones - for (let h = 0; h < params.harmonicCount; h++) { - const freq = params.fundamentalFreq * (h + 1); - const osc = new Tone.Oscillator(freq, "sine"); - // Each successive harmonic is quieter - const level = Math.pow(params.harmonicDecayFactor, h); - const gain = new Tone.Gain(level); - osc.connect(gain); - oscillators.push(osc); - gains.push(gain); - } - - // Shared amplitude envelope - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: params.sustainLevel, - release: params.release, - }); - - // Mix all harmonics into the envelope - for (const gain of gains) { - gain.connect(env); - } - - const duration = params.attack + params.decay + params.release; - - return { - start(time: number): void { - for (const osc of oscillators) { - osc.start(time); - } - env.triggerAttackRelease(params.attack + params.decay, time); - }, - stop(time: number): void { - for (const osc of oscillators) { - osc.stop(time); - } - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/ui-scifi-confirm.ts b/src/recipes/ui-scifi-confirm.ts deleted file mode 100644 index c18a97e..0000000 --- a/src/recipes/ui-scifi-confirm.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * UI Sci-Fi Confirm Recipe - * - * A pure-synthesis confirmation sound suitable for sci-fi UI interactions. - * Uses a sine oscillator through an amplitude envelope with optional - * filter sweep. All parameters are seed-derived for deterministic variation. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * ui-scifi-confirm-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.3 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getUiSciFiConfirmParams } from "./ui-scifi-confirm-params.js"; - -// Re-export params API so existing consumers don't break -export { getUiSciFiConfirmParams } from "./ui-scifi-confirm-params.js"; -export type { UiSciFiConfirmParams } from "./ui-scifi-confirm-params.js"; - -/** - * Creates a UI sci-fi confirm Recipe. - * - * Pure synthesis: Tone.Oscillator -> Tone.Filter -> Tone.AmplitudeEnvelope - */ -export function createUiSciFiConfirm(rng: Rng): Recipe { - const params = getUiSciFiConfirmParams(rng); - - const osc = new Tone.Oscillator(params.frequency, "sine"); - const filter = new Tone.Filter(params.filterCutoff, "lowpass"); - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - osc.chain(filter, amp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - osc.start(time); - amp.triggerAttack(time); - }, - stop(time: number): void { - osc.stop(time); - }, - toDestination(): void { - amp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/vehicle-engine.ts b/src/recipes/vehicle-engine.ts deleted file mode 100644 index e381f3a..0000000 --- a/src/recipes/vehicle-engine.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Vehicle Engine Recipe - * - * A sample-hybrid engine sound that layers a CC0 looping engine - * sample with a sawtooth oscillator for harmonic reinforcement, - * filtered through a lowpass with LFO modulation. - * - * The sound has two layers: - * 1. Sample: A real engine loop played continuously - * 2. Oscillator: A sawtooth wave at the engine fundamental frequency - * - * Both layers pass through a shared lowpass filter with LFO-modulated - * cutoff, creating RPM-like tonal variation. The sample provides - * mechanical texture while oscillator parameters vary by seed. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * vehicle-engine-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/CORE_PRD.md Section 6.5 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getVehicleEngineParams } from "./vehicle-engine-params.js"; - -export { getVehicleEngineParams } from "./vehicle-engine-params.js"; -export type { VehicleEngineParams } from "./vehicle-engine-params.js"; - -/** - * Creates a vehicle engine Recipe for browser/interactive playback. - * - * Layers a Tone.Player (looping engine sample) with a sawtooth - * oscillator, both through a lowpass filter with LFO modulation. - */ -export function createVehicleEngine(rng: Rng): Recipe { - const params = getVehicleEngineParams(rng); - - // Sample layer: CC0 engine loop - const player = new Tone.Player({ - url: "/assets/samples/vehicle-engine/loop.wav", - loop: true, - }); - const sampleGain = new Tone.Gain(params.mixLevel); - - // Oscillator layer: sawtooth for harmonic reinforcement - const osc = new Tone.Oscillator(params.oscFreq, "sawtooth"); - const synthGain = new Tone.Gain(1 - params.mixLevel); - - // LFO for filter cutoff modulation - const lfo = new Tone.LFO(params.lfoRate, params.filterCutoff - params.lfoDepth * 0.5, params.filterCutoff + params.lfoDepth * 0.5); - - // Shared lowpass filter - const filter = new Tone.Filter(params.filterCutoff, "lowpass"); - lfo.connect(filter.frequency); - - // Amplitude envelope - const env = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: 0, - sustain: 1, - release: params.release, - }); - - player.chain(sampleGain, filter, env); - osc.chain(synthGain, filter); - - const duration = params.attack + 0.4 + params.release; // attack + brief sustain + release - - return { - start(time: number): void { - player.start(time); - osc.start(time); - lfo.start(time); - env.triggerAttack(time); - }, - stop(time: number): void { - player.stop(time); - osc.stop(time); - lfo.stop(time); - }, - toDestination(): void { - env.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/recipes/weapon-laser-zap.ts b/src/recipes/weapon-laser-zap.ts deleted file mode 100644 index fbdb0e4..0000000 --- a/src/recipes/weapon-laser-zap.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Weapon Laser Zap Recipe - * - * A short, punchy laser/blaster sound using FM synthesis + noise burst. - * All parameters are seed-derived for deterministic variation. - * - * NOTE: This file imports Tone.js for the Recipe factory (used in browser/ - * interactive contexts). The offline CLI render path imports only - * weapon-laser-zap-params.ts which has zero heavy dependencies. - * - * Reference: docs/prd/DEMO_ROADMAP.md line 56 - */ - -import * as Tone from "tone"; -import type { Rng } from "../core/rng.js"; -import type { Recipe } from "../core/recipe.js"; -import { getWeaponLaserZapParams } from "./weapon-laser-zap-params.js"; - -export { getWeaponLaserZapParams } from "./weapon-laser-zap-params.js"; -export type { WeaponLaserZapParams } from "./weapon-laser-zap-params.js"; - -/** - * Creates a weapon laser zap Recipe. - * - * FM synthesis: carrier oscillator modulated by a second oscillator, - * plus a noise burst through a bandpass filter for texture. - */ -export function createWeaponLaserZap(rng: Rng): Recipe { - const params = getWeaponLaserZapParams(rng); - - // FM carrier - const carrier = new Tone.Oscillator(params.carrierFreq, "sine"); - - // FM modulator — connected to carrier frequency for FM effect - const modulator = new Tone.Oscillator(params.modulatorFreq, "sine"); - const modGain = new Tone.Gain(params.modIndex * params.modulatorFreq); - modulator.connect(modGain); - modGain.connect(carrier.frequency); - - // Amplitude envelope - const amp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay, - sustain: 0, - release: 0, - }); - - carrier.connect(amp); - - // Noise burst for texture - const noise = new Tone.Noise("white"); - const noiseFilter = new Tone.Filter(params.carrierFreq * 2, "bandpass"); - const noiseGain = new Tone.Gain(params.noiseBurstLevel); - const noiseAmp = new Tone.AmplitudeEnvelope({ - attack: params.attack, - decay: params.decay * 0.5, - sustain: 0, - release: 0, - }); - - noise.chain(noiseFilter, noiseGain, noiseAmp); - - const duration = params.attack + params.decay; - - return { - start(time: number): void { - modulator.start(time); - carrier.start(time); - amp.triggerAttack(time); - noise.start(time); - noiseAmp.triggerAttack(time); - }, - stop(time: number): void { - modulator.stop(time); - carrier.stop(time); - noise.stop(time); - }, - toDestination(): void { - amp.toDestination(); - noiseAmp.toDestination(); - }, - get duration(): number { - return duration; - }, - }; -} diff --git a/src/tui/__tests__/session-persistence.test.ts b/src/tui/__tests__/session-persistence.test.ts index 6b6b9bb..3dd7885 100644 --- a/src/tui/__tests__/session-persistence.test.ts +++ b/src/tui/__tests__/session-persistence.test.ts @@ -1,396 +1,63 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, writeFile, readFile, readdir, rm } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - saveSession, - loadSession, - detectSessionFile, - deleteSessionFile, - listBackups, - SESSION_SCHEMA_VERSION, - SessionVersionMismatchError, - SessionCorruptedError, -} from "../session-persistence.js"; -import type { - WizardSessionData, - CandidateSelection, - ManifestEntry, -} from "../types.js"; -import type { ExploreCandidate } from "../../explore/types.js"; -import type { ClassificationResult } from "../../classify/types.js"; -import type { AnalysisResult } from "../../analyze/types.js"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import { detectSessionFile, DEFAULT_SESSION_FILE } from "../session-persistence.js"; -// --------------------------------------------------------------------------- -// Test helpers -- reused from state.test.ts patterns -// --------------------------------------------------------------------------- +describe("session-persistence: detectSessionFile", () => { + let origVitest: string | undefined; + let origNodeEnv: string | undefined; -function makeAnalysis(): AnalysisResult { - return { - analysisVersion: "1.0", - sampleRate: 44100, - sampleCount: 1000, - metrics: { - time: { duration: 1.0, peak: 0.9, rms: 0.3, crestFactor: 3.0 }, - spectral: { spectralCentroid: 1200 }, - envelope: { attackTime: 0.05 }, - quality: { clipping: false, silence: false }, - }, - }; -} - -function makeCandidate(recipe: string, seed: number): ExploreCandidate { - return { - id: `${recipe}_seed-${String(seed).padStart(5, "0")}`, - recipe, - seed, - duration: 1.0, - sampleRate: 44100, - sampleCount: 44100, - analysis: makeAnalysis(), - score: 0.85, - metricScores: { rms: 0.8, centroid: 0.7 }, - cluster: -1, - promoted: false, - libraryId: null, - params: { freq: 440, decay: 0.5 }, - }; -} - -function makeClassification(recipe: string): ClassificationResult { - return { - source: recipe, - category: "card-game", - intensity: "medium", - texture: ["smooth", "bright"], - material: null, - tags: ["card", "ui"], - embedding: [0.1, 0.2, 0.3], - analysisRef: "analysis-ref-1", - }; -} - -function makeSelection(recipe: string, seed: number): CandidateSelection { - return { - recipe, - candidate: makeCandidate(recipe, seed), - classification: makeClassification(recipe), - }; -} - -function makeManifestEntry(recipe: string): ManifestEntry { - return { - recipe, - description: `Description for ${recipe}`, - category: "card-game", - tags: ["card", "ui"], - }; -} - -function makeSessionData(overrides?: Partial): WizardSessionData { - const selections = new Map(); - selections.set("card-flip", makeSelection("card-flip", 42)); - selections.set("coin-collect", makeSelection("coin-collect", 7)); - - return { - currentStage: "review", - manifest: { - entries: [ - makeManifestEntry("card-flip"), - makeManifestEntry("coin-collect"), - ], - }, - selections, - sweepCache: new Map(), - exportDir: "./output", - exportByCategory: true, - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("session-persistence", () => { - let tmpDir: string; - let sessionPath: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), "toneforge-session-test-")); - sessionPath = join(tmpDir, ".toneforge-session.json"); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - // ------------------------------------------------------------------------- - // Save / Load round-trip - // ------------------------------------------------------------------------- - - describe("save and load round-trip", () => { - it("round-trips session data correctly", async () => { - const data = makeSessionData(); - await saveSession(data, sessionPath); - const loaded = await loadSession(sessionPath); - - expect(loaded.currentStage).toBe(data.currentStage); - expect(loaded.manifest).toEqual(data.manifest); - expect(loaded.exportDir).toBe(data.exportDir); - expect(loaded.exportByCategory).toBe(data.exportByCategory); - - // selections Map round-trips correctly - expect(loaded.selections.size).toBe(2); - expect(loaded.selections.get("card-flip")!.recipe).toBe("card-flip"); - expect(loaded.selections.get("card-flip")!.candidate.seed).toBe(42); - expect(loaded.selections.get("coin-collect")!.recipe).toBe("coin-collect"); - expect(loaded.selections.get("coin-collect")!.candidate.seed).toBe(7); - - // sweepCache is always empty after load (excluded from persistence) - expect(loaded.sweepCache.size).toBe(0); - }); - - it("preserves nested candidate fields through serialization", async () => { - const data = makeSessionData(); - await saveSession(data, sessionPath); - const loaded = await loadSession(sessionPath); - - const sel = loaded.selections.get("card-flip")!; - - // ExploreCandidate fields - expect(sel.candidate.id).toBe("card-flip_seed-00042"); - expect(sel.candidate.sampleRate).toBe(44100); - expect(sel.candidate.metricScores).toEqual({ rms: 0.8, centroid: 0.7 }); - expect(sel.candidate.params).toEqual({ freq: 440, decay: 0.5 }); - - // AnalysisResult nested - expect(sel.candidate.analysis.analysisVersion).toBe("1.0"); - expect(sel.candidate.analysis.metrics.time).toEqual({ - duration: 1.0, peak: 0.9, rms: 0.3, crestFactor: 3.0, - }); - - // ClassificationResult fields - expect(sel.classification.category).toBe("card-game"); - expect(sel.classification.texture).toEqual(["smooth", "bright"]); - expect(sel.classification.embedding).toEqual([0.1, 0.2, 0.3]); - }); - - it("handles empty selections map", async () => { - const data = makeSessionData({ - selections: new Map(), - manifest: { entries: [] }, - currentStage: "define", - }); - await saveSession(data, sessionPath); - const loaded = await loadSession(sessionPath); - - expect(loaded.selections.size).toBe(0); - expect(loaded.currentStage).toBe("define"); - }); - - it("excludes sweepCache from saved file", async () => { - const data = makeSessionData(); - data.sweepCache.set("card-flip", { - recipe: "card-flip", - candidates: [makeCandidate("card-flip", 1)], - }); - - await saveSession(data, sessionPath); - - // Read the raw JSON to verify sweepCache is not present - const raw = JSON.parse(await readFile(sessionPath, "utf-8")); - expect(raw.sweepCache).toBeUndefined(); - expect(raw.schemaVersion).toBe(SESSION_SCHEMA_VERSION); - - // Loaded data has empty sweepCache - const loaded = await loadSession(sessionPath); - expect(loaded.sweepCache.size).toBe(0); - }); + beforeEach(() => { + // Save original env values and avoid mutating global env permanently + origVitest = process.env.VITEST; + origNodeEnv = process.env.NODE_ENV; + vi.restoreAllMocks(); }); - // ------------------------------------------------------------------------- - // Schema version - // ------------------------------------------------------------------------- - - describe("schema version", () => { - it("includes schemaVersion in saved file", async () => { - await saveSession(makeSessionData(), sessionPath); - const raw = JSON.parse(await readFile(sessionPath, "utf-8")); - expect(raw.schemaVersion).toBe(SESSION_SCHEMA_VERSION); - }); + afterEach(() => { + // Restore original environment values + if (origVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = origVitest; - it("throws SessionVersionMismatchError for wrong version", async () => { - const raw = { - schemaVersion: 999, - currentStage: "define", - manifest: { entries: [] }, - selections: {}, - exportDir: null, - exportByCategory: true, - }; - await writeFile(sessionPath, JSON.stringify(raw)); + if (origNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = origNodeEnv; - await expect(loadSession(sessionPath)).rejects.toThrow( - SessionVersionMismatchError, - ); - - try { - await loadSession(sessionPath); - } catch (err) { - expect(err).toBeInstanceOf(SessionVersionMismatchError); - const e = err as SessionVersionMismatchError; - expect(e.fileVersion).toBe(999); - expect(e.expectedVersion).toBe(SESSION_SCHEMA_VERSION); - expect(e.message).toContain("version 999"); - expect(e.message).toContain(`version ${SESSION_SCHEMA_VERSION}`); - } - }); + vi.restoreAllMocks(); }); - // ------------------------------------------------------------------------- - // Corrupted file handling - // ------------------------------------------------------------------------- - - describe("corrupted file handling", () => { - it("throws SessionCorruptedError for invalid JSON", async () => { - await writeFile(sessionPath, "not valid json {{{"); - - await expect(loadSession(sessionPath)).rejects.toThrow( - SessionCorruptedError, - ); - }); - - it("throws SessionCorruptedError for missing required fields", async () => { - await writeFile(sessionPath, JSON.stringify({ - schemaVersion: SESSION_SCHEMA_VERSION, - // missing currentStage, manifest, selections - })); - - await expect(loadSession(sessionPath)).rejects.toThrow( - SessionCorruptedError, - ); - }); + 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 SessionCorruptedError for non-existent file", async () => { - await expect(loadSession(join(tmpDir, "nonexistent.json"))).rejects.toThrow( - SessionCorruptedError, - ); - }); - - it("error message includes file path", async () => { - await writeFile(sessionPath, "broken"); - - try { - await loadSession(sessionPath); - } catch (err) { - expect((err as Error).message).toContain(sessionPath); - } - }); + try { + expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(false); + } finally { + fs.unlinkSync(DEFAULT_SESSION_FILE); + } }); - // ------------------------------------------------------------------------- - // Detect session file - // ------------------------------------------------------------------------- + it("returns false when NODE_ENV is test", () => { + process.env.NODE_ENV = "test"; + fs.writeFileSync(DEFAULT_SESSION_FILE, "{}", "utf-8"); - describe("detectSessionFile", () => { - it("returns false when no file exists", () => { - expect(detectSessionFile(sessionPath)).toBe(false); - }); - - it("returns true when file exists", async () => { - await saveSession(makeSessionData(), sessionPath); - expect(detectSessionFile(sessionPath)).toBe(true); - }); + try { + expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(false); + } finally { + fs.unlinkSync(DEFAULT_SESSION_FILE); + } }); - // ------------------------------------------------------------------------- - // Delete session file - // ------------------------------------------------------------------------- - - describe("deleteSessionFile", () => { - it("deletes the session file and all backups", async () => { - const data = makeSessionData(); - - // Save multiple times to create backups - await saveSession(data, sessionPath); - await sleep(50); // small delay for unique timestamps - await saveSession(data, sessionPath); - await sleep(50); - await saveSession(data, sessionPath); - - // Verify files exist - expect(existsSync(sessionPath)).toBe(true); - const backupsBefore = await listBackups(sessionPath); - expect(backupsBefore.length).toBeGreaterThanOrEqual(1); - - // Delete all - await deleteSessionFile(sessionPath); + 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; - expect(existsSync(sessionPath)).toBe(false); - const backupsAfter = await listBackups(sessionPath); - expect(backupsAfter.length).toBe(0); - }); + fs.writeFileSync(DEFAULT_SESSION_FILE, "{}", "utf-8"); - it("does not throw when file does not exist", async () => { - await expect(deleteSessionFile(sessionPath)).resolves.not.toThrow(); - }); - }); - - // ------------------------------------------------------------------------- - // Backup rotation - // ------------------------------------------------------------------------- - - describe("backup rotation", () => { - it("creates a backup on save when file already exists", async () => { - await saveSession(makeSessionData(), sessionPath); - const backupsBefore = await listBackups(sessionPath); - expect(backupsBefore.length).toBe(0); // First save has no previous file to back up - - await sleep(50); - await saveSession(makeSessionData(), sessionPath); - const backupsAfter = await listBackups(sessionPath); - expect(backupsAfter.length).toBe(1); - }); - - it("retains max 3 backups and prunes older ones", async () => { - const data = makeSessionData(); - - // Create initial save (no backup created) - await saveSession(data, sessionPath); - - // Create 5 more saves, each creating a backup - for (let i = 0; i < 5; i++) { - await sleep(50); // Ensure unique timestamps - await saveSession(data, sessionPath); - } - - const backups = await listBackups(sessionPath); - expect(backups.length).toBeLessThanOrEqual(3); - }); - - it("backup files contain valid session data", async () => { - const data = makeSessionData(); - await saveSession(data, sessionPath); - await sleep(50); - await saveSession(data, sessionPath); - - const backups = await listBackups(sessionPath); - expect(backups.length).toBe(1); - - // The backup should be loadable - const backupContent = JSON.parse(await readFile(backups[0]!, "utf-8")); - expect(backupContent.schemaVersion).toBe(SESSION_SCHEMA_VERSION); - expect(backupContent.currentStage).toBe("review"); - }); + try { + expect(detectSessionFile(DEFAULT_SESSION_FILE)).toBe(true); + } finally { + fs.unlinkSync(DEFAULT_SESSION_FILE); + } }); }); - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/tui/session-persistence.ts b/src/tui/session-persistence.ts index f6e30f1..59e95a9 100644 --- a/src/tui/session-persistence.ts +++ b/src/tui/session-persistence.ts @@ -229,6 +229,14 @@ export async function loadSession( export 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); } diff --git a/web/e2e/tonegraph-smoke.spec.ts b/web/e2e/tonegraph-smoke.spec.ts new file mode 100644 index 0000000..1a3cc63 --- /dev/null +++ b/web/e2e/tonegraph-smoke.spec.ts @@ -0,0 +1,135 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const NODE_ERROR_PATTERNS = [ + /require is not defined/i, + /fs is not defined/i, + /process is not defined/i, +]; + +const thisFileDir = resolve(fileURLToPath(new URL(".", import.meta.url))); + +const CORE_BROWSER_FILES = [ + resolve(thisFileDir, "../../src/core/recipe.ts"), + 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"); + } + + const fallbackRows = document.querySelectorAll(".xterm-rows > div"); + return Array.from(fallbackRows) + .map((row) => row.textContent ?? "") + .join("\n"); + }); +} + +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 page.goto("/"); + await page.waitForSelector(".xterm", { timeout: 10_000 }); + await waitForTerminalText(page, "ToneForge Terminal", 20_000); + + const demoSelect = page.locator("#demo-select"); + if (await demoSelect.count()) { + await demoSelect.selectOption("mvp-1"); + } + + const actOneButton = page.locator(".wizard-nav-btn", { hasText: "1/4" }); + await actOneButton.click(); + + const runButton = page.locator(".wizard-btn-run"); + await expect(runButton).toBeVisible({ timeout: 5_000 }); + + consoleErrors.length = 0; + await runButton.click(); + + const renderedBufferLength = await waitForRenderedBufferLength(page, 45_000); + expect(renderedBufferLength).toBeGreaterThan(0); + + expect(consoleErrors).toHaveLength(0); + + for (const pattern of NODE_ERROR_PATTERNS) { + const found = consoleErrors.some((message) => pattern.test(message)); + expect(found).toBe(false); + } + }); + + test("core modules avoid unconditional Node-only top-level imports", async () => { + const topLevelNodeImport = /^\s*import\s+.+\s+from\s+["']node:[^"']+["'];?/gm; + const topLevelRequire = + /^\s*(const|let|var)\s+.+?=\s*require\(\s*["'](?:node:)?(?:fs|path|url|child_process|os|crypto|http|https|net|tls|dns|worker_threads|zlib|stream|module)[^"']*["']\s*\);?/gm; + + for (const filePath of CORE_BROWSER_FILES) { + const source = await readFile(filePath, "utf-8"); + expect(source.match(topLevelNodeImport)).toBeNull(); + expect(source.match(topLevelRequire)).toBeNull(); + } + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 7cb861a..ddaf10b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,6 @@ "@xterm/xterm": "^5.5.0", "express": "^5.1.0", "node-pty": "^1.0.0", - "tone": "^15.1.22", "ws": "^8.18.2" }, "devDependencies": { @@ -27,15 +26,6 @@ "vitest": "^4.0.18" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1131,8 +1121,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -1157,19 +1146,6 @@ "node": ">=12" } }, - "node_modules/automation-events": { - "version": "7.1.15", - "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.15.tgz", - "integrity": "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=18.2.0" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1643,7 +1619,6 @@ "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -1935,7 +1910,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2285,17 +2259,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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.6", - "automation-events": "^7.0.9", - "tslib": "^2.7.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2365,22 +2328,6 @@ "node": ">=0.6" } }, - "node_modules/tone": { - "version": "15.1.22", - "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", - "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", - "license": "MIT", - "dependencies": { - "standardized-audio-context": "^25.3.70", - "tslib": "^2.3.1" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2440,7 +2387,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/web/package.json b/web/package.json index 3a636bf..2d9905c 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "start": "node dist-server/index.js", "test": "vitest run", "test:e2e": "playwright test", + "test:e2e:ci": "npm run build && playwright test", "typecheck": "tsc -p tsconfig.server.json --noEmit && vite build --mode development 2>&1 | head -1" }, "dependencies": { @@ -18,7 +19,6 @@ "@xterm/xterm": "^5.5.0", "express": "^5.1.0", "node-pty": "^1.0.0", - "tone": "^15.1.22", "ws": "^8.18.2" }, "devDependencies": { diff --git a/web/src/audio.ts b/web/src/audio.ts index 25c8219..a0d8435 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -1,23 +1,7 @@ -// Browser audio playback via Tone.js -// Renders recipes client-side using the same seed, plays through Web Audio API. -// -// IMPORTANT: Tone.js is loaded lazily via dynamic import() to avoid creating -// an AudioContext before a user gesture. Static imports of "tone" would trigger -// AudioContext creation on page load, which Chrome blocks with: -// "The AudioContext was not allowed to start." -// -// Recipe factories are also lazy-loaded alongside Tone.js. All registered -// recipes are supported — the recipe name is extracted from the CLI command -// string and dispatched to the corresponding factory. - -import type { RecipeFactory } from "@toneforge/core/recipe.js"; - -// Lazy-loaded module references (populated on first use inside a click handler) -let Tone: typeof import("tone") | null = null; -let createRng: typeof import("@toneforge/core/rng").createRng | null = null; -let recipeFactories: Record | null = null; - -let audioContextStarted = false; +import { createRng } from "@toneforge/core/rng.js"; +import { registry } from "@toneforge/recipes/index.js"; + +let realtimeCtx: AudioContext | null = null; /** * Extract seed from a CLI command string. @@ -43,9 +27,9 @@ export function extractRecipeName(command: string): string | null { */ export function isGenerateCommand(command: string): boolean { return ( - command.includes("generate") && - extractRecipeName(command) !== null && - extractSeed(command) !== null + command.includes("generate") + && extractRecipeName(command) !== null + && extractSeed(command) !== null ); } @@ -64,135 +48,53 @@ export function extractPresetName(command: string): string | null { */ export function isStackRenderCommand(command: string): boolean { return ( - command.includes("stack") && - command.includes("render") && - extractPresetName(command) !== null && - extractSeed(command) !== null + command.includes("stack") + && command.includes("render") + && extractPresetName(command) !== null + && extractSeed(command) !== null ); } -/** - * Lazily load Tone.js and all recipe dependencies. - * Must be called from within a user gesture handler (click, etc.) - * to satisfy the browser autoplay policy. - */ -async function loadAudioDeps(): Promise { - if (Tone) return; // already loaded - - const [ - toneModule, - rngModule, - uiSciFiConfirmModule, - weaponLaserZapModule, - footstepStoneModule, - uiNotificationChimeModule, - ambientWindGustModule, - footstepGravelModule, - creatureVocalModule, - vehicleEngineModule, - characterJumpStep1Module, - characterJumpStep2Module, - characterJumpStep3Module, - characterJumpStep4Module, - characterJumpModule, - impactCrackModule, - rumbleBodyModule, - debrisTailModule, - slamTransientModule, - resonanceBodyModule, - rattleDecayModule, - ] = await Promise.all([ - import("tone"), - import("@toneforge/core/rng"), - import("@toneforge/recipes/ui-scifi-confirm"), - import("@toneforge/recipes/weapon-laser-zap"), - import("@toneforge/recipes/footstep-stone"), - import("@toneforge/recipes/ui-notification-chime"), - import("@toneforge/recipes/ambient-wind-gust"), - import("@toneforge/recipes/footstep-gravel"), - import("@toneforge/recipes/creature-vocal"), - import("@toneforge/recipes/vehicle-engine"), - import("@toneforge/recipes/character-jump-step1"), - import("@toneforge/recipes/character-jump-step2"), - import("@toneforge/recipes/character-jump-step3"), - import("@toneforge/recipes/character-jump-step4"), - import("@toneforge/recipes/character-jump"), - import("@toneforge/recipes/impact-crack"), - import("@toneforge/recipes/rumble-body"), - import("@toneforge/recipes/debris-tail"), - import("@toneforge/recipes/slam-transient"), - import("@toneforge/recipes/resonance-body"), - import("@toneforge/recipes/rattle-decay"), - ]); - - Tone = toneModule; - createRng = rngModule.createRng; - recipeFactories = { - "ui-scifi-confirm": uiSciFiConfirmModule.createUiSciFiConfirm, - "weapon-laser-zap": weaponLaserZapModule.createWeaponLaserZap, - "footstep-stone": footstepStoneModule.createFootstepStone, - "ui-notification-chime": uiNotificationChimeModule.createUiNotificationChime, - "ambient-wind-gust": ambientWindGustModule.createAmbientWindGust, - "footstep-gravel": footstepGravelModule.createFootstepGravel, - "creature-vocal": creatureVocalModule.createCreatureVocal, - "vehicle-engine": vehicleEngineModule.createVehicleEngine, - "character-jump-step1": characterJumpStep1Module.createCharacterJumpStep1, - "character-jump-step2": characterJumpStep2Module.createCharacterJumpStep2, - "character-jump-step3": characterJumpStep3Module.createCharacterJumpStep3, - "character-jump-step4": characterJumpStep4Module.createCharacterJumpStep4, - "character-jump": characterJumpModule.createCharacterJump, - "impact-crack": impactCrackModule.createImpactCrack, - "rumble-body": rumbleBodyModule.createRumbleBody, - "debris-tail": debrisTailModule.createDebrisTail, - "slam-transient": slamTransientModule.createSlamTransient, - "resonance-body": resonanceBodyModule.createResonanceBody, - "rattle-decay": rattleDecayModule.createRattleDecay, - }; +function getRealtimeContext(): AudioContext { + if (!realtimeCtx) { + realtimeCtx = new AudioContext(); + } + return realtimeCtx; } -/** - * Ensure the audio context is started (satisfies autoplay policy). - * Must be called from a user gesture handler. - */ -async function ensureAudioContext(): Promise { - await loadAudioDeps(); - if (!audioContextStarted) { - await Tone!.start(); - audioContextStarted = true; +async function ensureAudioContext(): Promise { + const ctx = getRealtimeContext(); + if (ctx.state === "suspended") { + await ctx.resume(); } + return ctx; } /** * Render and play a recipe with the given seed in the browser. - * - * Uses Tone.Offline to render the audio graph, then plays it - * through the browser's Web Audio API. */ export async function renderAndPlay(recipeName: string, seed: number): Promise { - await ensureAudioContext(); - - const factory = recipeFactories![recipeName]; - if (!factory) { - console.warn(`Unknown recipe "${recipeName}" — skipping audio playback.`); + const registration = registry.getRegistration(recipeName); + if (!registration) { + console.warn(`Unknown recipe "${recipeName}" - skipping audio playback.`); return; } - const rng = createRng!(seed); - const recipe = factory(rng); - const duration = recipe.duration; - - // Render offline using Tone.Offline. - // Tone.js sets the offline context as the global context during the callback, - // so recipe.toDestination() correctly routes to the offline destination. - const buffer = await Tone!.Offline(() => { - recipe.toDestination(); - recipe.start(0); - recipe.stop(duration); - }, duration); - - // Play the rendered buffer - const player = new Tone!.Player(buffer).toDestination(); - player.start(); + const durationRng = createRng(seed); + const duration = registration.getDuration(durationRng); + const sampleRate = 44100; + const length = Math.ceil(sampleRate * duration); + + const offlineCtx = new OfflineAudioContext(1, length, sampleRate); + const graphRng = createRng(seed); + await registration.buildOfflineGraph(graphRng, offlineCtx as unknown as import("node-web-audio-api").OfflineAudioContext, duration); + const renderedBuffer = await offlineCtx.startRendering(); + + const ctx = await ensureAudioContext(); + const source = ctx.createBufferSource(); + source.buffer = renderedBuffer; + source.connect(ctx.destination); + source.start(0); } /** @@ -202,12 +104,10 @@ export async function renderAndPlay(recipeName: string, seed: number): Promise { - // Stack render commands: detected but not yet supported in the browser. - // Log a visible notice so the user understands why there's no audio. if (isStackRenderCommand(command)) { console.info( - "Stack render detected — browser audio playback for stacked presets is not yet supported. " + - "Audio will play from the CLI output only.", + "Stack render detected - browser audio playback for stacked presets is not yet supported. " + + "Audio will play from the CLI output only.", ); return false; }