@real-music-packages/web-core 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,10 +78,16 @@ npm install @real-music-packages/web-core
78
78
  ## Dev
79
79
  ```
80
80
  npm install
81
- npm test # vitest
82
- npm run build # tsup → dist/ (ESM + .d.ts)
81
+ npm test # vitest
82
+ npm run build # tsup → dist/ (ESM + .d.ts)
83
+ npm run build:watch # tsup --watch → rebuild dist/ on every edit (fast dev loop)
83
84
  ```
84
85
 
86
+ **Iterating against a live app without a publish/re-pin?** See
87
+ [DEVELOPMENT.md](./DEVELOPMENT.md) — `build:watch` + `tools/dev-link.sh <app>`
88
+ links the local web-core into an app so its vite dev server reflects your edits
89
+ directly; `tools/dev-unlink.sh <app>` restores the published pin.
90
+
85
91
  ## Publishing — auto-publishes from GitHub (no local `npm publish`)
86
92
 
87
93
  **To release: bump `version` in `package.json` and push to `main`.** That's it.
package/dist/audio.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  KEYS_PREFER_FLATS,
3
- getMidiNote,
4
- midiToNoteName
5
- } from "./chunk-LLMDQM4C.js";
3
+ getMidiNote
4
+ } from "./chunk-5ZK4HVY4.js";
6
5
  import {
7
6
  SALAMANDER_CDN_BASE,
8
7
  SALAMANDER_URLS_8,
@@ -10,6 +9,9 @@ import {
10
9
  createSalamanderSampler,
11
10
  generateReverb
12
11
  } from "./chunk-JVGAABTK.js";
12
+ import {
13
+ midiToNoteName
14
+ } from "./chunk-25RUSM2X.js";
13
15
 
14
16
  // src/engine.ts
15
17
  var browser = typeof window !== "undefined";
package/dist/audio.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/engine.ts"],"sourcesContent":["import { getMidiNote } from './scales';\nimport { midiToNoteName } from './notes';\nimport { KEYS_PREFER_FLATS } from './enharmonic';\nimport { SALAMANDER_URLS_FULL } from './salamander';\nimport { createSalamanderSampler, generateReverb } from './audioHelpers';\n\n// SSR-safe browser guard (equivalent to SvelteKit's `browser` at runtime).\nconst browser = typeof window !== 'undefined';\n\n// Tone.js types (loaded dynamically)\ntype ToneSampler = {\n\ttriggerAttackRelease: (note: string, duration: number | string, time?: number, velocity?: number) => void;\n\ttoDestination: () => ToneSampler;\n\tvolume?: { value: number };\n};\ntype TonePolySynth = {\n\ttriggerAttackRelease: (notes: string[], duration: number | string, time?: number, velocity?: number) => void;\n\ttriggerAttack: (notes: string[], time?: number, velocity?: number) => void;\n\ttriggerRelease: (notes: string[], time?: number) => void;\n\treleaseAll?: (time?: number) => void;\n\ttoDestination: () => TonePolySynth;\n\tconnect: (node: unknown) => TonePolySynth;\n\tvolume: { value: number };\n};\n// Minimal audio-node shape we need to reroute the pad through reverb later.\ntype ToneNode = {\n\tconnect: (node: unknown) => unknown;\n\tdisconnect: () => void;\n\ttoDestination: () => unknown;\n};\n\n// Pre-warm the Tone import so that init() can call Tone.start()\n// synchronously inside a user-gesture handler (required by iOS Safari).\nconst tonePreload: Promise<typeof import('tone')> | null = browser\n\t? import('tone')\n\t: null;\n\nexport class AudioEngine {\n\tprivate piano: ToneSampler | null = null;\n\tprivate synthPiano: ToneSampler | null = null;\n\tprivate strings: TonePolySynth | null = null;\n\tprivate dronePad: TonePolySynth | null = null;\n\tprivate droneNotes: string[] = [];\n\t/** Piano voice level in dB; applied to whichever voice is active. */\n\tprivate pianoVolumeDb = 0;\n\tprivate isInitialized = false;\n\tprivate isLoading = false;\n\tprivate samplerUpgradeStarted = false;\n\tprivate reverbStarted = false;\n\tprivate padFilter: ToneNode | null = null;\n\tprivate Tone: typeof import('tone') | null = null;\n\t/** Which piano voice is currently sounding — for diagnostics. */\n\tvoice: 'none' | 'synth' | 'sampler' = 'none';\n\t/** Last init milestone reached — surfaced on-device to pinpoint stalls. */\n\tlastStage = 'idle';\n\t/**\n\t * Raw AudioContext we create + resume SYNCHRONOUSLY inside the first user\n\t * gesture. iOS Safari only unlocks audio when resume() is called directly\n\t * in the gesture task; awaiting the Tone import first (as init() must) loses\n\t * that activation, so Tone.start()'s resume() hangs with the context stuck\n\t * 'suspended'. We resume this context in the gesture, then hand it to Tone.\n\t */\n\tprivate rawCtx: AudioContext | null = null;\n\tprivate captureDest: MediaStreamAudioDestinationNode | null = null;\n\n\t/**\n\t * Initialize the audio engine (must be called from a user gesture handler).\n\t *\n\t * Strategy: graceful degradation. We bring up a synthesized piano voice\n\t * FIRST — it needs no downloads and decodes nothing, so it is ready\n\t * instantly and works on every browser including iOS Safari. Audio is\n\t * considered ready at that point. We THEN try to load the richer\n\t * Salamander samples in the background and swap them in if they finish.\n\t *\n\t * Why: Tone.Sampler's MP3 decode path hangs on iOS Safari (await\n\t * Tone.loaded() never resolves), which previously stalled init forever.\n\t * Loading samples off the critical path means iOS always gets working\n\t * sound (synth) and merely misses the upgrade, instead of getting silence.\n\t *\n\t * On iOS Safari, Tone.start() must run during the synchronous portion of\n\t * the gesture handler, which is why we pre-import the Tone module above.\n\t */\n\tasync init(): Promise<void> {\n\t\tif (!browser) return;\n\t\tif (this.isInitialized || this.isLoading) return;\n\t\tthis.isLoading = true;\n\t\tthis.lastStage = 'init-start';\n\n\t\ttry {\n\t\t\t// Resolve the pre-warmed Tone import (typically already done).\n\t\t\tthis.Tone = await tonePreload!;\n\t\t\tthis.lastStage = 'tone-imported';\n\n\t\t\t// Use the context we already resumed synchronously in the gesture\n\t\t\t// (see unlock()). Handing it to Tone before any node is created means\n\t\t\t// Tone runs on an already-'running' context, so start() can't hang.\n\t\t\tif (this.rawCtx) {\n\t\t\t\ttry {\n\t\t\t\t\tthis.Tone.setContext(this.rawCtx);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.warn('[audio] setContext failed', e);\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.lastStage = 'context-set:' + this.contextState;\n\n\t\t\t// start() resumes the context; on an already-running context it\n\t\t\t// resolves immediately. Race a short timeout as a backstop so a\n\t\t\t// hung resume() can never stall init — the context is running anyway.\n\t\t\tawait Promise.race([\n\t\t\t\tthis.Tone.start(),\n\t\t\t\tnew Promise<void>((r) => setTimeout(r, 1500)),\n\t\t\t]);\n\t\t\tthis.lastStage = 'context-started:' + this.contextState;\n\n\t\t\t// ── Reliable synth piano: instant, zero downloads, universal ──\n\t\t\tthis.synthPiano = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'triangle' },\n\t\t\t\tenvelope: { attack: 0.005, decay: 0.5, sustain: 0.3, release: 1.2 },\n\t\t\t}).toDestination() as unknown as ToneSampler;\n\t\t\tthis.piano = this.synthPiano;\n\t\t\tthis.voice = 'synth';\n\t\t\tthis.applyPianoVolume();\n\t\t\tthis.lastStage = 'piano-synth-ready';\n\n\t\t\t// ── Pad synth for background chords ──\n\t\t\t// Pad → lowpass → destination, immediately and reliably (dry). Reverb\n\t\t\t// is added later in the background (see addReverbInBackground): its\n\t\t\t// generate() runs an OfflineAudioContext render that can hang on iOS,\n\t\t\t// so it must never sit on the init critical path.\n\t\t\tthis.padFilter = new this.Tone.Filter({\n\t\t\t\ttype: 'lowpass',\n\t\t\t\tfrequency: 2000,\n\t\t\t\trolloff: -12,\n\t\t\t}).toDestination() as unknown as ToneNode;\n\n\t\t\tthis.strings = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'fatsawtooth', count: 3, spread: 30 },\n\t\t\t\tenvelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 2 },\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t\t}).connect(this.padFilter as any) as TonePolySynth;\n\t\t\tthis.strings.volume.value = -10;\n\n\t\t\t// ── Drone pad: a sustained, NON-resolving key anchor ──\n\t\t\t// Open-fifth tonic drone (root + 5th, no third). Exploratory views use\n\t\t\t// it instead of a tonic triad so a held note keeps its real tension\n\t\t\t// (e.g. the leading tone 7 still strains toward 1) rather than being\n\t\t\t// harmonised into a consonant Imaj/Imaj7. Soft, low, routed through the\n\t\t\t// same filter/reverb as the pad. Sustains until stopDrone().\n\t\t\tthis.dronePad = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'triangle' },\n\t\t\t\tenvelope: { attack: 0.6, decay: 0.2, sustain: 0.9, release: 1.5 },\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t\t}).connect(this.padFilter as any) as TonePolySynth;\n\t\t\tthis.dronePad.volume.value = -16;\n\n\t\t\t// Audio is usable now — do NOT block on sample loading or reverb.\n\t\t\tthis.isInitialized = true;\n\t\t\tthis.lastStage = 'ready';\n\n\t\t\t// Background upgrades — neither blocks readiness.\n\t\t\tthis.upgradeToSamplerInBackground();\n\t\t\tthis.addReverbInBackground();\n\t\t} catch (error) {\n\t\t\tthis.lastStage = 'error:' + (error instanceof Error ? error.message : String(error));\n\t\t\tconsole.error('Failed to initialize audio:', error);\n\t\t} finally {\n\t\t\tthis.isLoading = false;\n\t\t}\n\t}\n\n\t/**\n\t * Attempt to load the Salamander sampler in the background and swap it in\n\t * for the synth piano once (and only if) every sample has decoded. On iOS\n\t * Safari this typically never completes, so we simply stay on the synth —\n\t * no stall is ever surfaced because init already resolved.\n\t */\n\tprivate upgradeToSamplerInBackground(): void {\n\t\tif (!this.Tone || this.samplerUpgradeStarted) return;\n\t\tthis.samplerUpgradeStarted = true;\n\n\t\ttry {\n\t\t\tconst sampler = createSalamanderSampler(this.Tone, {\n\t\t\t\turls: SALAMANDER_URLS_FULL,\n\t\t\t\tbaseUrl: '/audio/salamander/',\n\t\t\t\trelease: 1,\n\t\t\t\tonload: () => {\n\t\t\t\t\t// Swap only after every sample decoded successfully.\n\t\t\t\t\tthis.piano = sampler as ToneSampler;\n\t\t\t\t\tthis.voice = 'sampler';\n\t\t\t\t\tthis.applyPianoVolume();\n\t\t\t\t\tconsole.info('[audio] upgraded piano voice to Salamander samples');\n\t\t\t\t},\n\t\t\t\tonerror: (e: unknown) => {\n\t\t\t\t\tconsole.warn('[audio] sample load failed, staying on synth voice', e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tvoid sampler;\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] sampler init threw, staying on synth voice', e);\n\t\t}\n\t}\n\n\t/**\n\t * Generate a reverb in the background and reroute the background pad through\n\t * it once ready: pad → filter → reverb → destination. Reverb.generate()\n\t * runs an OfflineAudioContext render that can hang on iOS Safari, so this is\n\t * deliberately off the init critical path — if it never resolves, the pad\n\t * simply stays dry. Raced against a timeout so we log and move on cleanly.\n\t */\n\tprivate async addReverbInBackground(): Promise<void> {\n\t\tif (!this.Tone || !this.padFilter || this.reverbStarted) return;\n\t\tthis.reverbStarted = true;\n\n\t\ttry {\n\t\t\tconst reverb = await generateReverb(this.Tone, { decay: 3, wet: 0.5 }, 4000);\n\t\t\tif (!reverb) {\n\t\t\t\tconsole.warn('[audio] reverb generate timed out, pad stays dry');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t(reverb as unknown as ToneNode).toDestination();\n\t\t\t// Reroute the pad: detach the filter from the raw destination and\n\t\t\t// send it through the reverb instead.\n\t\t\tthis.padFilter.disconnect();\n\t\t\tthis.padFilter.connect(reverb);\n\t\t\tconsole.info('[audio] reverb enabled');\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] reverb generate failed, pad stays dry', e);\n\t\t}\n\t}\n\n\t/**\n\t * MUST be called SYNCHRONOUSLY from a user-gesture handler (touchstart /\n\t * click / keydown), before any await. Creates and resumes a raw\n\t * AudioContext in the gesture task so iOS Safari actually unlocks audio.\n\t * init() then adopts this context via Tone.setContext(). Idempotent.\n\t */\n\tunlock(): void {\n\t\tif (!browser) return;\n\t\ttry {\n\t\t\tif (!this.rawCtx) {\n\t\t\t\tconst AC = (window.AudioContext ||\n\t\t\t\t\t(window as unknown as { webkitAudioContext?: typeof AudioContext })\n\t\t\t\t\t\t.webkitAudioContext) as typeof AudioContext | undefined;\n\t\t\t\tif (!AC) return;\n\t\t\t\tthis.rawCtx = new AC();\n\t\t\t}\n\t\t\tif (this.rawCtx.state !== 'running') {\n\t\t\t\tthis.rawCtx.resume().catch(() => {});\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] unlock failed', e);\n\t\t}\n\t}\n\n\t/**\n\t * iOS Safari can silently leave the AudioContext in 'suspended' or\n\t * 'interrupted' state even after Tone.start() resolves, especially after\n\t * tab backgrounding or initial activation. Call this synchronously from\n\t * a user-gesture handler (or before playback) to wake it up. No-op when\n\t * the context is already running.\n\t */\n\tensureRunning(): void {\n\t\t// Resume the raw context we own (covers the pre-Tone window too).\n\t\tif (this.rawCtx && this.rawCtx.state !== 'running') {\n\t\t\tthis.rawCtx.resume().catch(() => {});\n\t\t}\n\t\tif (!this.Tone) return;\n\t\tconst ctx = this.Tone.getContext().rawContext as AudioContext | undefined;\n\t\tif (ctx && ctx.state !== 'running') {\n\t\t\t// Fire-and-forget resume — keeps the call synchronous so it stays\n\t\t\t// inside the current user-gesture stack.\n\t\t\tctx.resume().catch(() => {});\n\t\t}\n\t}\n\n\t/** Current AudioContext state, for diagnostics. */\n\tget contextState(): string {\n\t\tif (!this.Tone) return 'no-tone';\n\t\tconst ctx = this.Tone.getContext().rawContext as AudioContext | undefined;\n\t\treturn ctx?.state ?? 'no-context';\n\t}\n\n\t/**\n\t * Play a MIDI note\n\t */\n\tplayNote(midiNote: number, duration: number = 0.5, velocity: number = 0.8): void {\n\t\tif (!this.piano || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tconst noteName = midiToNoteName(midiNote);\n\t\tthis.piano.triggerAttackRelease(noteName, duration, this.Tone!.now(), velocity);\n\t}\n\n\t/**\n\t * Play a scale degree in a given key\n\t */\n\tplayScaleDegree(degree: number, key: string, octave: number = 4, duration: number = 0.5): void {\n\t\tconst midiNote = getMidiNote(degree, key, octave);\n\t\tthis.playNote(midiNote, duration);\n\t}\n\n\t/**\n\t * Start a sustained background chord (tonic chord)\n\t */\n\tstartBackgroundChord(key: string, duration: number = 4): void {\n\t\tif (!this.strings || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\n\t\tconst useFlats = (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n\n\t\t// Build tonic chord voicing\n\t\tconst root4 = getMidiNote(1, key, 4);\n\t\tconst fifth4 = getMidiNote(5, key, 4);\n\t\tconst root5 = getMidiNote(1, key, 5);\n\t\tconst third5 = getMidiNote(3, key, 5);\n\n\t\tconst notes = [root4, fifth4, root5, third5].map((m) => midiToNoteName(m, useFlats));\n\n\t\tthis.strings.triggerAttackRelease(notes, duration, this.Tone!.now(), 0.3);\n\t}\n\n\t/**\n\t * Start a sustained tonic DRONE as a key anchor — an open fifth (root + 5th,\n\t * no third) in a low octave. Unlike startBackgroundChord (a full tonic\n\t * triad), the missing third means individual scale degrees keep their\n\t * tension instead of being resolved into the tonic chord: a note held over\n\t * this drone sounds as tense/stable as it truly is. Intended for exploratory\n\t * note-by-note views. Sustains until stopDrone() is called; calling again\n\t * restarts cleanly in the new key. Idempotent-safe.\n\t */\n\tstartDrone(key: string): void {\n\t\tif (!this.dronePad || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tthis.stopDrone();\n\n\t\tconst useFlats = (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n\t\tconst root3 = getMidiNote(1, key, 3);\n\t\tconst fifth3 = getMidiNote(5, key, 3);\n\t\tconst root4 = getMidiNote(1, key, 4);\n\t\tthis.droneNotes = [root3, fifth3, root4].map((m) => midiToNoteName(m, useFlats));\n\n\t\tthis.dronePad.triggerAttack(this.droneNotes, this.Tone!.now());\n\t}\n\n\t/** Stop the sustained drone started by startDrone(). No-op if none playing. */\n\tstopDrone(): void {\n\t\tif (!this.dronePad) return;\n\t\ttry {\n\t\t\tif (this.droneNotes.length > 0) {\n\t\t\t\tthis.dronePad.triggerRelease(this.droneNotes, this.Tone!.now());\n\t\t\t} else {\n\t\t\t\tthis.dronePad.releaseAll?.(this.Tone!.now());\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] stopDrone failed', e);\n\t\t}\n\t\tthis.droneNotes = [];\n\t}\n\n\t/**\n\t * Set the piano voice level in dB (0 = unchanged default, negative = quieter).\n\t * Applies to the current voice and is re-applied when the sampler swaps in.\n\t */\n\tsetPianoVolume(db: number): void {\n\t\tthis.pianoVolumeDb = db;\n\t\tthis.applyPianoVolume();\n\t}\n\n\tprivate applyPianoVolume(): void {\n\t\tfor (const voice of [this.synthPiano, this.piano]) {\n\t\t\tconst vol = (voice as { volume?: { value: number } } | null)?.volume;\n\t\t\tif (vol) vol.value = this.pianoVolumeDb;\n\t\t}\n\t}\n\n\t/**\n\t * Play a chord (list of MIDI notes) on the piano sampler.\n\t */\n\tplayChord(midiNotes: number[], duration: number = 1.5, velocity: number = 0.7): void {\n\t\tif (!this.piano || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tconst names = midiNotes.map((m) => midiToNoteName(m));\n\t\tfor (const n of names) {\n\t\t\tthis.piano.triggerAttackRelease(n, duration, this.Tone!.now(), velocity);\n\t\t}\n\t}\n\n\t/**\n\t * Play a melodic phrase: a sequence of scale degrees with configurable timing.\n\t * Each note plays for `noteDuration` seconds, with `gap` ms gap between notes.\n\t */\n\tasync playPhrase(\n\t\tdegrees: number[],\n\t\tkey: string,\n\t\toctave: number,\n\t\tnoteDuration: number = 0.4,\n\t\tgap: number = 100\n\t): Promise<void> {\n\t\tfor (let i = 0; i < degrees.length; i++) {\n\t\t\tthis.playScaleDegree(degrees[i], key, octave, noteDuration);\n\t\t\tawait this.wait(Math.round(noteDuration * 1000) + gap);\n\t\t}\n\t}\n\n\t/**\n\t * Play a sequence of scale degrees\n\t */\n\tasync playSequence(\n\t\tdegrees: number[],\n\t\tkey: string,\n\t\ttempo: number = 500,\n\t\toctave: number = 4,\n\t\tonNoteStart?: (index: number) => void,\n\t\tonNoteEnd?: (index: number) => void\n\t): Promise<void> {\n\t\tfor (let i = 0; i < degrees.length; i++) {\n\t\t\tonNoteStart?.(i);\n\t\t\tthis.playScaleDegree(degrees[i], key, octave, 0.45);\n\t\t\tawait this.wait(tempo);\n\t\t\tonNoteEnd?.(i);\n\t\t}\n\t}\n\n\t/**\n\t * Utility to wait for a given duration\n\t */\n\tprivate wait(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n\n\t/**\n\t * Check if audio is ready\n\t */\n\tget isReady(): boolean {\n\t\treturn this.isInitialized;\n\t}\n\n\t/**\n\t * Current audio-clock time in SECONDS (`Tone.now()`), or 0 before init.\n\t *\n\t * The scheduling seam for web-core's per-segment audio hook\n\t * (`@real-music-packages/web-core/scene` → `recordSceneSpec`'s `segmentAudio`\n\t * / `BuiltScene.scheduleAudio`). Pair it with `instrument` so an\n\t * AudioEngine-driven app (RET) can satisfy `segmentAudio` exactly like a\n\t * raw promo sampler does — `{ instrument: engine.instrument, audioNow: () => engine.audioNow() }`.\n\t */\n\taudioNow(): number {\n\t\treturn this.Tone ? this.Tone.now() : 0;\n\t}\n\n\t/**\n\t * A `ScheduleTarget`-shaped view of the active piano voice: an object whose\n\t * `triggerAttackRelease(note, dur, time?, velocity?)` forwards to the current\n\t * voice (synth, upgrading to the Salamander sampler when it loads). Unlike\n\t * `playNote`/`playChord` — which hard-code `Tone.now()` — this accepts an\n\t * explicit absolute `time`, which is what offline/deterministic capture needs.\n\t *\n\t * This is the seam web-core's runner consumes: `segmentAudio.instrument =\n\t * engine.instrument`. The declarative `SegmentAudio.schedule` path drives this\n\t * via `applySchedule`; the imperative `onEnter` path can call it directly, or\n\t * use the `scheduleNoteAt` / `scheduleChordAt` convenience wrappers below.\n\t */\n\tget instrument(): {\n\t\ttriggerAttackRelease: (note: unknown, dur: unknown, time?: unknown, velocity?: unknown) => void;\n\t} {\n\t\treturn {\n\t\t\ttriggerAttackRelease: (note, dur, time, velocity) => {\n\t\t\t\tthis.piano?.triggerAttackRelease(\n\t\t\t\t\tnote as string,\n\t\t\t\t\tdur as number | string,\n\t\t\t\t\ttime as number | undefined,\n\t\t\t\t\tvelocity as number | undefined,\n\t\t\t\t);\n\t\t\t},\n\t\t};\n\t}\n\n\t/**\n\t * Schedule a single MIDI note to sound at an explicit absolute audio-clock\n\t * time (seconds). The capture-clock counterpart of `playNote` — use it inside\n\t * a segment's `audio.onEnter` (where you're handed the segment's `startSec`).\n\t */\n\tscheduleNoteAt(midiNote: number, atSec: number, durSec: number = 0.5, velocity: number = 0.8): void {\n\t\tif (!this.piano) return;\n\t\tthis.piano.triggerAttackRelease(midiToNoteName(midiNote), durSec, atSec, velocity);\n\t}\n\n\t/**\n\t * Schedule a chord (list of MIDI notes) to sound at an explicit absolute\n\t * audio-clock time (seconds). The capture-clock counterpart of `playChord`.\n\t */\n\tscheduleChordAt(midiNotes: number[], atSec: number, durSec: number = 1.5, velocity: number = 0.7): void {\n\t\tif (!this.piano) return;\n\t\tfor (const m of midiNotes) {\n\t\t\tthis.piano.triggerAttackRelease(midiToNoteName(m), durSec, atSec, velocity);\n\t\t}\n\t}\n\n\t/** A MediaStream of the engine's full output, tapped additively (speakers keep\n\t * playing). Returns null until init() has created the audio context. For the\n\t * promo capture only — not part of normal playback. */\n\tgetCaptureStream(): MediaStream | null {\n\t\tif (!this.Tone || !this.rawCtx) return null;\n\t\tif (!this.captureDest) {\n\t\t\tthis.captureDest = this.rawCtx.createMediaStreamDestination();\n\t\t\t(this.Tone.getDestination() as unknown as { connect: (n: AudioNode) => void }).connect(this.captureDest);\n\t\t}\n\t\treturn this.captureDest.stream;\n\t}\n}\n\n// Singleton instance\nexport const audio = new AudioEngine();\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/engine.ts"],"sourcesContent":["import { getMidiNote } from './scales';\nimport { midiToNoteName } from './notes';\nimport { KEYS_PREFER_FLATS } from './enharmonic';\nimport { SALAMANDER_URLS_FULL } from './salamander';\nimport { createSalamanderSampler, generateReverb } from './audioHelpers';\n\n// SSR-safe browser guard (equivalent to SvelteKit's `browser` at runtime).\nconst browser = typeof window !== 'undefined';\n\n// Tone.js types (loaded dynamically)\ntype ToneSampler = {\n\ttriggerAttackRelease: (note: string, duration: number | string, time?: number, velocity?: number) => void;\n\ttoDestination: () => ToneSampler;\n\tvolume?: { value: number };\n};\ntype TonePolySynth = {\n\ttriggerAttackRelease: (notes: string[], duration: number | string, time?: number, velocity?: number) => void;\n\ttriggerAttack: (notes: string[], time?: number, velocity?: number) => void;\n\ttriggerRelease: (notes: string[], time?: number) => void;\n\treleaseAll?: (time?: number) => void;\n\ttoDestination: () => TonePolySynth;\n\tconnect: (node: unknown) => TonePolySynth;\n\tvolume: { value: number };\n};\n// Minimal audio-node shape we need to reroute the pad through reverb later.\ntype ToneNode = {\n\tconnect: (node: unknown) => unknown;\n\tdisconnect: () => void;\n\ttoDestination: () => unknown;\n};\n\n// Pre-warm the Tone import so that init() can call Tone.start()\n// synchronously inside a user-gesture handler (required by iOS Safari).\nconst tonePreload: Promise<typeof import('tone')> | null = browser\n\t? import('tone')\n\t: null;\n\nexport class AudioEngine {\n\tprivate piano: ToneSampler | null = null;\n\tprivate synthPiano: ToneSampler | null = null;\n\tprivate strings: TonePolySynth | null = null;\n\tprivate dronePad: TonePolySynth | null = null;\n\tprivate droneNotes: string[] = [];\n\t/** Piano voice level in dB; applied to whichever voice is active. */\n\tprivate pianoVolumeDb = 0;\n\tprivate isInitialized = false;\n\tprivate isLoading = false;\n\tprivate samplerUpgradeStarted = false;\n\tprivate reverbStarted = false;\n\tprivate padFilter: ToneNode | null = null;\n\tprivate Tone: typeof import('tone') | null = null;\n\t/** Which piano voice is currently sounding — for diagnostics. */\n\tvoice: 'none' | 'synth' | 'sampler' = 'none';\n\t/** Last init milestone reached — surfaced on-device to pinpoint stalls. */\n\tlastStage = 'idle';\n\t/**\n\t * Raw AudioContext we create + resume SYNCHRONOUSLY inside the first user\n\t * gesture. iOS Safari only unlocks audio when resume() is called directly\n\t * in the gesture task; awaiting the Tone import first (as init() must) loses\n\t * that activation, so Tone.start()'s resume() hangs with the context stuck\n\t * 'suspended'. We resume this context in the gesture, then hand it to Tone.\n\t */\n\tprivate rawCtx: AudioContext | null = null;\n\tprivate captureDest: MediaStreamAudioDestinationNode | null = null;\n\n\t/**\n\t * Initialize the audio engine (must be called from a user gesture handler).\n\t *\n\t * Strategy: graceful degradation. We bring up a synthesized piano voice\n\t * FIRST — it needs no downloads and decodes nothing, so it is ready\n\t * instantly and works on every browser including iOS Safari. Audio is\n\t * considered ready at that point. We THEN try to load the richer\n\t * Salamander samples in the background and swap them in if they finish.\n\t *\n\t * Why: Tone.Sampler's MP3 decode path hangs on iOS Safari (await\n\t * Tone.loaded() never resolves), which previously stalled init forever.\n\t * Loading samples off the critical path means iOS always gets working\n\t * sound (synth) and merely misses the upgrade, instead of getting silence.\n\t *\n\t * On iOS Safari, Tone.start() must run during the synchronous portion of\n\t * the gesture handler, which is why we pre-import the Tone module above.\n\t */\n\tasync init(): Promise<void> {\n\t\tif (!browser) return;\n\t\tif (this.isInitialized || this.isLoading) return;\n\t\tthis.isLoading = true;\n\t\tthis.lastStage = 'init-start';\n\n\t\ttry {\n\t\t\t// Resolve the pre-warmed Tone import (typically already done).\n\t\t\tthis.Tone = await tonePreload!;\n\t\t\tthis.lastStage = 'tone-imported';\n\n\t\t\t// Use the context we already resumed synchronously in the gesture\n\t\t\t// (see unlock()). Handing it to Tone before any node is created means\n\t\t\t// Tone runs on an already-'running' context, so start() can't hang.\n\t\t\tif (this.rawCtx) {\n\t\t\t\ttry {\n\t\t\t\t\tthis.Tone.setContext(this.rawCtx);\n\t\t\t\t} catch (e) {\n\t\t\t\t\tconsole.warn('[audio] setContext failed', e);\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.lastStage = 'context-set:' + this.contextState;\n\n\t\t\t// start() resumes the context; on an already-running context it\n\t\t\t// resolves immediately. Race a short timeout as a backstop so a\n\t\t\t// hung resume() can never stall init — the context is running anyway.\n\t\t\tawait Promise.race([\n\t\t\t\tthis.Tone.start(),\n\t\t\t\tnew Promise<void>((r) => setTimeout(r, 1500)),\n\t\t\t]);\n\t\t\tthis.lastStage = 'context-started:' + this.contextState;\n\n\t\t\t// ── Reliable synth piano: instant, zero downloads, universal ──\n\t\t\tthis.synthPiano = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'triangle' },\n\t\t\t\tenvelope: { attack: 0.005, decay: 0.5, sustain: 0.3, release: 1.2 },\n\t\t\t}).toDestination() as unknown as ToneSampler;\n\t\t\tthis.piano = this.synthPiano;\n\t\t\tthis.voice = 'synth';\n\t\t\tthis.applyPianoVolume();\n\t\t\tthis.lastStage = 'piano-synth-ready';\n\n\t\t\t// ── Pad synth for background chords ──\n\t\t\t// Pad → lowpass → destination, immediately and reliably (dry). Reverb\n\t\t\t// is added later in the background (see addReverbInBackground): its\n\t\t\t// generate() runs an OfflineAudioContext render that can hang on iOS,\n\t\t\t// so it must never sit on the init critical path.\n\t\t\tthis.padFilter = new this.Tone.Filter({\n\t\t\t\ttype: 'lowpass',\n\t\t\t\tfrequency: 2000,\n\t\t\t\trolloff: -12,\n\t\t\t}).toDestination() as unknown as ToneNode;\n\n\t\t\tthis.strings = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'fatsawtooth', count: 3, spread: 30 },\n\t\t\t\tenvelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 2 },\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t\t}).connect(this.padFilter as any) as TonePolySynth;\n\t\t\tthis.strings.volume.value = -10;\n\n\t\t\t// ── Drone pad: a sustained, NON-resolving key anchor ──\n\t\t\t// Open-fifth tonic drone (root + 5th, no third). Exploratory views use\n\t\t\t// it instead of a tonic triad so a held note keeps its real tension\n\t\t\t// (e.g. the leading tone 7 still strains toward 1) rather than being\n\t\t\t// harmonised into a consonant Imaj/Imaj7. Soft, low, routed through the\n\t\t\t// same filter/reverb as the pad. Sustains until stopDrone().\n\t\t\tthis.dronePad = new this.Tone.PolySynth(this.Tone.Synth, {\n\t\t\t\toscillator: { type: 'triangle' },\n\t\t\t\tenvelope: { attack: 0.6, decay: 0.2, sustain: 0.9, release: 1.5 },\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\t\t}).connect(this.padFilter as any) as TonePolySynth;\n\t\t\tthis.dronePad.volume.value = -16;\n\n\t\t\t// Audio is usable now — do NOT block on sample loading or reverb.\n\t\t\tthis.isInitialized = true;\n\t\t\tthis.lastStage = 'ready';\n\n\t\t\t// Background upgrades — neither blocks readiness.\n\t\t\tthis.upgradeToSamplerInBackground();\n\t\t\tthis.addReverbInBackground();\n\t\t} catch (error) {\n\t\t\tthis.lastStage = 'error:' + (error instanceof Error ? error.message : String(error));\n\t\t\tconsole.error('Failed to initialize audio:', error);\n\t\t} finally {\n\t\t\tthis.isLoading = false;\n\t\t}\n\t}\n\n\t/**\n\t * Attempt to load the Salamander sampler in the background and swap it in\n\t * for the synth piano once (and only if) every sample has decoded. On iOS\n\t * Safari this typically never completes, so we simply stay on the synth —\n\t * no stall is ever surfaced because init already resolved.\n\t */\n\tprivate upgradeToSamplerInBackground(): void {\n\t\tif (!this.Tone || this.samplerUpgradeStarted) return;\n\t\tthis.samplerUpgradeStarted = true;\n\n\t\ttry {\n\t\t\tconst sampler = createSalamanderSampler(this.Tone, {\n\t\t\t\turls: SALAMANDER_URLS_FULL,\n\t\t\t\tbaseUrl: '/audio/salamander/',\n\t\t\t\trelease: 1,\n\t\t\t\tonload: () => {\n\t\t\t\t\t// Swap only after every sample decoded successfully.\n\t\t\t\t\tthis.piano = sampler as ToneSampler;\n\t\t\t\t\tthis.voice = 'sampler';\n\t\t\t\t\tthis.applyPianoVolume();\n\t\t\t\t\tconsole.info('[audio] upgraded piano voice to Salamander samples');\n\t\t\t\t},\n\t\t\t\tonerror: (e: unknown) => {\n\t\t\t\t\tconsole.warn('[audio] sample load failed, staying on synth voice', e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tvoid sampler;\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] sampler init threw, staying on synth voice', e);\n\t\t}\n\t}\n\n\t/**\n\t * Generate a reverb in the background and reroute the background pad through\n\t * it once ready: pad → filter → reverb → destination. Reverb.generate()\n\t * runs an OfflineAudioContext render that can hang on iOS Safari, so this is\n\t * deliberately off the init critical path — if it never resolves, the pad\n\t * simply stays dry. Raced against a timeout so we log and move on cleanly.\n\t */\n\tprivate async addReverbInBackground(): Promise<void> {\n\t\tif (!this.Tone || !this.padFilter || this.reverbStarted) return;\n\t\tthis.reverbStarted = true;\n\n\t\ttry {\n\t\t\tconst reverb = await generateReverb(this.Tone, { decay: 3, wet: 0.5 }, 4000);\n\t\t\tif (!reverb) {\n\t\t\t\tconsole.warn('[audio] reverb generate timed out, pad stays dry');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t(reverb as unknown as ToneNode).toDestination();\n\t\t\t// Reroute the pad: detach the filter from the raw destination and\n\t\t\t// send it through the reverb instead.\n\t\t\tthis.padFilter.disconnect();\n\t\t\tthis.padFilter.connect(reverb);\n\t\t\tconsole.info('[audio] reverb enabled');\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] reverb generate failed, pad stays dry', e);\n\t\t}\n\t}\n\n\t/**\n\t * MUST be called SYNCHRONOUSLY from a user-gesture handler (touchstart /\n\t * click / keydown), before any await. Creates and resumes a raw\n\t * AudioContext in the gesture task so iOS Safari actually unlocks audio.\n\t * init() then adopts this context via Tone.setContext(). Idempotent.\n\t */\n\tunlock(): void {\n\t\tif (!browser) return;\n\t\ttry {\n\t\t\tif (!this.rawCtx) {\n\t\t\t\tconst AC = (window.AudioContext ||\n\t\t\t\t\t(window as unknown as { webkitAudioContext?: typeof AudioContext })\n\t\t\t\t\t\t.webkitAudioContext) as typeof AudioContext | undefined;\n\t\t\t\tif (!AC) return;\n\t\t\t\tthis.rawCtx = new AC();\n\t\t\t}\n\t\t\tif (this.rawCtx.state !== 'running') {\n\t\t\t\tthis.rawCtx.resume().catch(() => {});\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] unlock failed', e);\n\t\t}\n\t}\n\n\t/**\n\t * iOS Safari can silently leave the AudioContext in 'suspended' or\n\t * 'interrupted' state even after Tone.start() resolves, especially after\n\t * tab backgrounding or initial activation. Call this synchronously from\n\t * a user-gesture handler (or before playback) to wake it up. No-op when\n\t * the context is already running.\n\t */\n\tensureRunning(): void {\n\t\t// Resume the raw context we own (covers the pre-Tone window too).\n\t\tif (this.rawCtx && this.rawCtx.state !== 'running') {\n\t\t\tthis.rawCtx.resume().catch(() => {});\n\t\t}\n\t\tif (!this.Tone) return;\n\t\tconst ctx = this.Tone.getContext().rawContext as AudioContext | undefined;\n\t\tif (ctx && ctx.state !== 'running') {\n\t\t\t// Fire-and-forget resume — keeps the call synchronous so it stays\n\t\t\t// inside the current user-gesture stack.\n\t\t\tctx.resume().catch(() => {});\n\t\t}\n\t}\n\n\t/** Current AudioContext state, for diagnostics. */\n\tget contextState(): string {\n\t\tif (!this.Tone) return 'no-tone';\n\t\tconst ctx = this.Tone.getContext().rawContext as AudioContext | undefined;\n\t\treturn ctx?.state ?? 'no-context';\n\t}\n\n\t/**\n\t * Play a MIDI note\n\t */\n\tplayNote(midiNote: number, duration: number = 0.5, velocity: number = 0.8): void {\n\t\tif (!this.piano || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tconst noteName = midiToNoteName(midiNote);\n\t\tthis.piano.triggerAttackRelease(noteName, duration, this.Tone!.now(), velocity);\n\t}\n\n\t/**\n\t * Play a scale degree in a given key\n\t */\n\tplayScaleDegree(degree: number, key: string, octave: number = 4, duration: number = 0.5): void {\n\t\tconst midiNote = getMidiNote(degree, key, octave);\n\t\tthis.playNote(midiNote, duration);\n\t}\n\n\t/**\n\t * Start a sustained background chord (tonic chord)\n\t */\n\tstartBackgroundChord(key: string, duration: number = 4): void {\n\t\tif (!this.strings || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\n\t\tconst useFlats = (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n\n\t\t// Build tonic chord voicing\n\t\tconst root4 = getMidiNote(1, key, 4);\n\t\tconst fifth4 = getMidiNote(5, key, 4);\n\t\tconst root5 = getMidiNote(1, key, 5);\n\t\tconst third5 = getMidiNote(3, key, 5);\n\n\t\tconst notes = [root4, fifth4, root5, third5].map((m) => midiToNoteName(m, useFlats));\n\n\t\tthis.strings.triggerAttackRelease(notes, duration, this.Tone!.now(), 0.3);\n\t}\n\n\t/**\n\t * Start a sustained tonic DRONE as a key anchor — an open fifth (root + 5th,\n\t * no third) in a low octave. Unlike startBackgroundChord (a full tonic\n\t * triad), the missing third means individual scale degrees keep their\n\t * tension instead of being resolved into the tonic chord: a note held over\n\t * this drone sounds as tense/stable as it truly is. Intended for exploratory\n\t * note-by-note views. Sustains until stopDrone() is called; calling again\n\t * restarts cleanly in the new key. Idempotent-safe.\n\t */\n\tstartDrone(key: string): void {\n\t\tif (!this.dronePad || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tthis.stopDrone();\n\n\t\tconst useFlats = (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n\t\tconst root3 = getMidiNote(1, key, 3);\n\t\tconst fifth3 = getMidiNote(5, key, 3);\n\t\tconst root4 = getMidiNote(1, key, 4);\n\t\tthis.droneNotes = [root3, fifth3, root4].map((m) => midiToNoteName(m, useFlats));\n\n\t\tthis.dronePad.triggerAttack(this.droneNotes, this.Tone!.now());\n\t}\n\n\t/** Stop the sustained drone started by startDrone(). No-op if none playing. */\n\tstopDrone(): void {\n\t\tif (!this.dronePad) return;\n\t\ttry {\n\t\t\tif (this.droneNotes.length > 0) {\n\t\t\t\tthis.dronePad.triggerRelease(this.droneNotes, this.Tone!.now());\n\t\t\t} else {\n\t\t\t\tthis.dronePad.releaseAll?.(this.Tone!.now());\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.warn('[audio] stopDrone failed', e);\n\t\t}\n\t\tthis.droneNotes = [];\n\t}\n\n\t/**\n\t * Set the piano voice level in dB (0 = unchanged default, negative = quieter).\n\t * Applies to the current voice and is re-applied when the sampler swaps in.\n\t */\n\tsetPianoVolume(db: number): void {\n\t\tthis.pianoVolumeDb = db;\n\t\tthis.applyPianoVolume();\n\t}\n\n\tprivate applyPianoVolume(): void {\n\t\tfor (const voice of [this.synthPiano, this.piano]) {\n\t\t\tconst vol = (voice as { volume?: { value: number } } | null)?.volume;\n\t\t\tif (vol) vol.value = this.pianoVolumeDb;\n\t\t}\n\t}\n\n\t/**\n\t * Play a chord (list of MIDI notes) on the piano sampler.\n\t */\n\tplayChord(midiNotes: number[], duration: number = 1.5, velocity: number = 0.7): void {\n\t\tif (!this.piano || !this.isInitialized) return;\n\t\tthis.ensureRunning();\n\t\tconst names = midiNotes.map((m) => midiToNoteName(m));\n\t\tfor (const n of names) {\n\t\t\tthis.piano.triggerAttackRelease(n, duration, this.Tone!.now(), velocity);\n\t\t}\n\t}\n\n\t/**\n\t * Play a melodic phrase: a sequence of scale degrees with configurable timing.\n\t * Each note plays for `noteDuration` seconds, with `gap` ms gap between notes.\n\t */\n\tasync playPhrase(\n\t\tdegrees: number[],\n\t\tkey: string,\n\t\toctave: number,\n\t\tnoteDuration: number = 0.4,\n\t\tgap: number = 100\n\t): Promise<void> {\n\t\tfor (let i = 0; i < degrees.length; i++) {\n\t\t\tthis.playScaleDegree(degrees[i], key, octave, noteDuration);\n\t\t\tawait this.wait(Math.round(noteDuration * 1000) + gap);\n\t\t}\n\t}\n\n\t/**\n\t * Play a sequence of scale degrees\n\t */\n\tasync playSequence(\n\t\tdegrees: number[],\n\t\tkey: string,\n\t\ttempo: number = 500,\n\t\toctave: number = 4,\n\t\tonNoteStart?: (index: number) => void,\n\t\tonNoteEnd?: (index: number) => void\n\t): Promise<void> {\n\t\tfor (let i = 0; i < degrees.length; i++) {\n\t\t\tonNoteStart?.(i);\n\t\t\tthis.playScaleDegree(degrees[i], key, octave, 0.45);\n\t\t\tawait this.wait(tempo);\n\t\t\tonNoteEnd?.(i);\n\t\t}\n\t}\n\n\t/**\n\t * Utility to wait for a given duration\n\t */\n\tprivate wait(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n\n\t/**\n\t * Check if audio is ready\n\t */\n\tget isReady(): boolean {\n\t\treturn this.isInitialized;\n\t}\n\n\t/**\n\t * Current audio-clock time in SECONDS (`Tone.now()`), or 0 before init.\n\t *\n\t * The scheduling seam for web-core's per-segment audio hook\n\t * (`@real-music-packages/web-core/scene` → `recordSceneSpec`'s `segmentAudio`\n\t * / `BuiltScene.scheduleAudio`). Pair it with `instrument` so an\n\t * AudioEngine-driven app (RET) can satisfy `segmentAudio` exactly like a\n\t * raw promo sampler does — `{ instrument: engine.instrument, audioNow: () => engine.audioNow() }`.\n\t */\n\taudioNow(): number {\n\t\treturn this.Tone ? this.Tone.now() : 0;\n\t}\n\n\t/**\n\t * A `ScheduleTarget`-shaped view of the active piano voice: an object whose\n\t * `triggerAttackRelease(note, dur, time?, velocity?)` forwards to the current\n\t * voice (synth, upgrading to the Salamander sampler when it loads). Unlike\n\t * `playNote`/`playChord` — which hard-code `Tone.now()` — this accepts an\n\t * explicit absolute `time`, which is what offline/deterministic capture needs.\n\t *\n\t * This is the seam web-core's runner consumes: `segmentAudio.instrument =\n\t * engine.instrument`. The declarative `SegmentAudio.schedule` path drives this\n\t * via `applySchedule`; the imperative `onEnter` path can call it directly, or\n\t * use the `scheduleNoteAt` / `scheduleChordAt` convenience wrappers below.\n\t */\n\tget instrument(): {\n\t\ttriggerAttackRelease: (note: unknown, dur: unknown, time?: unknown, velocity?: unknown) => void;\n\t} {\n\t\treturn {\n\t\t\ttriggerAttackRelease: (note, dur, time, velocity) => {\n\t\t\t\tthis.piano?.triggerAttackRelease(\n\t\t\t\t\tnote as string,\n\t\t\t\t\tdur as number | string,\n\t\t\t\t\ttime as number | undefined,\n\t\t\t\t\tvelocity as number | undefined,\n\t\t\t\t);\n\t\t\t},\n\t\t};\n\t}\n\n\t/**\n\t * Schedule a single MIDI note to sound at an explicit absolute audio-clock\n\t * time (seconds). The capture-clock counterpart of `playNote` — use it inside\n\t * a segment's `audio.onEnter` (where you're handed the segment's `startSec`).\n\t */\n\tscheduleNoteAt(midiNote: number, atSec: number, durSec: number = 0.5, velocity: number = 0.8): void {\n\t\tif (!this.piano) return;\n\t\tthis.piano.triggerAttackRelease(midiToNoteName(midiNote), durSec, atSec, velocity);\n\t}\n\n\t/**\n\t * Schedule a chord (list of MIDI notes) to sound at an explicit absolute\n\t * audio-clock time (seconds). The capture-clock counterpart of `playChord`.\n\t */\n\tscheduleChordAt(midiNotes: number[], atSec: number, durSec: number = 1.5, velocity: number = 0.7): void {\n\t\tif (!this.piano) return;\n\t\tfor (const m of midiNotes) {\n\t\t\tthis.piano.triggerAttackRelease(midiToNoteName(m), durSec, atSec, velocity);\n\t\t}\n\t}\n\n\t/** A MediaStream of the engine's full output, tapped additively (speakers keep\n\t * playing). Returns null until init() has created the audio context. For the\n\t * promo capture only — not part of normal playback. */\n\tgetCaptureStream(): MediaStream | null {\n\t\tif (!this.Tone || !this.rawCtx) return null;\n\t\tif (!this.captureDest) {\n\t\t\tthis.captureDest = this.rawCtx.createMediaStreamDestination();\n\t\t\t(this.Tone.getDestination() as unknown as { connect: (n: AudioNode) => void }).connect(this.captureDest);\n\t\t}\n\t\treturn this.captureDest.stream;\n\t}\n}\n\n// Singleton instance\nexport const audio = new AudioEngine();\n"],"mappings":";;;;;;;;;;;;;;;;AAOA,IAAM,UAAU,OAAO,WAAW;AA0BlC,IAAM,cAAqD,UACxD,OAAO,MAAM,IACb;AAEI,IAAM,cAAN,MAAkB;AAAA,EAChB,QAA4B;AAAA,EAC5B,aAAiC;AAAA,EACjC,UAAgC;AAAA,EAChC,WAAiC;AAAA,EACjC,aAAuB,CAAC;AAAA;AAAA,EAExB,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,wBAAwB;AAAA,EACxB,gBAAgB;AAAA,EAChB,YAA6B;AAAA,EAC7B,OAAqC;AAAA;AAAA,EAE7C,QAAsC;AAAA;AAAA,EAEtC,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQJ,SAA8B;AAAA,EAC9B,cAAsD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmB9D,MAAM,OAAsB;AAC3B,QAAI,CAAC,QAAS;AACd,QAAI,KAAK,iBAAiB,KAAK,UAAW;AAC1C,SAAK,YAAY;AACjB,SAAK,YAAY;AAEjB,QAAI;AAEH,WAAK,OAAO,MAAM;AAClB,WAAK,YAAY;AAKjB,UAAI,KAAK,QAAQ;AAChB,YAAI;AACH,eAAK,KAAK,WAAW,KAAK,MAAM;AAAA,QACjC,SAAS,GAAG;AACX,kBAAQ,KAAK,6BAA6B,CAAC;AAAA,QAC5C;AAAA,MACD;AACA,WAAK,YAAY,iBAAiB,KAAK;AAKvC,YAAM,QAAQ,KAAK;AAAA,QAClB,KAAK,KAAK,MAAM;AAAA,QAChB,IAAI,QAAc,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC;AAAA,MAC7C,CAAC;AACD,WAAK,YAAY,qBAAqB,KAAK;AAG3C,WAAK,aAAa,IAAI,KAAK,KAAK,UAAU,KAAK,KAAK,OAAO;AAAA,QAC1D,YAAY,EAAE,MAAM,WAAW;AAAA,QAC/B,UAAU,EAAE,QAAQ,MAAO,OAAO,KAAK,SAAS,KAAK,SAAS,IAAI;AAAA,MACnE,CAAC,EAAE,cAAc;AACjB,WAAK,QAAQ,KAAK;AAClB,WAAK,QAAQ;AACb,WAAK,iBAAiB;AACtB,WAAK,YAAY;AAOjB,WAAK,YAAY,IAAI,KAAK,KAAK,OAAO;AAAA,QACrC,MAAM;AAAA,QACN,WAAW;AAAA,QACX,SAAS;AAAA,MACV,CAAC,EAAE,cAAc;AAEjB,WAAK,UAAU,IAAI,KAAK,KAAK,UAAU,KAAK,KAAK,OAAO;AAAA,QACvD,YAAY,EAAE,MAAM,eAAe,OAAO,GAAG,QAAQ,GAAG;AAAA,QACxD,UAAU,EAAE,QAAQ,KAAK,OAAO,KAAK,SAAS,KAAK,SAAS,EAAE;AAAA;AAAA,MAE/D,CAAC,EAAE,QAAQ,KAAK,SAAgB;AAChC,WAAK,QAAQ,OAAO,QAAQ;AAQ5B,WAAK,WAAW,IAAI,KAAK,KAAK,UAAU,KAAK,KAAK,OAAO;AAAA,QACxD,YAAY,EAAE,MAAM,WAAW;AAAA,QAC/B,UAAU,EAAE,QAAQ,KAAK,OAAO,KAAK,SAAS,KAAK,SAAS,IAAI;AAAA;AAAA,MAEjE,CAAC,EAAE,QAAQ,KAAK,SAAgB;AAChC,WAAK,SAAS,OAAO,QAAQ;AAG7B,WAAK,gBAAgB;AACrB,WAAK,YAAY;AAGjB,WAAK,6BAA6B;AAClC,WAAK,sBAAsB;AAAA,IAC5B,SAAS,OAAO;AACf,WAAK,YAAY,YAAY,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAClF,cAAQ,MAAM,+BAA+B,KAAK;AAAA,IACnD,UAAE;AACD,WAAK,YAAY;AAAA,IAClB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQQ,+BAAqC;AAC5C,QAAI,CAAC,KAAK,QAAQ,KAAK,sBAAuB;AAC9C,SAAK,wBAAwB;AAE7B,QAAI;AACH,YAAM,UAAU,wBAAwB,KAAK,MAAM;AAAA,QAClD,MAAM;AAAA,QACN,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ,MAAM;AAEb,eAAK,QAAQ;AACb,eAAK,QAAQ;AACb,eAAK,iBAAiB;AACtB,kBAAQ,KAAK,oDAAoD;AAAA,QAClE;AAAA,QACA,SAAS,CAAC,MAAe;AACxB,kBAAQ,KAAK,sDAAsD,CAAC;AAAA,QACrE;AAAA,MACD,CAAC;AACD,WAAK;AAAA,IACN,SAAS,GAAG;AACX,cAAQ,KAAK,sDAAsD,CAAC;AAAA,IACrE;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,wBAAuC;AACpD,QAAI,CAAC,KAAK,QAAQ,CAAC,KAAK,aAAa,KAAK,cAAe;AACzD,SAAK,gBAAgB;AAErB,QAAI;AACH,YAAM,SAAS,MAAM,eAAe,KAAK,MAAM,EAAE,OAAO,GAAG,KAAK,IAAI,GAAG,GAAI;AAC3E,UAAI,CAAC,QAAQ;AACZ,gBAAQ,KAAK,kDAAkD;AAC/D;AAAA,MACD;AACA,MAAC,OAA+B,cAAc;AAG9C,WAAK,UAAU,WAAW;AAC1B,WAAK,UAAU,QAAQ,MAAM;AAC7B,cAAQ,KAAK,wBAAwB;AAAA,IACtC,SAAS,GAAG;AACX,cAAQ,KAAK,iDAAiD,CAAC;AAAA,IAChE;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,SAAe;AACd,QAAI,CAAC,QAAS;AACd,QAAI;AACH,UAAI,CAAC,KAAK,QAAQ;AACjB,cAAM,KAAM,OAAO,gBACjB,OACC;AACH,YAAI,CAAC,GAAI;AACT,aAAK,SAAS,IAAI,GAAG;AAAA,MACtB;AACA,UAAI,KAAK,OAAO,UAAU,WAAW;AACpC,aAAK,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAAA,MACpC;AAAA,IACD,SAAS,GAAG;AACX,cAAQ,KAAK,yBAAyB,CAAC;AAAA,IACxC;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,gBAAsB;AAErB,QAAI,KAAK,UAAU,KAAK,OAAO,UAAU,WAAW;AACnD,WAAK,OAAO,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACpC;AACA,QAAI,CAAC,KAAK,KAAM;AAChB,UAAM,MAAM,KAAK,KAAK,WAAW,EAAE;AACnC,QAAI,OAAO,IAAI,UAAU,WAAW;AAGnC,UAAI,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IAC5B;AAAA,EACD;AAAA;AAAA,EAGA,IAAI,eAAuB;AAC1B,QAAI,CAAC,KAAK,KAAM,QAAO;AACvB,UAAM,MAAM,KAAK,KAAK,WAAW,EAAE;AACnC,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA,EAKA,SAAS,UAAkB,WAAmB,KAAK,WAAmB,KAAW;AAChF,QAAI,CAAC,KAAK,SAAS,CAAC,KAAK,cAAe;AACxC,SAAK,cAAc;AACnB,UAAM,WAAW,eAAe,QAAQ;AACxC,SAAK,MAAM,qBAAqB,UAAU,UAAU,KAAK,KAAM,IAAI,GAAG,QAAQ;AAAA,EAC/E;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAgB,QAAgB,KAAa,SAAiB,GAAG,WAAmB,KAAW;AAC9F,UAAM,WAAW,YAAY,QAAQ,KAAK,MAAM;AAChD,SAAK,SAAS,UAAU,QAAQ;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,qBAAqB,KAAa,WAAmB,GAAS;AAC7D,QAAI,CAAC,KAAK,WAAW,CAAC,KAAK,cAAe;AAC1C,SAAK,cAAc;AAEnB,UAAM,WAAY,kBAAwC,SAAS,GAAG;AAGtE,UAAM,QAAQ,YAAY,GAAG,KAAK,CAAC;AACnC,UAAM,SAAS,YAAY,GAAG,KAAK,CAAC;AACpC,UAAM,QAAQ,YAAY,GAAG,KAAK,CAAC;AACnC,UAAM,SAAS,YAAY,GAAG,KAAK,CAAC;AAEpC,UAAM,QAAQ,CAAC,OAAO,QAAQ,OAAO,MAAM,EAAE,IAAI,CAAC,MAAM,eAAe,GAAG,QAAQ,CAAC;AAEnF,SAAK,QAAQ,qBAAqB,OAAO,UAAU,KAAK,KAAM,IAAI,GAAG,GAAG;AAAA,EACzE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,WAAW,KAAmB;AAC7B,QAAI,CAAC,KAAK,YAAY,CAAC,KAAK,cAAe;AAC3C,SAAK,cAAc;AACnB,SAAK,UAAU;AAEf,UAAM,WAAY,kBAAwC,SAAS,GAAG;AACtE,UAAM,QAAQ,YAAY,GAAG,KAAK,CAAC;AACnC,UAAM,SAAS,YAAY,GAAG,KAAK,CAAC;AACpC,UAAM,QAAQ,YAAY,GAAG,KAAK,CAAC;AACnC,SAAK,aAAa,CAAC,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,MAAM,eAAe,GAAG,QAAQ,CAAC;AAE/E,SAAK,SAAS,cAAc,KAAK,YAAY,KAAK,KAAM,IAAI,CAAC;AAAA,EAC9D;AAAA;AAAA,EAGA,YAAkB;AACjB,QAAI,CAAC,KAAK,SAAU;AACpB,QAAI;AACH,UAAI,KAAK,WAAW,SAAS,GAAG;AAC/B,aAAK,SAAS,eAAe,KAAK,YAAY,KAAK,KAAM,IAAI,CAAC;AAAA,MAC/D,OAAO;AACN,aAAK,SAAS,aAAa,KAAK,KAAM,IAAI,CAAC;AAAA,MAC5C;AAAA,IACD,SAAS,GAAG;AACX,cAAQ,KAAK,4BAA4B,CAAC;AAAA,IAC3C;AACA,SAAK,aAAa,CAAC;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,IAAkB;AAChC,SAAK,gBAAgB;AACrB,SAAK,iBAAiB;AAAA,EACvB;AAAA,EAEQ,mBAAyB;AAChC,eAAW,SAAS,CAAC,KAAK,YAAY,KAAK,KAAK,GAAG;AAClD,YAAM,MAAO,OAAiD;AAC9D,UAAI,IAAK,KAAI,QAAQ,KAAK;AAAA,IAC3B;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKA,UAAU,WAAqB,WAAmB,KAAK,WAAmB,KAAW;AACpF,QAAI,CAAC,KAAK,SAAS,CAAC,KAAK,cAAe;AACxC,SAAK,cAAc;AACnB,UAAM,QAAQ,UAAU,IAAI,CAAC,MAAM,eAAe,CAAC,CAAC;AACpD,eAAW,KAAK,OAAO;AACtB,WAAK,MAAM,qBAAqB,GAAG,UAAU,KAAK,KAAM,IAAI,GAAG,QAAQ;AAAA,IACxE;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WACL,SACA,KACA,QACA,eAAuB,KACvB,MAAc,KACE;AAChB,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACxC,WAAK,gBAAgB,QAAQ,CAAC,GAAG,KAAK,QAAQ,YAAY;AAC1D,YAAM,KAAK,KAAK,KAAK,MAAM,eAAe,GAAI,IAAI,GAAG;AAAA,IACtD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,aACL,SACA,KACA,QAAgB,KAChB,SAAiB,GACjB,aACA,WACgB;AAChB,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACxC,oBAAc,CAAC;AACf,WAAK,gBAAgB,QAAQ,CAAC,GAAG,KAAK,QAAQ,IAAI;AAClD,YAAM,KAAK,KAAK,KAAK;AACrB,kBAAY,CAAC;AAAA,IACd;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKQ,KAAK,IAA2B;AACvC,WAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,EACxD;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,UAAmB;AACtB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,WAAmB;AAClB,WAAO,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI;AAAA,EACtC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,IAAI,aAEF;AACD,WAAO;AAAA,MACN,sBAAsB,CAAC,MAAM,KAAK,MAAM,aAAa;AACpD,aAAK,OAAO;AAAA,UACX;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QACD;AAAA,MACD;AAAA,IACD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,eAAe,UAAkB,OAAe,SAAiB,KAAK,WAAmB,KAAW;AACnG,QAAI,CAAC,KAAK,MAAO;AACjB,SAAK,MAAM,qBAAqB,eAAe,QAAQ,GAAG,QAAQ,OAAO,QAAQ;AAAA,EAClF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,gBAAgB,WAAqB,OAAe,SAAiB,KAAK,WAAmB,KAAW;AACvG,QAAI,CAAC,KAAK,MAAO;AACjB,eAAW,KAAK,WAAW;AAC1B,WAAK,MAAM,qBAAqB,eAAe,CAAC,GAAG,QAAQ,OAAO,QAAQ;AAAA,IAC3E;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKA,mBAAuC;AACtC,QAAI,CAAC,KAAK,QAAQ,CAAC,KAAK,OAAQ,QAAO;AACvC,QAAI,CAAC,KAAK,aAAa;AACtB,WAAK,cAAc,KAAK,OAAO,6BAA6B;AAC5D,MAAC,KAAK,KAAK,eAAe,EAAqD,QAAQ,KAAK,WAAW;AAAA,IACxG;AACA,WAAO,KAAK,YAAY;AAAA,EACzB;AACD;AAGO,IAAM,QAAQ,IAAI,YAAY;","names":[]}
@@ -0,0 +1,31 @@
1
+ // src/notes.ts
2
+ var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
3
+ var NOTE_NAMES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
4
+ var LETTER_TO_INDEX = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
5
+ function noteNameToIndex(note) {
6
+ const letter = note[0]?.toUpperCase();
7
+ let index = LETTER_TO_INDEX[letter];
8
+ if (index === void 0) throw new Error(`Invalid note name: ${note}`);
9
+ for (const ch of note.slice(1)) {
10
+ if (ch === "#") index += 1;
11
+ else if (ch === "b") index -= 1;
12
+ }
13
+ return (index % 12 + 12) % 12;
14
+ }
15
+ function pitchClass(midi) {
16
+ return (midi % 12 + 12) % 12;
17
+ }
18
+ function midiToNoteName(midi, useFlats = false) {
19
+ const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;
20
+ const octave = Math.floor(midi / 12) - 1;
21
+ return `${names[pitchClass(midi)]}${octave}`;
22
+ }
23
+
24
+ export {
25
+ NOTE_NAMES,
26
+ NOTE_NAMES_FLAT,
27
+ noteNameToIndex,
28
+ pitchClass,
29
+ midiToNoteName
30
+ };
31
+ //# sourceMappingURL=chunk-25RUSM2X.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notes.ts"],"sourcesContent":["export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;\nexport const NOTE_NAMES_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] as const;\n\nconst LETTER_TO_INDEX: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };\n\n/** Note name (e.g. \"C\", \"C#\", \"Db\", \"Cb\") → pitch class 0..11. */\nexport function noteNameToIndex(note: string): number {\n const letter = note[0]?.toUpperCase();\n let index = LETTER_TO_INDEX[letter];\n if (index === undefined) throw new Error(`Invalid note name: ${note}`);\n for (const ch of note.slice(1)) {\n if (ch === '#') index += 1;\n else if (ch === 'b') index -= 1;\n }\n return ((index % 12) + 12) % 12;\n}\n\n/** MIDI number → pitch class 0..11. */\nexport function pitchClass(midi: number): number {\n return ((midi % 12) + 12) % 12;\n}\n\n/** MIDI → note name with octave, e.g. 61 → \"C#4\" (or \"Db4\" with useFlats). */\nexport function midiToNoteName(midi: number, useFlats = false): string {\n const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;\n const octave = Math.floor(midi / 12) - 1;\n return `${names[pitchClass(midi)]}${octave}`;\n}\n"],"mappings":";AAAO,IAAM,aAAa,CAAC,KAAK,MAAM,KAAK,MAAM,KAAK,KAAK,MAAM,KAAK,MAAM,KAAK,MAAM,GAAG;AACnF,IAAM,kBAAkB,CAAC,KAAK,MAAM,KAAK,MAAM,KAAK,KAAK,MAAM,KAAK,MAAM,KAAK,MAAM,GAAG;AAE/F,IAAM,kBAA0C,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG;AAGrF,SAAS,gBAAgB,MAAsB;AACpD,QAAM,SAAS,KAAK,CAAC,GAAG,YAAY;AACpC,MAAI,QAAQ,gBAAgB,MAAM;AAClC,MAAI,UAAU,OAAW,OAAM,IAAI,MAAM,sBAAsB,IAAI,EAAE;AACrE,aAAW,MAAM,KAAK,MAAM,CAAC,GAAG;AAC9B,QAAI,OAAO,IAAK,UAAS;AAAA,aAChB,OAAO,IAAK,UAAS;AAAA,EAChC;AACA,UAAS,QAAQ,KAAM,MAAM;AAC/B;AAGO,SAAS,WAAW,MAAsB;AAC/C,UAAS,OAAO,KAAM,MAAM;AAC9B;AAGO,SAAS,eAAe,MAAc,WAAW,OAAe;AACrE,QAAM,QAAQ,WAAW,kBAAkB;AAC3C,QAAM,SAAS,KAAK,MAAM,OAAO,EAAE,IAAI;AACvC,SAAO,GAAG,MAAM,WAAW,IAAI,CAAC,CAAC,GAAG,MAAM;AAC5C;","names":[]}
@@ -1,25 +1,6 @@
1
- // src/notes.ts
2
- var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
3
- var NOTE_NAMES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
4
- var LETTER_TO_INDEX = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
5
- function noteNameToIndex(note) {
6
- const letter = note[0]?.toUpperCase();
7
- let index = LETTER_TO_INDEX[letter];
8
- if (index === void 0) throw new Error(`Invalid note name: ${note}`);
9
- for (const ch of note.slice(1)) {
10
- if (ch === "#") index += 1;
11
- else if (ch === "b") index -= 1;
12
- }
13
- return (index % 12 + 12) % 12;
14
- }
15
- function pitchClass(midi) {
16
- return (midi % 12 + 12) % 12;
17
- }
18
- function midiToNoteName(midi, useFlats = false) {
19
- const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;
20
- const octave = Math.floor(midi / 12) - 1;
21
- return `${names[pitchClass(midi)]}${octave}`;
22
- }
1
+ import {
2
+ noteNameToIndex
3
+ } from "./chunk-25RUSM2X.js";
23
4
 
24
5
  // src/scales.ts
25
6
  var MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11];
