@real-music-packages/web-core 0.12.0 → 0.13.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/dist/audio.d.ts +36 -0
- package/dist/audio.js +55 -0
- package/dist/audio.js.map +1 -1
- package/dist/scene/index.d.ts +147 -78
- package/dist/scene/index.js +114 -91
- package/dist/scene/index.js.map +1 -1
- package/package.json +1 -1
package/dist/audio.d.ts
CHANGED
|
@@ -132,6 +132,42 @@ declare class AudioEngine {
|
|
|
132
132
|
* Check if audio is ready
|
|
133
133
|
*/
|
|
134
134
|
get isReady(): boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Current audio-clock time in SECONDS (`Tone.now()`), or 0 before init.
|
|
137
|
+
*
|
|
138
|
+
* The scheduling seam for web-core's per-segment audio hook
|
|
139
|
+
* (`@real-music-packages/web-core/scene` → `recordSceneSpec`'s `segmentAudio`
|
|
140
|
+
* / `BuiltScene.scheduleAudio`). Pair it with `instrument` so an
|
|
141
|
+
* AudioEngine-driven app (RET) can satisfy `segmentAudio` exactly like a
|
|
142
|
+
* raw promo sampler does — `{ instrument: engine.instrument, audioNow: () => engine.audioNow() }`.
|
|
143
|
+
*/
|
|
144
|
+
audioNow(): number;
|
|
145
|
+
/**
|
|
146
|
+
* A `ScheduleTarget`-shaped view of the active piano voice: an object whose
|
|
147
|
+
* `triggerAttackRelease(note, dur, time?, velocity?)` forwards to the current
|
|
148
|
+
* voice (synth, upgrading to the Salamander sampler when it loads). Unlike
|
|
149
|
+
* `playNote`/`playChord` — which hard-code `Tone.now()` — this accepts an
|
|
150
|
+
* explicit absolute `time`, which is what offline/deterministic capture needs.
|
|
151
|
+
*
|
|
152
|
+
* This is the seam web-core's runner consumes: `segmentAudio.instrument =
|
|
153
|
+
* engine.instrument`. The declarative `SegmentAudio.schedule` path drives this
|
|
154
|
+
* via `applySchedule`; the imperative `onEnter` path can call it directly, or
|
|
155
|
+
* use the `scheduleNoteAt` / `scheduleChordAt` convenience wrappers below.
|
|
156
|
+
*/
|
|
157
|
+
get instrument(): {
|
|
158
|
+
triggerAttackRelease: (note: unknown, dur: unknown, time?: unknown, velocity?: unknown) => void;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Schedule a single MIDI note to sound at an explicit absolute audio-clock
|
|
162
|
+
* time (seconds). The capture-clock counterpart of `playNote` — use it inside
|
|
163
|
+
* a segment's `audio.onEnter` (where you're handed the segment's `startSec`).
|
|
164
|
+
*/
|
|
165
|
+
scheduleNoteAt(midiNote: number, atSec: number, durSec?: number, velocity?: number): void;
|
|
166
|
+
/**
|
|
167
|
+
* Schedule a chord (list of MIDI notes) to sound at an explicit absolute
|
|
168
|
+
* audio-clock time (seconds). The capture-clock counterpart of `playChord`.
|
|
169
|
+
*/
|
|
170
|
+
scheduleChordAt(midiNotes: number[], atSec: number, durSec?: number, velocity?: number): void;
|
|
135
171
|
/** A MediaStream of the engine's full output, tapped additively (speakers keep
|
|
136
172
|
* playing). Returns null until init() has created the audio context. For the
|
|
137
173
|
* promo capture only — not part of normal playback. */
|
package/dist/audio.js
CHANGED
|
@@ -337,6 +337,61 @@ var AudioEngine = class {
|
|
|
337
337
|
get isReady() {
|
|
338
338
|
return this.isInitialized;
|
|
339
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Current audio-clock time in SECONDS (`Tone.now()`), or 0 before init.
|
|
342
|
+
*
|
|
343
|
+
* The scheduling seam for web-core's per-segment audio hook
|
|
344
|
+
* (`@real-music-packages/web-core/scene` → `recordSceneSpec`'s `segmentAudio`
|
|
345
|
+
* / `BuiltScene.scheduleAudio`). Pair it with `instrument` so an
|
|
346
|
+
* AudioEngine-driven app (RET) can satisfy `segmentAudio` exactly like a
|
|
347
|
+
* raw promo sampler does — `{ instrument: engine.instrument, audioNow: () => engine.audioNow() }`.
|
|
348
|
+
*/
|
|
349
|
+
audioNow() {
|
|
350
|
+
return this.Tone ? this.Tone.now() : 0;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A `ScheduleTarget`-shaped view of the active piano voice: an object whose
|
|
354
|
+
* `triggerAttackRelease(note, dur, time?, velocity?)` forwards to the current
|
|
355
|
+
* voice (synth, upgrading to the Salamander sampler when it loads). Unlike
|
|
356
|
+
* `playNote`/`playChord` — which hard-code `Tone.now()` — this accepts an
|
|
357
|
+
* explicit absolute `time`, which is what offline/deterministic capture needs.
|
|
358
|
+
*
|
|
359
|
+
* This is the seam web-core's runner consumes: `segmentAudio.instrument =
|
|
360
|
+
* engine.instrument`. The declarative `SegmentAudio.schedule` path drives this
|
|
361
|
+
* via `applySchedule`; the imperative `onEnter` path can call it directly, or
|
|
362
|
+
* use the `scheduleNoteAt` / `scheduleChordAt` convenience wrappers below.
|
|
363
|
+
*/
|
|
364
|
+
get instrument() {
|
|
365
|
+
return {
|
|
366
|
+
triggerAttackRelease: (note, dur, time, velocity) => {
|
|
367
|
+
this.piano?.triggerAttackRelease(
|
|
368
|
+
note,
|
|
369
|
+
dur,
|
|
370
|
+
time,
|
|
371
|
+
velocity
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Schedule a single MIDI note to sound at an explicit absolute audio-clock
|
|
378
|
+
* time (seconds). The capture-clock counterpart of `playNote` — use it inside
|
|
379
|
+
* a segment's `audio.onEnter` (where you're handed the segment's `startSec`).
|
|
380
|
+
*/
|
|
381
|
+
scheduleNoteAt(midiNote, atSec, durSec = 0.5, velocity = 0.8) {
|
|
382
|
+
if (!this.piano) return;
|
|
383
|
+
this.piano.triggerAttackRelease(midiToNoteName(midiNote), durSec, atSec, velocity);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Schedule a chord (list of MIDI notes) to sound at an explicit absolute
|
|
387
|
+
* audio-clock time (seconds). The capture-clock counterpart of `playChord`.
|
|
388
|
+
*/
|
|
389
|
+
scheduleChordAt(midiNotes, atSec, durSec = 1.5, velocity = 0.7) {
|
|
390
|
+
if (!this.piano) return;
|
|
391
|
+
for (const m of midiNotes) {
|
|
392
|
+
this.piano.triggerAttackRelease(midiToNoteName(m), durSec, atSec, velocity);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
340
395
|
/** A MediaStream of the engine's full output, tapped additively (speakers keep
|
|
341
396
|
* playing). Returns null until init() has created the audio context. For the
|
|
342
397
|
* promo capture only — not part of normal playback. */
|
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/** 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":"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","names":[]}
|
package/dist/scene/index.d.ts
CHANGED
|
@@ -140,6 +140,83 @@ interface LayerFactory<P = unknown> {
|
|
|
140
140
|
validateProps(props: unknown): string[];
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** One scheduled audio event, in seconds RELATIVE to the schedule start. */
|
|
144
|
+
interface AudioEvent {
|
|
145
|
+
/** Offset from schedule start, seconds. */
|
|
146
|
+
atSec: number;
|
|
147
|
+
/** Pitch (note name or frequency) for tonal hits; omitted for noise clicks. */
|
|
148
|
+
note?: string | number;
|
|
149
|
+
/** Duration, seconds. Default short. */
|
|
150
|
+
durSec?: number;
|
|
151
|
+
/** Linear gain 0..1. Default 1. */
|
|
152
|
+
gain?: number;
|
|
153
|
+
/** Tag for tests/debugging: "count" | "click" | "drone". */
|
|
154
|
+
kind: 'count' | 'click' | 'drone';
|
|
155
|
+
}
|
|
156
|
+
interface CountInOpts {
|
|
157
|
+
/** Beats to count (default 4 — a full bar of 4/4). */
|
|
158
|
+
beats?: number;
|
|
159
|
+
/** Tempo for the count, bpm. */
|
|
160
|
+
bpm: number;
|
|
161
|
+
/** Accent the downbeat (beat 1) with a higher pitch. Default true. */
|
|
162
|
+
accentDownbeat?: boolean;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The count-in schedule: one click per beat, starting at t=0, ending exactly on
|
|
166
|
+
* the downbeat of bar 1 (i.e. the music starts at `beats * 60/bpm` seconds).
|
|
167
|
+
* Pure.
|
|
168
|
+
*/
|
|
169
|
+
declare function countInSchedule(opts: CountInOpts): AudioEvent[];
|
|
170
|
+
/** Seconds after t=0 that the music should start, for a given count-in. */
|
|
171
|
+
declare function countInLeadSec(opts: CountInOpts): number;
|
|
172
|
+
interface ClickTrackOpts {
|
|
173
|
+
bpm: number;
|
|
174
|
+
/** Total duration to fill with clicks, seconds. */
|
|
175
|
+
durationSec: number;
|
|
176
|
+
/** Beats per bar, for downbeat accents. Default 4. */
|
|
177
|
+
beatsPerBar?: number;
|
|
178
|
+
/** Start offset, seconds. Default 0. */
|
|
179
|
+
startSec?: number;
|
|
180
|
+
}
|
|
181
|
+
/** A click on every beat across `durationSec`, accenting each bar's downbeat. */
|
|
182
|
+
declare function clickTrackSchedule(opts: ClickTrackOpts): AudioEvent[];
|
|
183
|
+
interface DroneOpts {
|
|
184
|
+
/** Root pitch of the pad (e.g. "C2"). */
|
|
185
|
+
root: string;
|
|
186
|
+
/** Pad duration, seconds. */
|
|
187
|
+
durationSec: number;
|
|
188
|
+
/** Add a perfect fifth above the root. Default true. */
|
|
189
|
+
fifth?: boolean;
|
|
190
|
+
/** Bed gain 0..1. Default 0.15 (sits well under the melody). */
|
|
191
|
+
gain?: number;
|
|
192
|
+
}
|
|
193
|
+
/** A sustained root (+fifth) pad for the whole clip. */
|
|
194
|
+
declare function droneSchedule(opts: DroneOpts): AudioEvent[];
|
|
195
|
+
interface DuckWindow {
|
|
196
|
+
/** Voice-over window start, seconds. */
|
|
197
|
+
startSec: number;
|
|
198
|
+
/** Voice-over window end, seconds. */
|
|
199
|
+
endSec: number;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Gain envelope for a music/bed bus that ducks under voice-over windows. Returns
|
|
203
|
+
* the linear gain 0..1 at time `tSec`: `floor` inside a window (after a short
|
|
204
|
+
* attack ramp), 1 outside (after a release ramp). Pure — feed it to a Tone
|
|
205
|
+
* Gain's `.gain.value` per frame, or sample it to build a ramp automation.
|
|
206
|
+
*/
|
|
207
|
+
declare function duckGainAt(windows: DuckWindow[], tSec: number, floor?: number, rampSec?: number): number;
|
|
208
|
+
/**
|
|
209
|
+
* Trigger a computed schedule on a Tone instrument at `startTime` (audio-clock
|
|
210
|
+
* seconds, e.g. `Tone.now()` or sampler.audioNow()). Thin glue over the existing
|
|
211
|
+
* sampler/synth — no new mastering bus. Tonal events use triggerAttackRelease;
|
|
212
|
+
* the instrument is whatever the caller wires into the promo mastering chain.
|
|
213
|
+
*
|
|
214
|
+
* Returns the number of events fired (handy for tests/gate sanity).
|
|
215
|
+
*/
|
|
216
|
+
declare function applySchedule(instrument: {
|
|
217
|
+
triggerAttackRelease: (note: any, dur: any, time?: any, velocity?: any) => void;
|
|
218
|
+
}, schedule: AudioEvent[], startTime: number): number;
|
|
219
|
+
|
|
143
220
|
declare const clamp: (x: number, lo: number, hi: number) => number;
|
|
144
221
|
declare const lerp: (a: number, b: number, t: number) => number;
|
|
145
222
|
/** Inverse-lerp: where does `x` sit in [a,b] as 0..1 (clamped, a===b -> 0). */
|
|
@@ -209,10 +286,52 @@ interface SpecLayer {
|
|
|
209
286
|
/** Props passed to the layer's init(). */
|
|
210
287
|
p?: unknown;
|
|
211
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Optional per-segment audio. Lets a segment SCHEDULE sound at its own start
|
|
291
|
+
* time during capture — the seam that lets per-scene / sequential-audio apps
|
|
292
|
+
* (RET templates schedule `engine.playNote` in a scene's `onEnter`; whozart's
|
|
293
|
+
* "listen" scenes play a phrase when the scene begins) run on the same runner
|
|
294
|
+
* instead of staying bespoke. Everything here is OPTIONAL and the runner stays
|
|
295
|
+
* pure-visual when it's absent (fully backward-compatible).
|
|
296
|
+
*
|
|
297
|
+
* Two complementary forms, both resolved against the CAPTURE clock (not
|
|
298
|
+
* wall-clock) so they fire at the right offset in an offline/deterministic
|
|
299
|
+
* render:
|
|
300
|
+
* - `schedule`: declarative `AudioEvent`s whose `atSec` is relative to the
|
|
301
|
+
* segment's start. Played through the same Tone sampler/mastering bus via the
|
|
302
|
+
* existing `applySchedule` glue (so timing == captureStart + segStart + atSec).
|
|
303
|
+
* - `onEnter(audioClock, ctx)`: an imperative escape hatch fired once at the
|
|
304
|
+
* segment's start time, handed the audio clock anchored to that start (its
|
|
305
|
+
* `nowMs()` returns the segment's absolute start ms) so the callback can do
|
|
306
|
+
* its own `triggerAttackRelease(note, dur, audioClock.nowMs()/1000 + …)`.
|
|
307
|
+
*/
|
|
308
|
+
interface SegmentAudio {
|
|
309
|
+
/** Declarative events; `atSec` is relative to the segment's start. */
|
|
310
|
+
schedule?: AudioEvent[];
|
|
311
|
+
/** Imperative hook fired once at the segment's start during capture. */
|
|
312
|
+
onEnter?: (audioClock: AudioClock, ctx: SegmentAudioCtx) => void;
|
|
313
|
+
}
|
|
314
|
+
/** Context handed to a `SegmentAudio.onEnter` callback. */
|
|
315
|
+
interface SegmentAudioCtx {
|
|
316
|
+
/** The Tone instrument wired into the promo mastering chain (has
|
|
317
|
+
* `triggerAttackRelease`). Same target `applySchedule` plays through. */
|
|
318
|
+
instrument: ScheduleTarget;
|
|
319
|
+
/** Absolute capture-clock seconds at which this segment starts (= the audio
|
|
320
|
+
* base time + the segment's resolved start). Add your own offsets to this. */
|
|
321
|
+
startSec: number;
|
|
322
|
+
/** The render Score, if any (timing/pitch source). */
|
|
323
|
+
score?: Score;
|
|
324
|
+
}
|
|
325
|
+
/** Minimal Tone-instrument surface the audio scheduler needs. */
|
|
326
|
+
interface ScheduleTarget {
|
|
327
|
+
triggerAttackRelease: (note: unknown, dur: unknown, time?: unknown, velocity?: unknown) => void;
|
|
328
|
+
}
|
|
212
329
|
interface TimelineSegment {
|
|
213
330
|
/** [start, end] in SECONDS; end may be "end" / "end-N". */
|
|
214
331
|
at: [number, TimeAnchor];
|
|
215
332
|
layers: SpecLayer[];
|
|
333
|
+
/** Optional sound scheduled at this segment's start (see SegmentAudio). */
|
|
334
|
+
audio?: SegmentAudio;
|
|
216
335
|
}
|
|
217
336
|
interface SceneSpec {
|
|
218
337
|
/** [width, height] px. */
|
|
@@ -237,6 +356,7 @@ interface ResolvedSegment {
|
|
|
237
356
|
startMs: number;
|
|
238
357
|
endMs: number;
|
|
239
358
|
layers: SpecLayer[];
|
|
359
|
+
audio?: SegmentAudio;
|
|
240
360
|
}
|
|
241
361
|
/** Resolve every segment's [start,end] to ms against the total clip length. */
|
|
242
362
|
declare function resolveTimeline(spec: SceneSpec, totalSec: number): ResolvedSegment[];
|
|
@@ -254,6 +374,17 @@ interface BuiltScene {
|
|
|
254
374
|
dispose(): void;
|
|
255
375
|
/** The resolved timeline (for the gate / diagnostics). */
|
|
256
376
|
resolved: ResolvedSegment[];
|
|
377
|
+
/**
|
|
378
|
+
* Schedule every segment's optional audio against the CAPTURE clock. Call once
|
|
379
|
+
* at capture start, passing the Tone instrument (the promo sampler) and the
|
|
380
|
+
* audio-clock base time in SECONDS (e.g. `sampler.audioNow()` / `Tone.now()`).
|
|
381
|
+
* Each segment's events fire at `baseSec + segStartMs/1000 + atSec`; each
|
|
382
|
+
* `onEnter` fires once with an AudioClock anchored to its segment start.
|
|
383
|
+
* Deterministic w.r.t. the supplied base time (no wall-clock). No-op for specs
|
|
384
|
+
* that declare no segment audio. Returns the count of events scheduled (handy
|
|
385
|
+
* for tests/gate sanity).
|
|
386
|
+
*/
|
|
387
|
+
scheduleAudio(instrument: ScheduleTarget, baseSec: number): number;
|
|
257
388
|
}
|
|
258
389
|
interface BuildSceneOpts {
|
|
259
390
|
spec: SceneSpec;
|
|
@@ -280,6 +411,21 @@ interface RecordSceneSpecOpts {
|
|
|
280
411
|
onProgress?: RecordOpts['onProgress'];
|
|
281
412
|
/** Inject the recorder for tests. Defaults to recordScenes. */
|
|
282
413
|
record?: (scenes: Scene[], o: RecordOpts) => Promise<Blob>;
|
|
414
|
+
/**
|
|
415
|
+
* OPTIONAL per-segment audio scheduling. When a spec declares segment audio
|
|
416
|
+
* (SegmentAudio), pass the Tone instrument (the promo sampler) and a clock so
|
|
417
|
+
* the runner can schedule each segment's sound against the capture timeline.
|
|
418
|
+
* `audioNow()` returns the audio-clock seconds at capture start (e.g.
|
|
419
|
+
* `sampler.audioNow()` / `Tone.now()`); `lookaheadSec` (default 0.1) shifts
|
|
420
|
+
* the whole schedule forward a touch so the first event isn't already in the
|
|
421
|
+
* past when the recorder spins up. Omit entirely → pure-visual capture
|
|
422
|
+
* (unchanged behavior).
|
|
423
|
+
*/
|
|
424
|
+
segmentAudio?: {
|
|
425
|
+
instrument: ScheduleTarget;
|
|
426
|
+
audioNow: () => number;
|
|
427
|
+
lookaheadSec?: number;
|
|
428
|
+
};
|
|
283
429
|
}
|
|
284
430
|
/**
|
|
285
431
|
* Capture a built scene to a Blob by wrapping the whole timeline as a SINGLE
|
|
@@ -364,83 +510,6 @@ interface CaptionStyle {
|
|
|
364
510
|
*/
|
|
365
511
|
declare function drawCaption(ctx: CanvasRenderingContext2D, script: CaptionScript, tMs: number, safe: SafeBox, theme: PromoTheme, style?: CaptionStyle): void;
|
|
366
512
|
|
|
367
|
-
/** One scheduled audio event, in seconds RELATIVE to the schedule start. */
|
|
368
|
-
interface AudioEvent {
|
|
369
|
-
/** Offset from schedule start, seconds. */
|
|
370
|
-
atSec: number;
|
|
371
|
-
/** Pitch (note name or frequency) for tonal hits; omitted for noise clicks. */
|
|
372
|
-
note?: string | number;
|
|
373
|
-
/** Duration, seconds. Default short. */
|
|
374
|
-
durSec?: number;
|
|
375
|
-
/** Linear gain 0..1. Default 1. */
|
|
376
|
-
gain?: number;
|
|
377
|
-
/** Tag for tests/debugging: "count" | "click" | "drone". */
|
|
378
|
-
kind: 'count' | 'click' | 'drone';
|
|
379
|
-
}
|
|
380
|
-
interface CountInOpts {
|
|
381
|
-
/** Beats to count (default 4 — a full bar of 4/4). */
|
|
382
|
-
beats?: number;
|
|
383
|
-
/** Tempo for the count, bpm. */
|
|
384
|
-
bpm: number;
|
|
385
|
-
/** Accent the downbeat (beat 1) with a higher pitch. Default true. */
|
|
386
|
-
accentDownbeat?: boolean;
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* The count-in schedule: one click per beat, starting at t=0, ending exactly on
|
|
390
|
-
* the downbeat of bar 1 (i.e. the music starts at `beats * 60/bpm` seconds).
|
|
391
|
-
* Pure.
|
|
392
|
-
*/
|
|
393
|
-
declare function countInSchedule(opts: CountInOpts): AudioEvent[];
|
|
394
|
-
/** Seconds after t=0 that the music should start, for a given count-in. */
|
|
395
|
-
declare function countInLeadSec(opts: CountInOpts): number;
|
|
396
|
-
interface ClickTrackOpts {
|
|
397
|
-
bpm: number;
|
|
398
|
-
/** Total duration to fill with clicks, seconds. */
|
|
399
|
-
durationSec: number;
|
|
400
|
-
/** Beats per bar, for downbeat accents. Default 4. */
|
|
401
|
-
beatsPerBar?: number;
|
|
402
|
-
/** Start offset, seconds. Default 0. */
|
|
403
|
-
startSec?: number;
|
|
404
|
-
}
|
|
405
|
-
/** A click on every beat across `durationSec`, accenting each bar's downbeat. */
|
|
406
|
-
declare function clickTrackSchedule(opts: ClickTrackOpts): AudioEvent[];
|
|
407
|
-
interface DroneOpts {
|
|
408
|
-
/** Root pitch of the pad (e.g. "C2"). */
|
|
409
|
-
root: string;
|
|
410
|
-
/** Pad duration, seconds. */
|
|
411
|
-
durationSec: number;
|
|
412
|
-
/** Add a perfect fifth above the root. Default true. */
|
|
413
|
-
fifth?: boolean;
|
|
414
|
-
/** Bed gain 0..1. Default 0.15 (sits well under the melody). */
|
|
415
|
-
gain?: number;
|
|
416
|
-
}
|
|
417
|
-
/** A sustained root (+fifth) pad for the whole clip. */
|
|
418
|
-
declare function droneSchedule(opts: DroneOpts): AudioEvent[];
|
|
419
|
-
interface DuckWindow {
|
|
420
|
-
/** Voice-over window start, seconds. */
|
|
421
|
-
startSec: number;
|
|
422
|
-
/** Voice-over window end, seconds. */
|
|
423
|
-
endSec: number;
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Gain envelope for a music/bed bus that ducks under voice-over windows. Returns
|
|
427
|
-
* the linear gain 0..1 at time `tSec`: `floor` inside a window (after a short
|
|
428
|
-
* attack ramp), 1 outside (after a release ramp). Pure — feed it to a Tone
|
|
429
|
-
* Gain's `.gain.value` per frame, or sample it to build a ramp automation.
|
|
430
|
-
*/
|
|
431
|
-
declare function duckGainAt(windows: DuckWindow[], tSec: number, floor?: number, rampSec?: number): number;
|
|
432
|
-
/**
|
|
433
|
-
* Trigger a computed schedule on a Tone instrument at `startTime` (audio-clock
|
|
434
|
-
* seconds, e.g. `Tone.now()` or sampler.audioNow()). Thin glue over the existing
|
|
435
|
-
* sampler/synth — no new mastering bus. Tonal events use triggerAttackRelease;
|
|
436
|
-
* the instrument is whatever the caller wires into the promo mastering chain.
|
|
437
|
-
*
|
|
438
|
-
* Returns the number of events fired (handy for tests/gate sanity).
|
|
439
|
-
*/
|
|
440
|
-
declare function applySchedule(instrument: {
|
|
441
|
-
triggerAttackRelease: (note: any, dur: any, time?: any, velocity?: any) => void;
|
|
442
|
-
}, schedule: AudioEvent[], startTime: number): number;
|
|
443
|
-
|
|
444
513
|
interface GateError {
|
|
445
514
|
check: 'av-duration' | 'placement' | 'fonts' | 'spec' | 'output';
|
|
446
515
|
message: string;
|
|
@@ -1396,4 +1465,4 @@ interface SectionMinimapProps {
|
|
|
1396
1465
|
}
|
|
1397
1466
|
declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
|
|
1398
1467
|
|
|
1399
|
-
export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, 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 Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, 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, 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, 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, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, whiteKeys, worldToViewport };
|
|
1468
|
+
export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, 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 SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, 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, 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, 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, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, whiteKeys, worldToViewport };
|