@real-music-packages/web-core 0.3.0 → 0.5.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 CHANGED
@@ -8,6 +8,10 @@ declare class AudioEngine {
8
8
  private piano;
9
9
  private synthPiano;
10
10
  private strings;
11
+ private dronePad;
12
+ private droneNotes;
13
+ /** Piano voice level in dB; applied to whichever voice is active. */
14
+ private pianoVolumeDb;
11
15
  private isInitialized;
12
16
  private isLoading;
13
17
  private samplerUpgradeStarted;
@@ -88,6 +92,24 @@ declare class AudioEngine {
88
92
  * Start a sustained background chord (tonic chord)
89
93
  */
90
94
  startBackgroundChord(key: string, duration?: number): void;
95
+ /**
96
+ * Start a sustained tonic DRONE as a key anchor — an open fifth (root + 5th,
97
+ * no third) in a low octave. Unlike startBackgroundChord (a full tonic
98
+ * triad), the missing third means individual scale degrees keep their
99
+ * tension instead of being resolved into the tonic chord: a note held over
100
+ * this drone sounds as tense/stable as it truly is. Intended for exploratory
101
+ * note-by-note views. Sustains until stopDrone() is called; calling again
102
+ * restarts cleanly in the new key. Idempotent-safe.
103
+ */
104
+ startDrone(key: string): void;
105
+ /** Stop the sustained drone started by startDrone(). No-op if none playing. */
106
+ stopDrone(): void;
107
+ /**
108
+ * Set the piano voice level in dB (0 = unchanged default, negative = quieter).
109
+ * Applies to the current voice and is re-applied when the sampler swaps in.
110
+ */
111
+ setPianoVolume(db: number): void;
112
+ private applyPianoVolume;
91
113
  /**
92
114
  * Play a chord (list of MIDI notes) on the piano sampler.
93
115
  */
package/dist/audio.js CHANGED
@@ -77,6 +77,10 @@ var AudioEngine = class {
77
77
  piano = null;
78
78
  synthPiano = null;
79
79
  strings = null;
80
+ dronePad = null;
81
+ droneNotes = [];
82
+ /** Piano voice level in dB; applied to whichever voice is active. */
83
+ pianoVolumeDb = 0;
80
84
  isInitialized = false;
81
85
  isLoading = false;
82
86
  samplerUpgradeStarted = false;
@@ -139,6 +143,7 @@ var AudioEngine = class {
139
143
  }).toDestination();
140
144
  this.piano = this.synthPiano;
141
145
  this.voice = "synth";
146
+ this.applyPianoVolume();
142
147
  this.lastStage = "piano-synth-ready";
143
148
  this.padFilter = new this.Tone.Filter({
144
149
  type: "lowpass",
@@ -151,6 +156,12 @@ var AudioEngine = class {
151
156
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
157
  }).connect(this.padFilter);
153
158
  this.strings.volume.value = -10;
159
+ this.dronePad = new this.Tone.PolySynth(this.Tone.Synth, {
160
+ oscillator: { type: "triangle" },
161
+ envelope: { attack: 0.6, decay: 0.2, sustain: 0.9, release: 1.5 }
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ }).connect(this.padFilter);
164
+ this.dronePad.volume.value = -16;
154
165
  this.isInitialized = true;
155
166
  this.lastStage = "ready";
156
167
  this.upgradeToSamplerInBackground();
@@ -179,6 +190,7 @@ var AudioEngine = class {
179
190
  onload: () => {
180
191
  this.piano = sampler;
181
192
  this.voice = "sampler";
193
+ this.applyPianoVolume();
182
194
  console.info("[audio] upgraded piano voice to Salamander samples");
183
195
  },
184
196
  onerror: (e) => {
@@ -291,6 +303,54 @@ var AudioEngine = class {
291
303
  const notes = [root4, fifth4, root5, third5].map((m) => midiToNoteName(m, useFlats));
292
304
  this.strings.triggerAttackRelease(notes, duration, this.Tone.now(), 0.3);
293
305
  }
306
+ /**
307
+ * Start a sustained tonic DRONE as a key anchor — an open fifth (root + 5th,
308
+ * no third) in a low octave. Unlike startBackgroundChord (a full tonic
309
+ * triad), the missing third means individual scale degrees keep their
310
+ * tension instead of being resolved into the tonic chord: a note held over
311
+ * this drone sounds as tense/stable as it truly is. Intended for exploratory
312
+ * note-by-note views. Sustains until stopDrone() is called; calling again
313
+ * restarts cleanly in the new key. Idempotent-safe.
314
+ */
315
+ startDrone(key) {
316
+ if (!this.dronePad || !this.isInitialized) return;
317
+ this.ensureRunning();
318
+ this.stopDrone();
319
+ const useFlats = KEYS_PREFER_FLATS.includes(key);
320
+ const root3 = getMidiNote(1, key, 3);
321
+ const fifth3 = getMidiNote(5, key, 3);
322
+ const root4 = getMidiNote(1, key, 4);
323
+ this.droneNotes = [root3, fifth3, root4].map((m) => midiToNoteName(m, useFlats));
324
+ this.dronePad.triggerAttack(this.droneNotes, this.Tone.now());
325
+ }
326
+ /** Stop the sustained drone started by startDrone(). No-op if none playing. */
327
+ stopDrone() {
328
+ if (!this.dronePad) return;
329
+ try {
330
+ if (this.droneNotes.length > 0) {
331
+ this.dronePad.triggerRelease(this.droneNotes, this.Tone.now());
332
+ } else {
333
+ this.dronePad.releaseAll?.(this.Tone.now());
334
+ }
335
+ } catch (e) {
336
+ console.warn("[audio] stopDrone failed", e);
337
+ }
338
+ this.droneNotes = [];
339
+ }
340
+ /**
341
+ * Set the piano voice level in dB (0 = unchanged default, negative = quieter).
342
+ * Applies to the current voice and is re-applied when the sampler swaps in.
343
+ */
344
+ setPianoVolume(db) {
345
+ this.pianoVolumeDb = db;
346
+ this.applyPianoVolume();
347
+ }
348
+ applyPianoVolume() {
349
+ for (const voice of [this.synthPiano, this.piano]) {
350
+ const vol = voice?.volume;
351
+ if (vol) vol.value = this.pianoVolumeDb;
352
+ }
353
+ }
294
354
  /**
295
355
  * Play a chord (list of MIDI notes) on the piano sampler.
296
356
  */
package/dist/audio.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/salamander.ts","../src/audioHelpers.ts","../src/engine.ts"],"sourcesContent":["export const SALAMANDER_CDN_BASE = 'https://tonejs.github.io/audio/salamander/';\n\n/** 8-anchor sample set (Stave uses these from the CDN). */\nexport const SALAMANDER_URLS_8: Record<string, string> = {\n C2: 'C2.mp3', 'F#2': 'Fs2.mp3', C3: 'C3.mp3', 'F#3': 'Fs3.mp3',\n C4: 'C4.mp3', 'F#4': 'Fs4.mp3', C5: 'C5.mp3', 'F#5': 'Fs5.mp3',\n};\n\n/** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */\nexport const SALAMANDER_URLS_FULL: Record<string, string> = {\n A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3',\n C2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3',\n C3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3',\n C4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3',\n C5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3',\n C6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3',\n C7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3',\n C8: 'C8.mp3',\n};\n","// Tone is provided by the consumer (peer dep); type loosely to avoid coupling.\ntype ToneModule = any; // eslint-disable-line @typescript-eslint/no-explicit-any\n\nexport interface SalamanderSamplerOpts {\n urls: Record<string, string>;\n baseUrl: string;\n release: number;\n volumeDb?: number;\n onload?: () => void;\n onerror?: (e: unknown) => void;\n}\n\n/** Create a Salamander Tone.Sampler routed to destination. Mirrors the exact\n * `new Tone.Sampler({...}).toDestination()` both engines did inline. */\nexport function createSalamanderSampler(Tone: ToneModule, opts: SalamanderSamplerOpts) {\n const sampler = new Tone.Sampler({\n urls: opts.urls,\n baseUrl: opts.baseUrl,\n release: opts.release,\n onload: opts.onload,\n onerror: opts.onerror,\n }).toDestination();\n if (opts.volumeDb !== undefined) sampler.volume.value = opts.volumeDb;\n return sampler;\n}\n\n/** Generate a Tone.Reverb, racing reverb.generate() against a timeout so it can\n * never hang init on iOS. Returns the reverb if generated, else null. Caller\n * wires routing (toDestination / reroute). Mirrors RET's addReverbInBackground. */\nexport async function generateReverb(\n Tone: ToneModule,\n opts: { decay: number; wet: number },\n timeoutMs: number,\n): Promise<any | null> { // eslint-disable-line @typescript-eslint/no-explicit-any\n const reverb = new Tone.Reverb(opts);\n const ok = await Promise.race([\n reverb.generate().then(() => true),\n new Promise<boolean>((r) => setTimeout(() => r(false), timeoutMs)),\n ]);\n return ok ? reverb : null;\n}\n","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};\ntype TonePolySynth = {\n\ttriggerAttackRelease: (notes: string[], duration: number | string, time?: number, velocity?: 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 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\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.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// 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\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 * 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\n// Singleton instance\nexport const audio = new AudioEngine();\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/salamander.ts","../src/audioHelpers.ts","../src/engine.ts"],"sourcesContent":["export const SALAMANDER_CDN_BASE = 'https://tonejs.github.io/audio/salamander/';\n\n/** 8-anchor sample set (Stave uses these from the CDN). */\nexport const SALAMANDER_URLS_8: Record<string, string> = {\n C2: 'C2.mp3', 'F#2': 'Fs2.mp3', C3: 'C3.mp3', 'F#3': 'Fs3.mp3',\n C4: 'C4.mp3', 'F#4': 'Fs4.mp3', C5: 'C5.mp3', 'F#5': 'Fs5.mp3',\n};\n\n/** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */\nexport const SALAMANDER_URLS_FULL: Record<string, string> = {\n A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3',\n C2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3',\n C3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3',\n C4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3',\n C5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3',\n C6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3',\n C7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3',\n C8: 'C8.mp3',\n};\n","// Tone is provided by the consumer (peer dep); type loosely to avoid coupling.\ntype ToneModule = any; // eslint-disable-line @typescript-eslint/no-explicit-any\n\nexport interface SalamanderSamplerOpts {\n urls: Record<string, string>;\n baseUrl: string;\n release: number;\n volumeDb?: number;\n onload?: () => void;\n onerror?: (e: unknown) => void;\n}\n\n/** Create a Salamander Tone.Sampler routed to destination. Mirrors the exact\n * `new Tone.Sampler({...}).toDestination()` both engines did inline. */\nexport function createSalamanderSampler(Tone: ToneModule, opts: SalamanderSamplerOpts) {\n const sampler = new Tone.Sampler({\n urls: opts.urls,\n baseUrl: opts.baseUrl,\n release: opts.release,\n onload: opts.onload,\n onerror: opts.onerror,\n }).toDestination();\n if (opts.volumeDb !== undefined) sampler.volume.value = opts.volumeDb;\n return sampler;\n}\n\n/** Generate a Tone.Reverb, racing reverb.generate() against a timeout so it can\n * never hang init on iOS. Returns the reverb if generated, else null. Caller\n * wires routing (toDestination / reroute). Mirrors RET's addReverbInBackground. */\nexport async function generateReverb(\n Tone: ToneModule,\n opts: { decay: number; wet: number },\n timeoutMs: number,\n): Promise<any | null> { // eslint-disable-line @typescript-eslint/no-explicit-any\n const reverb = new Tone.Reverb(opts);\n const ok = await Promise.race([\n reverb.generate().then(() => true),\n new Promise<boolean>((r) => setTimeout(() => r(false), timeoutMs)),\n ]);\n return ok ? reverb : null;\n}\n","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\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\n// Singleton instance\nexport const audio = new AudioEngine();\n"],"mappings":"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","names":[]}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Framework-agnostic promo video module.
3
+ *
4
+ * Provides:
5
+ * - PromoTheme — colour/font/brand tokens
6
+ * - Scene — {durationMs, draw, onEnter?}
7
+ * - recordScenes — rAF-based canvas capture → Blob (browser only)
8
+ * - hookScene / revealScene / ctaScene — scene builders (pure, unit-testable)
9
+ * - runPromoCapture — orchestrator with dependency-injected recorder
10
+ *
11
+ * Visual style ported from whozart/src/lib/promo.ts + video.ts.
12
+ */
13
+ interface PromoTheme {
14
+ paper: string;
15
+ ink: string;
16
+ accent: string;
17
+ sepia: string;
18
+ gold: string;
19
+ fontDisplay: string;
20
+ fontBody: string;
21
+ brand: string;
22
+ }
23
+ interface Scene {
24
+ durationMs: number;
25
+ draw: (ctx: CanvasRenderingContext2D, t01: number) => void;
26
+ onEnter?: () => void;
27
+ }
28
+ interface RecordOpts {
29
+ audioStream: MediaStream;
30
+ width: number;
31
+ height: number;
32
+ fps?: number;
33
+ background?: string;
34
+ onProgress?: (p: {
35
+ phase: string;
36
+ progress01: number;
37
+ }) => void;
38
+ }
39
+ interface PromoMeta {
40
+ id?: string;
41
+ title?: string;
42
+ composer?: string;
43
+ [k: string]: unknown;
44
+ }
45
+ interface PromoCaptureOpts {
46
+ buildScenes: () => Scene[];
47
+ audioStream: MediaStream;
48
+ meta: PromoMeta;
49
+ width?: number;
50
+ height?: number;
51
+ fps?: number;
52
+ background?: string;
53
+ download?: boolean;
54
+ }
55
+ declare function pickMimeType(): string;
56
+ declare function recordScenes(scenes: Scene[], opts: RecordOpts): Promise<Blob>;
57
+ declare function truncate(s: string, max: number): string;
58
+ declare function initials(name: string): string;
59
+ interface HookSceneOpts {
60
+ lines: string[];
61
+ brand?: string;
62
+ }
63
+ declare function hookScene(theme: PromoTheme, opts: HookSceneOpts): Scene;
64
+ interface RevealSceneOpts {
65
+ title: string;
66
+ subtitle: string;
67
+ initials?: string;
68
+ }
69
+ declare function revealScene(theme: PromoTheme, opts: RevealSceneOpts): Scene;
70
+ interface CtaSceneOpts {
71
+ lines: string[];
72
+ }
73
+ declare function ctaScene(theme: PromoTheme, opts: CtaSceneOpts): Scene;
74
+ declare function runPromoCapture(opts: PromoCaptureOpts, record?: (scenes: Scene[], o: RecordOpts) => Promise<Blob>): Promise<void>;
75
+
76
+ export { type CtaSceneOpts, type HookSceneOpts, type PromoCaptureOpts, type PromoMeta, type PromoTheme, type RecordOpts, type RevealSceneOpts, type Scene, ctaScene, hookScene, initials, pickMimeType, recordScenes, revealScene, runPromoCapture, truncate };
package/dist/video.js ADDED
@@ -0,0 +1,202 @@
1
+ // src/video.ts
2
+ function pickMimeType() {
3
+ const candidates = [
4
+ "video/webm;codecs=vp9,opus",
5
+ "video/webm;codecs=vp8,opus",
6
+ "video/webm"
7
+ ];
8
+ for (const t of candidates) {
9
+ if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) return t;
10
+ }
11
+ return "video/webm";
12
+ }
13
+ async function recordScenes(scenes, opts) {
14
+ const { audioStream, width, height, fps = 30, background = "#000000", onProgress } = opts;
15
+ const canvas = document.createElement("canvas");
16
+ canvas.width = width;
17
+ canvas.height = height;
18
+ const maybeCtx = canvas.getContext("2d");
19
+ if (!maybeCtx) throw new Error("Failed to get 2D context");
20
+ const ctx = maybeCtx;
21
+ const videoStream = canvas.captureStream(fps);
22
+ const combined = new MediaStream([
23
+ ...videoStream.getVideoTracks(),
24
+ ...audioStream.getAudioTracks()
25
+ ]);
26
+ const mimeType = pickMimeType();
27
+ const recorder = new MediaRecorder(combined, {
28
+ mimeType,
29
+ videoBitsPerSecond: 4e6,
30
+ audioBitsPerSecond: 128e3
31
+ });
32
+ const chunks = [];
33
+ recorder.ondataavailable = (e) => {
34
+ if (e.data.size > 0) chunks.push(e.data);
35
+ };
36
+ const stopped = new Promise((res) => {
37
+ recorder.onstop = () => res();
38
+ });
39
+ recorder.start();
40
+ const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);
41
+ const t0 = performance.now();
42
+ onProgress?.({ phase: "capturing", progress01: 0 });
43
+ let sceneStart = 0;
44
+ let sceneIdx = 0;
45
+ let enteredScene = -1;
46
+ ctx.fillStyle = background;
47
+ ctx.fillRect(0, 0, width, height);
48
+ scenes[0].draw(ctx, 0);
49
+ await new Promise((resolve) => {
50
+ function tick() {
51
+ const now = performance.now() - t0;
52
+ if (now >= totalMs) {
53
+ setTimeout(resolve, 100);
54
+ return;
55
+ }
56
+ while (sceneIdx < scenes.length - 1 && now - sceneStart >= scenes[sceneIdx].durationMs) {
57
+ sceneStart += scenes[sceneIdx].durationMs;
58
+ sceneIdx += 1;
59
+ }
60
+ const scene = scenes[sceneIdx];
61
+ if (enteredScene !== sceneIdx) {
62
+ enteredScene = sceneIdx;
63
+ scene.onEnter?.();
64
+ }
65
+ const tInScene = (now - sceneStart) / scene.durationMs;
66
+ ctx.fillStyle = background;
67
+ ctx.fillRect(0, 0, width, height);
68
+ scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));
69
+ onProgress?.({
70
+ phase: "capturing",
71
+ progress01: Math.min(0.95, now / totalMs)
72
+ });
73
+ requestAnimationFrame(tick);
74
+ }
75
+ requestAnimationFrame(tick);
76
+ });
77
+ onProgress?.({ phase: "finalizing", progress01: 0.96 });
78
+ recorder.stop();
79
+ await stopped;
80
+ return new Blob(chunks, { type: mimeType });
81
+ }
82
+ function truncate(s, max) {
83
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + "\u2026" : s;
84
+ }
85
+ function initials(name) {
86
+ return name.split(/\s+/).filter(Boolean).slice(0, 3).map((w) => w[0]).join("").toUpperCase();
87
+ }
88
+ function hookScene(theme, opts) {
89
+ return {
90
+ durationMs: 2e3,
91
+ draw(ctx, _t01) {
92
+ const W = ctx.canvas.width;
93
+ const H = ctx.canvas.height;
94
+ ctx.textAlign = "center";
95
+ ctx.fillStyle = theme.ink;
96
+ ctx.font = `bold 64px ${theme.fontDisplay}`;
97
+ opts.lines.forEach((line, i) => {
98
+ ctx.fillText(line, W / 2, H * 0.42 + i * 78);
99
+ });
100
+ const brand = opts.brand ?? theme.brand;
101
+ ctx.font = `italic 28px ${theme.fontBody}`;
102
+ ctx.fillStyle = theme.sepia;
103
+ ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 78 + 4);
104
+ }
105
+ };
106
+ }
107
+ function revealScene(theme, opts) {
108
+ return {
109
+ durationMs: 2200,
110
+ draw(ctx, t01) {
111
+ const W = ctx.canvas.width;
112
+ const H = ctx.canvas.height;
113
+ const cx = W / 2;
114
+ const cy = H * 0.4;
115
+ const r = 92;
116
+ const k = Math.min(1, t01 * 1.4);
117
+ ctx.globalAlpha = k;
118
+ ctx.textAlign = "center";
119
+ ctx.beginPath();
120
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
121
+ ctx.fillStyle = theme.paper;
122
+ ctx.fill();
123
+ ctx.lineWidth = 5;
124
+ ctx.strokeStyle = theme.gold;
125
+ ctx.stroke();
126
+ ctx.fillStyle = theme.ink;
127
+ ctx.font = `bold 60px ${theme.fontDisplay}`;
128
+ const badge = opts.initials ?? initials(opts.title);
129
+ ctx.fillText(badge, cx, cy + 21);
130
+ const truncated = truncate(opts.title, 24);
131
+ let fontSize = 54;
132
+ ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
133
+ if (ctx.measureText(truncated).width > W * 0.86) {
134
+ fontSize = 46;
135
+ ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
136
+ }
137
+ ctx.fillStyle = theme.ink;
138
+ ctx.fillText(truncated, cx, cy + r + 96);
139
+ ctx.font = `italic 34px ${theme.fontBody}`;
140
+ ctx.fillStyle = theme.sepia;
141
+ ctx.fillText(opts.subtitle, cx, cy + r + 150);
142
+ ctx.globalAlpha = 1;
143
+ }
144
+ };
145
+ }
146
+ function ctaScene(theme, opts) {
147
+ return {
148
+ durationMs: 2500,
149
+ draw(ctx, _t01) {
150
+ const W = ctx.canvas.width;
151
+ const H = ctx.canvas.height;
152
+ ctx.textAlign = "center";
153
+ if (opts.lines.length > 0) {
154
+ ctx.fillStyle = theme.ink;
155
+ ctx.font = `bold 52px ${theme.fontDisplay}`;
156
+ ctx.fillText(opts.lines[0], W / 2, H * 0.42);
157
+ }
158
+ opts.lines.slice(1).forEach((line, i) => {
159
+ ctx.fillStyle = theme.accent;
160
+ ctx.font = `bold 60px ${theme.fontDisplay}`;
161
+ ctx.fillText(line, W / 2, H * 0.42 + 86 * (i + 1));
162
+ });
163
+ }
164
+ };
165
+ }
166
+ async function runPromoCapture(opts, record = recordScenes) {
167
+ const w = window;
168
+ w.__promoMeta = opts.meta;
169
+ try {
170
+ const blob = await record(opts.buildScenes(), {
171
+ audioStream: opts.audioStream,
172
+ width: opts.width ?? 1080,
173
+ height: opts.height ?? 1920,
174
+ fps: opts.fps,
175
+ background: opts.background
176
+ });
177
+ const url = URL.createObjectURL(blob);
178
+ w.__promoBlobUrl = url;
179
+ if (opts.download !== false) {
180
+ const a = document.createElement("a");
181
+ a.href = url;
182
+ a.download = `promo-${opts.meta.id ?? "clip"}.webm`;
183
+ document.body.appendChild(a);
184
+ a.click();
185
+ }
186
+ w.__promoReady = true;
187
+ } catch (e) {
188
+ w.__promoError = String(e);
189
+ throw e;
190
+ }
191
+ }
192
+ export {
193
+ ctaScene,
194
+ hookScene,
195
+ initials,
196
+ pickMimeType,
197
+ recordScenes,
198
+ revealScene,
199
+ runPromoCapture,
200
+ truncate
201
+ };
202
+ //# sourceMappingURL=video.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/video.ts"],"sourcesContent":["/**\n * Framework-agnostic promo video module.\n *\n * Provides:\n * - PromoTheme — colour/font/brand tokens\n * - Scene — {durationMs, draw, onEnter?}\n * - recordScenes — rAF-based canvas capture → Blob (browser only)\n * - hookScene / revealScene / ctaScene — scene builders (pure, unit-testable)\n * - runPromoCapture — orchestrator with dependency-injected recorder\n *\n * Visual style ported from whozart/src/lib/promo.ts + video.ts.\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface PromoTheme {\n paper: string;\n ink: string;\n accent: string;\n sepia: string;\n gold: string;\n fontDisplay: string;\n fontBody: string;\n brand: string;\n}\n\nexport interface Scene {\n durationMs: number;\n draw: (ctx: CanvasRenderingContext2D, t01: number) => void;\n onEnter?: () => void;\n}\n\nexport interface RecordOpts {\n audioStream: MediaStream;\n width: number;\n height: number;\n fps?: number;\n background?: string;\n onProgress?: (p: { phase: string; progress01: number }) => void;\n}\n\nexport interface PromoMeta {\n id?: string;\n title?: string;\n composer?: string;\n [k: string]: unknown;\n}\n\nexport interface PromoCaptureOpts {\n buildScenes: () => Scene[];\n audioStream: MediaStream;\n meta: PromoMeta;\n width?: number;\n height?: number;\n fps?: number;\n background?: string;\n download?: boolean;\n}\n\n// ─── MIME type picker ─────────────────────────────────────────────────────────\n\nexport function pickMimeType(): string {\n const candidates = [\n 'video/webm;codecs=vp9,opus',\n 'video/webm;codecs=vp8,opus',\n 'video/webm',\n ];\n for (const t of candidates) {\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;\n }\n return 'video/webm';\n}\n\n// ─── recordScenes ─────────────────────────────────────────────────────────────\n\nexport async function recordScenes(scenes: Scene[], opts: RecordOpts): Promise<Blob> {\n const { audioStream, width, height, fps = 30, background = '#000000', onProgress } = opts;\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const maybeCtx = canvas.getContext('2d');\n if (!maybeCtx) throw new Error('Failed to get 2D context');\n const ctx: CanvasRenderingContext2D = maybeCtx;\n\n const videoStream = canvas.captureStream(fps);\n const combined = new MediaStream([\n ...videoStream.getVideoTracks(),\n ...audioStream.getAudioTracks(),\n ]);\n\n const mimeType = pickMimeType();\n const recorder = new MediaRecorder(combined, {\n mimeType,\n videoBitsPerSecond: 4_000_000,\n audioBitsPerSecond: 128_000,\n });\n\n const chunks: Blob[] = [];\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunks.push(e.data);\n };\n const stopped = new Promise<void>((res) => {\n recorder.onstop = () => res();\n });\n\n recorder.start();\n\n const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);\n const t0 = performance.now();\n\n onProgress?.({ phase: 'capturing', progress01: 0 });\n\n let sceneStart = 0;\n let sceneIdx = 0;\n let enteredScene = -1;\n\n // Draw first frame immediately\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n scenes[0].draw(ctx, 0);\n\n await new Promise<void>((resolve) => {\n function tick() {\n const now = performance.now() - t0;\n if (now >= totalMs) {\n setTimeout(resolve, 100);\n return;\n }\n\n // Advance scene index\n while (\n sceneIdx < scenes.length - 1 &&\n now - sceneStart >= scenes[sceneIdx].durationMs\n ) {\n sceneStart += scenes[sceneIdx].durationMs;\n sceneIdx += 1;\n }\n\n const scene = scenes[sceneIdx];\n\n // Fire onEnter once per scene\n if (enteredScene !== sceneIdx) {\n enteredScene = sceneIdx;\n scene.onEnter?.();\n }\n\n const tInScene = (now - sceneStart) / scene.durationMs;\n\n // Clear to background\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n\n scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));\n\n onProgress?.({\n phase: 'capturing',\n progress01: Math.min(0.95, now / totalMs),\n });\n\n requestAnimationFrame(tick);\n }\n\n requestAnimationFrame(tick);\n });\n\n onProgress?.({ phase: 'finalizing', progress01: 0.96 });\n recorder.stop();\n await stopped;\n\n return new Blob(chunks, { type: mimeType });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nexport function truncate(s: string, max: number): string {\n return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;\n}\n\nexport function initials(name: string): string {\n return name\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 3)\n .map((w) => w[0])\n .join('')\n .toUpperCase();\n}\n\n// ─── Scene builders ───────────────────────────────────────────────────────────\n\nexport interface HookSceneOpts {\n lines: string[];\n brand?: string;\n}\n\nexport function hookScene(theme: PromoTheme, opts: HookSceneOpts): Scene {\n return {\n durationMs: 2000,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n ctx.textAlign = 'center';\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 64px ${theme.fontDisplay}`;\n opts.lines.forEach((line, i) => {\n ctx.fillText(line, W / 2, H * 0.42 + i * 78);\n });\n const brand = opts.brand ?? theme.brand;\n ctx.font = `italic 28px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 78 + 4);\n },\n };\n}\n\nexport interface RevealSceneOpts {\n title: string;\n subtitle: string;\n initials?: string;\n}\n\nexport function revealScene(theme: PromoTheme, opts: RevealSceneOpts): Scene {\n return {\n durationMs: 2200,\n draw(ctx, t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n const cx = W / 2;\n const cy = H * 0.40;\n const r = 92;\n const k = Math.min(1, t01 * 1.4);\n ctx.globalAlpha = k;\n ctx.textAlign = 'center';\n\n // Medallion circle (faint surface)\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI * 2);\n ctx.fillStyle = theme.paper;\n ctx.fill();\n ctx.lineWidth = 5;\n ctx.strokeStyle = theme.gold;\n ctx.stroke();\n\n // Initials inside medallion\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 60px ${theme.fontDisplay}`;\n const badge = opts.initials ?? initials(opts.title);\n ctx.fillText(badge, cx, cy + 21);\n\n // Title (with font-size fallback if too wide)\n const truncated = truncate(opts.title, 24);\n let fontSize = 54;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n if (ctx.measureText(truncated).width > W * 0.86) {\n fontSize = 46;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n }\n ctx.fillStyle = theme.ink;\n ctx.fillText(truncated, cx, cy + r + 96);\n\n // Subtitle\n ctx.font = `italic 34px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(opts.subtitle, cx, cy + r + 150);\n\n ctx.globalAlpha = 1;\n },\n };\n}\n\nexport interface CtaSceneOpts {\n lines: string[];\n}\n\nexport function ctaScene(theme: PromoTheme, opts: CtaSceneOpts): Scene {\n return {\n durationMs: 2500,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n ctx.textAlign = 'center';\n if (opts.lines.length > 0) {\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 52px ${theme.fontDisplay}`;\n ctx.fillText(opts.lines[0], W / 2, H * 0.42);\n }\n opts.lines.slice(1).forEach((line, i) => {\n ctx.fillStyle = theme.accent;\n ctx.font = `bold 60px ${theme.fontDisplay}`;\n ctx.fillText(line, W / 2, H * 0.42 + 86 * (i + 1));\n });\n },\n };\n}\n\n// ─── runPromoCapture ──────────────────────────────────────────────────────────\n\nexport async function runPromoCapture(\n opts: PromoCaptureOpts,\n record: (scenes: Scene[], o: RecordOpts) => Promise<Blob> = recordScenes,\n): Promise<void> {\n const w = window as unknown as Record<string, unknown>;\n w.__promoMeta = opts.meta;\n try {\n const blob = await record(opts.buildScenes(), {\n audioStream: opts.audioStream,\n width: opts.width ?? 1080,\n height: opts.height ?? 1920,\n fps: opts.fps,\n background: opts.background,\n });\n const url = URL.createObjectURL(blob);\n w.__promoBlobUrl = url;\n if (opts.download !== false) {\n const a = document.createElement('a');\n a.href = url;\n a.download = `promo-${opts.meta.id ?? 'clip'}.webm`;\n document.body.appendChild(a);\n a.click();\n }\n w.__promoReady = true;\n } catch (e) {\n w.__promoError = String(e);\n throw e;\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,16 +1,28 @@
1
1
  {
2
2
  "name": "@real-music-packages/web-core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared music-theory + audio primitives for the music-suite web apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
10
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
11
- "./audio": { "types": "./dist/audio.d.ts", "import": "./dist/audio.js" }
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./audio": {
15
+ "types": "./dist/audio.d.ts",
16
+ "import": "./dist/audio.js"
17
+ },
18
+ "./video": {
19
+ "types": "./dist/video.d.ts",
20
+ "import": "./dist/video.js"
21
+ }
12
22
  },
13
- "files": ["dist"],
23
+ "files": [
24
+ "dist"
25
+ ],
14
26
  "scripts": {
15
27
  "build": "tsup",
16
28
  "test": "vitest run",
@@ -21,17 +33,22 @@
21
33
  "publishConfig": {
22
34
  "access": "public"
23
35
  },
24
- "repository": { "type": "git", "url": "git+https://github.com/e7mac/web-core.git" },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/e7mac/web-core.git"
39
+ },
25
40
  "peerDependencies": {
26
41
  "tone": ">=14"
27
42
  },
28
43
  "peerDependenciesMeta": {
29
- "tone": { "optional": true }
44
+ "tone": {
45
+ "optional": true
46
+ }
30
47
  },
31
48
  "devDependencies": {
32
49
  "tone": "^15.1.22",
33
50
  "tsup": "^8.3.0",
34
51
  "typescript": "^5.6.0",
35
- "vitest": "^2.1.0"
52
+ "vitest": "^4.1.7"
36
53
  }
37
54
  }