@@ -57,11 +38,6 @@ function useFlatsForKeyFifths(fifths) {
57
38
  }
58
39
 
59
40
  export {
60
- NOTE_NAMES,
61
- NOTE_NAMES_FLAT,
62
- noteNameToIndex,
63
- pitchClass,
64
- midiToNoteName,
65
41
  MAJOR_SCALE_INTERVALS,
66
42
  ALL_KEYS,
67
43
  getMidiNote,
@@ -72,4 +48,4 @@ export {
72
48
  useFlatsForKeyName,
73
49
  useFlatsForKeyFifths
74
50
  };
75
- //# sourceMappingURL=chunk-LLMDQM4C.js.map
51
+ //# sourceMappingURL=chunk-5ZK4HVY4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/scales.ts","../src/enharmonic.ts"],"sourcesContent":["import { noteNameToIndex } from './notes';\n\nexport const MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11] as const;\nexport const ALL_KEYS = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'F', 'Bb', 'Eb', 'Ab', 'Db'] as const;\n\n/**\n * Get MIDI note number for a scale degree in a given key.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n * NOTE: RET uses octave*12 (not (octave+1)*12), so getMidiNote(1,'C',4)=48,\n * not 60. This is RET's established convention — consumers must account for it.\n */\nexport function getMidiNote(degree: number, key: string, octave = 4): number {\n const rootIndex = noteNameToIndex(key);\n const actualDegree = ((degree - 1) % 7 + 7) % 7;\n const octaveShift = Math.floor((degree - 1) / 7);\n const semitones = MAJOR_SCALE_INTERVALS[actualDegree];\n return rootIndex + semitones + ((octave + octaveShift) * 12);\n}\n\n/**\n * Get the scale degree (1-7) for a MIDI note in a key, or null if not in scale.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getScaleDegree(midiNote: number, key: string): number | null {\n const rootIndex = noteNameToIndex(key);\n const noteInOctave = ((midiNote % 12) - rootIndex + 12) % 12;\n const degreeIndex = (MAJOR_SCALE_INTERVALS as readonly number[]).indexOf(noteInOctave);\n return degreeIndex !== -1 ? degreeIndex + 1 : null;\n}\n\n/**\n * Check if a MIDI note is in the given key.\n */\nexport function isInScale(midiNote: number, key: string): boolean {\n return getScaleDegree(midiNote, key) !== null;\n}\n\n/**\n * Get stability category for a scale degree.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getStability(degree: number): 'stable' | 'lessStable' | 'unstable' | null {\n if ([1, 3, 5].includes(degree)) return 'stable';\n if ([2, 4, 6].includes(degree)) return 'lessStable';\n if (degree === 7) return 'unstable';\n return null;\n}\n","/** Keys conventionally spelled with flats (RET's key-name model). */\nexport const KEYS_PREFER_FLATS = ['F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb'] as const;\n\n/** Whether to use flat spellings for a key given by name (e.g. \"Eb\"). */\nexport function useFlatsForKeyName(key: string): boolean {\n return (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n}\n\n/**\n * Whether to use flat spellings for a key given by its key-signature \"fifths\"\n * value (Stave's model: -7..+7, negative = flat keys). 0 (C) and positive\n * (sharp keys) use sharps.\n */\nexport function useFlatsForKeyFifths(fifths: number): boolean {\n return fifths < 0;\n}\n"],"mappings":";;;;;AAEO,IAAM,wBAAwB,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE;AACnD,IAAM,WAAW,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,MAAM,KAAK,MAAM,MAAM,MAAM,IAAI;AAQjF,SAAS,YAAY,QAAgB,KAAa,SAAS,GAAW;AAC3E,QAAM,YAAY,gBAAgB,GAAG;AACrC,QAAM,iBAAiB,SAAS,KAAK,IAAI,KAAK;AAC9C,QAAM,cAAc,KAAK,OAAO,SAAS,KAAK,CAAC;AAC/C,QAAM,YAAY,sBAAsB,YAAY;AACpD,SAAO,YAAY,aAAc,SAAS,eAAe;AAC3D;AAMO,SAAS,eAAe,UAAkB,KAA4B;AAC3E,QAAM,YAAY,gBAAgB,GAAG;AACrC,QAAM,gBAAiB,WAAW,KAAM,YAAY,MAAM;AAC1D,QAAM,cAAe,sBAA4C,QAAQ,YAAY;AACrF,SAAO,gBAAgB,KAAK,cAAc,IAAI;AAChD;AAKO,SAAS,UAAU,UAAkB,KAAsB;AAChE,SAAO,eAAe,UAAU,GAAG,MAAM;AAC3C;AAMO,SAAS,aAAa,QAA6D;AACxF,MAAI,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,MAAM,EAAG,QAAO;AACvC,MAAI,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,MAAM,EAAG,QAAO;AACvC,MAAI,WAAW,EAAG,QAAO;AACzB,SAAO;AACT;;;AC7CO,IAAM,oBAAoB,CAAC,KAAK,MAAM,MAAM,MAAM,MAAM,IAAI;AAG5D,SAAS,mBAAmB,KAAsB;AACvD,SAAQ,kBAAwC,SAAS,GAAG;AAC9D;AAOO,SAAS,qBAAqB,QAAyB;AAC5D,SAAO,SAAS;AAClB;","names":[]}
@@ -0,0 +1,24 @@
1
+ // src/intervals.ts
2
+ var INTERVALS = [
3
+ { semitones: 1, label: "m2", mnemonic: "Jaws theme" },
4
+ { semitones: 2, label: "M2", mnemonic: "Happy Birthday opening" },
5
+ { semitones: 3, label: "m3", mnemonic: "Brahms' Lullaby" },
6
+ { semitones: 4, label: "M3", mnemonic: "When the Saints" },
7
+ { semitones: 5, label: "P4", mnemonic: "Here Comes the Bride" },
8
+ { semitones: 6, label: "TT", mnemonic: "The Simpsons theme" },
9
+ { semitones: 7, label: "P5", mnemonic: "Twinkle Twinkle" },
10
+ { semitones: 8, label: "m6", mnemonic: "Love Story theme" },
11
+ { semitones: 9, label: "M6", mnemonic: "NBC chimes" },
12
+ { semitones: 10, label: "m7", mnemonic: "Somewhere (West Side Story)" },
13
+ { semitones: 11, label: "M7", mnemonic: "Take On Me chorus" },
14
+ { semitones: 12, label: "P8", mnemonic: "Somewhere Over the Rainbow" }
15
+ ];
16
+ function intervalBySemitones(semitones) {
17
+ return INTERVALS.find((i) => i.semitones === semitones);
18
+ }
19
+
20
+ export {
21
+ INTERVALS,
22
+ intervalBySemitones
23
+ };
24
+ //# sourceMappingURL=chunk-N56UTMWA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/intervals.ts"],"sourcesContent":["export interface IntervalDef { semitones: number; label: string; mnemonic: string }\n\nexport const INTERVALS: IntervalDef[] = [\n { semitones: 1, label: 'm2', mnemonic: 'Jaws theme' },\n { semitones: 2, label: 'M2', mnemonic: 'Happy Birthday opening' },\n { semitones: 3, label: 'm3', mnemonic: \"Brahms' Lullaby\" },\n { semitones: 4, label: 'M3', mnemonic: 'When the Saints' },\n { semitones: 5, label: 'P4', mnemonic: 'Here Comes the Bride' },\n { semitones: 6, label: 'TT', mnemonic: 'The Simpsons theme' },\n { semitones: 7, label: 'P5', mnemonic: 'Twinkle Twinkle' },\n { semitones: 8, label: 'm6', mnemonic: 'Love Story theme' },\n { semitones: 9, label: 'M6', mnemonic: 'NBC chimes' },\n { semitones: 10, label: 'm7', mnemonic: 'Somewhere (West Side Story)' },\n { semitones: 11, label: 'M7', mnemonic: 'Take On Me chorus' },\n { semitones: 12, label: 'P8', mnemonic: 'Somewhere Over the Rainbow' },\n];\n\nexport function intervalBySemitones(semitones: number): IntervalDef | undefined {\n return INTERVALS.find(i => i.semitones === semitones);\n}\n"],"mappings":";AAEO,IAAM,YAA2B;AAAA,EACtC,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,yBAAyB;AAAA,EACjE,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,uBAAuB;AAAA,EAC/D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,qBAAqB;AAAA,EAC7D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,mBAAmB;AAAA,EAC3D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,8BAA8B;AAAA,EACtE,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,oBAAoB;AAAA,EAC5D,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,6BAA6B;AACvE;AAEO,SAAS,oBAAoB,WAA4C;AAC9E,SAAO,UAAU,KAAK,OAAK,EAAE,cAAc,SAAS;AACtD;","names":[]}
package/dist/index.js CHANGED
@@ -2,43 +2,30 @@ import {
2
2
  ALL_KEYS,
3
3
  KEYS_PREFER_FLATS,
4
4
  MAJOR_SCALE_INTERVALS,
5
- NOTE_NAMES,
6
- NOTE_NAMES_FLAT,
7
5
  getMidiNote,
8
6
  getScaleDegree,
9
7
  getStability,
10
8
  isInScale,
11
- midiToNoteName,
12
- noteNameToIndex,
13
- pitchClass,
14
9
  useFlatsForKeyFifths,
15
10
  useFlatsForKeyName
16
- } from "./chunk-LLMDQM4C.js";
11
+ } from "./chunk-5ZK4HVY4.js";
12
+ import {
13
+ INTERVALS,
14
+ intervalBySemitones
15
+ } from "./chunk-N56UTMWA.js";
16
+ import {
17
+ NOTE_NAMES,
18
+ NOTE_NAMES_FLAT,
19
+ midiToNoteName,
20
+ noteNameToIndex,
21
+ pitchClass
22
+ } from "./chunk-25RUSM2X.js";
17
23
 
18
24
  // src/frequency.ts
19
25
  function midiToFrequency(midi) {
20
26
  return 440 * Math.pow(2, (midi - 69) / 12);
21
27
  }
22
28
 
23
- // src/intervals.ts
24
- var INTERVALS = [
25
- { semitones: 1, label: "m2", mnemonic: "Jaws theme" },
26
- { semitones: 2, label: "M2", mnemonic: "Happy Birthday opening" },
27
- { semitones: 3, label: "m3", mnemonic: "Brahms' Lullaby" },
28
- { semitones: 4, label: "M3", mnemonic: "When the Saints" },
29
- { semitones: 5, label: "P4", mnemonic: "Here Comes the Bride" },
30
- { semitones: 6, label: "TT", mnemonic: "The Simpsons theme" },
31
- { semitones: 7, label: "P5", mnemonic: "Twinkle Twinkle" },
32
- { semitones: 8, label: "m6", mnemonic: "Love Story theme" },
33
- { semitones: 9, label: "M6", mnemonic: "NBC chimes" },
34
- { semitones: 10, label: "m7", mnemonic: "Somewhere (West Side Story)" },
35
- { semitones: 11, label: "M7", mnemonic: "Take On Me chorus" },
36
- { semitones: 12, label: "P8", mnemonic: "Somewhere Over the Rainbow" }
37
- ];
38
- function intervalBySemitones(semitones) {
39
- return INTERVALS.find((i) => i.semitones === semitones);
40
- }
41
-
42
29
  // src/chords.ts
43
30
  var CHORD_TEMPLATES = [
44
31
  { name: "maj7", intervals: [0, 4, 7, 11] },
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/frequency.ts","../src/intervals.ts","../src/chords.ts"],"sourcesContent":["/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","export interface IntervalDef { semitones: number; label: string; mnemonic: string }\n\nexport const INTERVALS: IntervalDef[] = [\n { semitones: 1, label: 'm2', mnemonic: 'Jaws theme' },\n { semitones: 2, label: 'M2', mnemonic: 'Happy Birthday opening' },\n { semitones: 3, label: 'm3', mnemonic: \"Brahms' Lullaby\" },\n { semitones: 4, label: 'M3', mnemonic: 'When the Saints' },\n { semitones: 5, label: 'P4', mnemonic: 'Here Comes the Bride' },\n { semitones: 6, label: 'TT', mnemonic: 'The Simpsons theme' },\n { semitones: 7, label: 'P5', mnemonic: 'Twinkle Twinkle' },\n { semitones: 8, label: 'm6', mnemonic: 'Love Story theme' },\n { semitones: 9, label: 'M6', mnemonic: 'NBC chimes' },\n { semitones: 10, label: 'm7', mnemonic: 'Somewhere (West Side Story)' },\n { semitones: 11, label: 'M7', mnemonic: 'Take On Me chorus' },\n { semitones: 12, label: 'P8', mnemonic: 'Somewhere Over the Rainbow' },\n];\n\nexport function intervalBySemitones(semitones: number): IntervalDef | undefined {\n return INTERVALS.find(i => i.semitones === semitones);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;AACO,SAAS,gBAAgB,MAAsB;AACpD,SAAO,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,EAAE;AAC3C;;;ACDO,IAAM,YAA2B;AAAA,EACtC,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,yBAAyB;AAAA,EACjE,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,uBAAuB;AAAA,EAC/D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,qBAAqB;AAAA,EAC7D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,mBAAmB;AAAA,EAC3D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,8BAA8B;AAAA,EACtE,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,oBAAoB;AAAA,EAC5D,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,6BAA6B;AACvE;AAEO,SAAS,oBAAoB,WAA4C;AAC9E,SAAO,UAAU,KAAK,OAAK,EAAE,cAAc,SAAS;AACtD;;;ACfO,IAAM,kBAAmC;AAAA,EAC9C,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,MAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,CAAC,EAAE;AAAA,EACxC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,IAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AACvC;","names":[]}
1
+ {"version":3,"sources":["../src/frequency.ts","../src/chords.ts"],"sourcesContent":["/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AACO,SAAS,gBAAgB,MAAsB;AACpD,SAAO,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,EAAE;AAC3C;;;ACCO,IAAM,kBAAmC;AAAA,EAC9C,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,MAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,CAAC,EAAE;AAAA,EACxC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,IAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AACvC;","names":[]}
@@ -1628,4 +1628,219 @@ interface SectionMinimapProps {
1628
1628
  }
1629
1629
  declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
1630
1630
 
1631
- export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type PromoCardsDemoOpts, type Quiz, type QuizOption, type QuizPhase, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, type SafeGuidesProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctMeasureIndices, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, invLerp, isBlackKey, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, measureSystemMap, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, projectPoint, promoCardsDemoSpec, quizPhase, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, systemBox, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, vstackAudioPlayheadLine, vstackFollowBox, whiteKeys, worldToViewport };
1631
+ type PulseStyle = 'bloom' | 'vignette';
1632
+ interface BeatPulseProps {
1633
+ /** Override onset times (ms). Default = distinct onsets of ctx.score.notes. */
1634
+ onsetsMs?: number[];
1635
+ /** Which renderings to draw. Default ["bloom"]. */
1636
+ styles?: PulseStyle[];
1637
+ /** Max overlay alpha at a fresh onset. Default 0.42 (subtle). */
1638
+ intensity?: number;
1639
+ /** Attack ramp to full, ms. Default 0 (snap to full on the onset). */
1640
+ attackMs?: number;
1641
+ /** Exponential decay time constant, ms. Default 170. */
1642
+ decayMs?: number;
1643
+ /** Overlay colour. Default theme.accent. */
1644
+ color?: string;
1645
+ /**
1646
+ * Canvas blend for the overlay. Default "source-over" (a tinted bloom that
1647
+ * reads on LIGHT backgrounds — most of our promos are paper/cream). Use
1648
+ * "lighter" (additive glow) on DARK backgrounds where it would otherwise just
1649
+ * wash to white.
1650
+ */
1651
+ blend?: 'source-over' | 'lighter';
1652
+ }
1653
+ /**
1654
+ * Pulse value at t = max envelope over the few most recent onsets. Pure. Exported
1655
+ * so other layers / tests can read the same heartbeat the visual draws.
1656
+ */
1657
+ declare function pulseAt(onsetsMs: number[], tMs: number, attackMs?: number, decayMs?: number): number;
1658
+ declare const beatPulseFactory: LayerFactory<BeatPulseProps>;
1659
+
1660
+ interface ChordRibbonProps {
1661
+ /** REQUIRED: timed chord spans (function + display label). Host-supplied. */
1662
+ chordTrack: ChordSpan[];
1663
+ /** Horizontal time scale, px per ms. Default 0.06 (60 px/s). */
1664
+ pxPerMs?: number;
1665
+ /** Where "now" sits, as a fraction of the safe width. Default 0.32. */
1666
+ playheadFrac?: number;
1667
+ /** Chip baseline (world y / screen y, since pinned). Default safeBox.bottom - 260. */
1668
+ y?: number;
1669
+ /** Chip height (px). Default 84. */
1670
+ height?: number;
1671
+ /** Tint chips by tonal function. Default true. */
1672
+ showFunction?: boolean;
1673
+ /** Override the function→colour map. */
1674
+ colors?: FunctionColors;
1675
+ }
1676
+ declare const chordRibbonFactory: LayerFactory<ChordRibbonProps>;
1677
+
1678
+ interface ProgressRingProps {
1679
+ /** Total length, ms. Default ctx.score.durationMs. Required if no score. */
1680
+ totalMs?: number;
1681
+ /** Ring radius (px). Default 46. */
1682
+ radius?: number;
1683
+ /** Ring stroke thickness (px). Default 9. */
1684
+ thickness?: number;
1685
+ /** Centre x (px). Default safeBox.right - radius - 16. */
1686
+ cx?: number;
1687
+ /** Centre y (px). Default safeBox.top + radius + 16. */
1688
+ cy?: number;
1689
+ /** Filled-arc colour. Default theme.gold. */
1690
+ color?: string;
1691
+ /** Unfilled track colour. Default a faint paper tint. */
1692
+ trackColor?: string;
1693
+ /** Draw seconds-remaining in the centre. Default false. */
1694
+ showCountdown?: boolean;
1695
+ }
1696
+ declare const progressRingFactory: LayerFactory<ProgressRingProps>;
1697
+
1698
+ interface RadialSpectrumProps {
1699
+ /** Number of bars around the ring. Default 64. */
1700
+ bars?: number;
1701
+ /** Center x as a fraction of W. Default 0.5. */
1702
+ cxFrac?: number;
1703
+ /** Center y as a fraction of H. Default 0.42. */
1704
+ cyFrac?: number;
1705
+ /** Inner radius (bar roots) as a fraction of min(W,H). Default 0.20. */
1706
+ innerFrac?: number;
1707
+ /** Max bar length as a fraction of min(W,H). Default 0.13. */
1708
+ maxLenFrac?: number;
1709
+ /** Bar fill at low magnitude. Default theme.accent. */
1710
+ colorLow?: string;
1711
+ /** Bar fill at high magnitude. Default theme.gold. */
1712
+ colorHigh?: string;
1713
+ /** Per-frame level provider (overrides ctx.spectrum). Pure fn of t for tests. */
1714
+ levelsFn?: (tMs: number, bands: number) => Float32Array | number[] | null;
1715
+ }
1716
+ declare const radialSpectrumFactory: LayerFactory<RadialSpectrumProps>;
1717
+
1718
+ interface KaraokeWord {
1719
+ /** The word (no surrounding spaces). */
1720
+ text: string;
1721
+ /** Time this word lights up, ms. */
1722
+ atMs: number;
1723
+ }
1724
+ interface KaraokeCaptionProps {
1725
+ /** REQUIRED: timed words, ascending by atMs. */
1726
+ words: KaraokeWord[];
1727
+ /** Vertical center as a fraction of H. Default 0.5. */
1728
+ centerFrac?: number;
1729
+ /** Font px. Default 64. */
1730
+ fontPx?: number;
1731
+ /** Colour of already-sung words. Default theme.ink. */
1732
+ sungColor?: string;
1733
+ /** Colour of not-yet-sung words. Default a faint ink. */
1734
+ upcomingColor?: string;
1735
+ /** Highlight colour of the word that just lit. Default theme.accent. */
1736
+ activeColor?: string;
1737
+ /** ms a freshly-lit word stays in the active highlight colour. Default 320. */
1738
+ activeHoldMs?: number;
1739
+ }
1740
+ declare const karaokeCaptionFactory: LayerFactory<KaraokeCaptionProps>;
1741
+
1742
+ interface IntervalArcsProps {
1743
+ /** Plot band top (world y). Default safeBox.top + safeBox.h * 0.18. */
1744
+ top?: number;
1745
+ /** Plot band height (world px). Default safeBox.h * 0.34. */
1746
+ height?: number;
1747
+ /** Arc colour for an ascending interval. Default theme.accent. */
1748
+ upColor?: string;
1749
+ /** Arc colour for a descending interval. Default theme.sepia. */
1750
+ downColor?: string;
1751
+ /** Draw the interval label on each arc. Default true. */
1752
+ showLabels?: boolean;
1753
+ /** Label font px. Default 30. */
1754
+ fontPx?: number;
1755
+ /** Note-dot radius (world px). Default 9. */
1756
+ dotRadius?: number;
1757
+ }
1758
+ declare const intervalArcsFactory: LayerFactory<IntervalArcsProps>;
1759
+
1760
+ type KineticMode = 'typewriter' | 'word-pop';
1761
+ interface KineticTextProps {
1762
+ /** REQUIRED: the text. "\n" forces a line break; otherwise wraps to safe width. */
1763
+ text: string;
1764
+ /** Animation style. Default "word-pop". */
1765
+ mode?: KineticMode;
1766
+ /** When the animation starts (ms). Default 0. */
1767
+ startMs?: number;
1768
+ /** Time to fully reveal (ms). Default 1400. */
1769
+ revealMs?: number;
1770
+ /** Font px. Default 88. */
1771
+ fontPx?: number;
1772
+ /** Text colour. Default theme.ink. */
1773
+ color?: string;
1774
+ /** Vertical center as a fraction of H. Default 0.42. */
1775
+ centerFrac?: number;
1776
+ /** Typewriter blinking cursor. Default true (ignored in word-pop). */
1777
+ cursor?: boolean;
1778
+ }
1779
+ declare const kineticTextFactory: LayerFactory<KineticTextProps>;
1780
+
1781
+ interface CountdownProps {
1782
+ /** Count from this number down to 1. Default 3. */
1783
+ from?: number;
1784
+ /** When the countdown starts (ms). Default 0. */
1785
+ startMs?: number;
1786
+ /** Total countdown window (ms); each number shows durationMs/from. Default 3000. */
1787
+ durationMs?: number;
1788
+ /** Number colour. Default theme.accent. */
1789
+ color?: string;
1790
+ /** Number font px. Default 280. */
1791
+ fontPx?: number;
1792
+ /** Vertical center as a fraction of H. Default 0.46. */
1793
+ centerFrac?: number;
1794
+ /** Draw a draining ring around the number. Default true. */
1795
+ ring?: boolean;
1796
+ }
1797
+ declare const countdownFactory: LayerFactory<CountdownProps>;
1798
+
1799
+ /** Host-provided time-domain sample source (AnalyserNode shape). */
1800
+ interface WaveformInput {
1801
+ /** 0..255 samples centered ~128 (AnalyserNode.getByteTimeDomainData). */
1802
+ byteTime?(tMs: number): Uint8Array | null | undefined;
1803
+ }
1804
+ declare module '../layer' {
1805
+ interface RenderCtx {
1806
+ /** Optional time-domain source for the waveform layer (host-wired). */
1807
+ waveform?: WaveformInput;
1808
+ }
1809
+ }
1810
+ interface WaveformProps {
1811
+ /** Number of samples plotted. Default 128. */
1812
+ samples?: number;
1813
+ /** Vertical center as a fraction of H. Default 0.46. */
1814
+ centerFrac?: number;
1815
+ /** Max deflection as a fraction of H. Default 0.10. */
1816
+ amplitudeFrac?: number;
1817
+ /** Line width px. Default 5. */
1818
+ lineWidth?: number;
1819
+ /** Line colour. Default theme.accent. */
1820
+ color?: string;
1821
+ /** Per-frame sample provider returning -1..1 (overrides ctx.waveform). */
1822
+ samplesFn?: (tMs: number, n: number) => Float32Array | number[] | null;
1823
+ }
1824
+ declare const waveformFactory: LayerFactory<WaveformProps>;
1825
+
1826
+ /** Semitone interval sets (from the root) for the supported scales/modes. */
1827
+ declare const SCALE_INTERVALS: Record<string, number[]>;
1828
+ interface ScaleHighlightProps {
1829
+ /** Tonic — note name ('C','D','Bb'…) or pitch-class number 0..11. Default 'C'. */
1830
+ root?: string | number;
1831
+ /** Scale/mode key (see SCALE_INTERVALS). Default 'major'. */
1832
+ scale?: string;
1833
+ /** Colour for in-scale keys. Default theme.accent. */
1834
+ scaleColor?: string;
1835
+ /** Colour for the tonic key. Default theme.gold. */
1836
+ tonicColor?: string;
1837
+ /** Tint opacity over the key. Default 0.55. */
1838
+ alpha?: number;
1839
+ /** Draw the scale name label. Default true. */
1840
+ label?: boolean;
1841
+ /** Override the label text. Default `${root} ${PrettyScale}`. */
1842
+ labelText?: string;
1843
+ }
1844
+ declare const scaleHighlightFactory: LayerFactory<ScaleHighlightProps>;
1845
+
1846
+ export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatPulseProps, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordRibbonProps, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountdownProps, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type IntervalArcsProps, type KaraokeCaptionProps, type KaraokeWord, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type KineticMode, type KineticTextProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type ProgressRingProps, type PromoCardsDemoOpts, type PulseStyle, type Quiz, type QuizOption, type QuizPhase, type RadialSpectrumProps, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, SCALE_INTERVALS, type SafeGuidesProps, type ScaleHighlightProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, type WaveformInput, type WaveformProps, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, beatPulseFactory, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, chordRibbonFactory, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownFactory, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctMeasureIndices, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, intervalArcsFactory, invLerp, isBlackKey, karaokeCaptionFactory, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, kineticTextFactory, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, measureSystemMap, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, progressRingFactory, projectPoint, promoCardsDemoSpec, pulseAt, quizPhase, radialSpectrumFactory, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scaleHighlightFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, systemBox, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, vstackAudioPlayheadLine, vstackFollowBox, waveformFactory, whiteKeys, worldToViewport };