@real-music-packages/web-core 0.1.0 → 0.2.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
@@ -4,28 +4,112 @@ declare const SALAMANDER_URLS_8: Record<string, string>;
4
4
  /** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */
5
5
  declare const SALAMANDER_URLS_FULL: Record<string, string>;
6
6
 
7
- /**
8
- * Emit a near-silent, very short tone on the given AudioContext. iOS Safari
9
- * only unlocks audio if actual output is produced DURING the user gesture —
10
- * resuming the context alone is not enough. Call this synchronously inside a
11
- * click/pointerdown handler. (This is the trick RET's old unlock() omitted.)
12
- */
13
- declare function emitUnlockBlip(ctx: AudioContext): void;
14
- type ToneContextSetter = (ctx: AudioContext) => void;
15
- /**
16
- * Owns a raw AudioContext that is created + resumed SYNCHRONOUSLY inside the
17
- * first user gesture, then emits an unlock blip on it. Hand the context to your
18
- * audio library (e.g. Tone.setContext) via the onContext callback so the lib
19
- * runs on an already-running context (so its own start()/resume() can't hang
20
- * on iOS). Tone-agnostic — works with any version or no Tone at all.
21
- */
22
- declare function createAudioUnlocker(): {
23
- /** Call SYNCHRONOUSLY from a user gesture. Idempotent. */
24
- unlock(onContext?: ToneContextSetter): void;
25
- /** Resume the context if it has fallen back to suspended/interrupted. */
7
+ declare class AudioEngine {
8
+ private piano;
9
+ private synthPiano;
10
+ private strings;
11
+ private isInitialized;
12
+ private isLoading;
13
+ private samplerUpgradeStarted;
14
+ private reverbStarted;
15
+ private padFilter;
16
+ private Tone;
17
+ /** Which piano voice is currently sounding for diagnostics. */
18
+ voice: 'none' | 'synth' | 'sampler';
19
+ /** Last init milestone reached surfaced on-device to pinpoint stalls. */
20
+ lastStage: string;
21
+ /**
22
+ * Raw AudioContext we create + resume SYNCHRONOUSLY inside the first user
23
+ * gesture. iOS Safari only unlocks audio when resume() is called directly
24
+ * in the gesture task; awaiting the Tone import first (as init() must) loses
25
+ * that activation, so Tone.start()'s resume() hangs with the context stuck
26
+ * 'suspended'. We resume this context in the gesture, then hand it to Tone.
27
+ */
28
+ private rawCtx;
29
+ /**
30
+ * Initialize the audio engine (must be called from a user gesture handler).
31
+ *
32
+ * Strategy: graceful degradation. We bring up a synthesized piano voice
33
+ * FIRST — it needs no downloads and decodes nothing, so it is ready
34
+ * instantly and works on every browser including iOS Safari. Audio is
35
+ * considered ready at that point. We THEN try to load the richer
36
+ * Salamander samples in the background and swap them in if they finish.
37
+ *
38
+ * Why: Tone.Sampler's MP3 decode path hangs on iOS Safari (await
39
+ * Tone.loaded() never resolves), which previously stalled init forever.
40
+ * Loading samples off the critical path means iOS always gets working
41
+ * sound (synth) and merely misses the upgrade, instead of getting silence.
42
+ *
43
+ * On iOS Safari, Tone.start() must run during the synchronous portion of
44
+ * the gesture handler, which is why we pre-import the Tone module above.
45
+ */
46
+ init(): Promise<void>;
47
+ /**
48
+ * Attempt to load the Salamander sampler in the background and swap it in
49
+ * for the synth piano once (and only if) every sample has decoded. On iOS
50
+ * Safari this typically never completes, so we simply stay on the synth —
51
+ * no stall is ever surfaced because init already resolved.
52
+ */
53
+ private upgradeToSamplerInBackground;
54
+ /**
55
+ * Generate a reverb in the background and reroute the background pad through
56
+ * it once ready: pad → filter → reverb → destination. Reverb.generate()
57
+ * runs an OfflineAudioContext render that can hang on iOS Safari, so this is
58
+ * deliberately off the init critical path — if it never resolves, the pad
59
+ * simply stays dry. Raced against a timeout so we log and move on cleanly.
60
+ */
61
+ private addReverbInBackground;
62
+ /**
63
+ * MUST be called SYNCHRONOUSLY from a user-gesture handler (touchstart /
64
+ * click / keydown), before any await. Creates and resumes a raw
65
+ * AudioContext in the gesture task so iOS Safari actually unlocks audio.
66
+ * init() then adopts this context via Tone.setContext(). Idempotent.
67
+ */
68
+ unlock(): void;
69
+ /**
70
+ * iOS Safari can silently leave the AudioContext in 'suspended' or
71
+ * 'interrupted' state even after Tone.start() resolves, especially after
72
+ * tab backgrounding or initial activation. Call this synchronously from
73
+ * a user-gesture handler (or before playback) to wake it up. No-op when
74
+ * the context is already running.
75
+ */
26
76
  ensureRunning(): void;
27
- readonly context: AudioContext | null;
28
- };
29
- type AudioUnlocker = ReturnType<typeof createAudioUnlocker>;
77
+ /** Current AudioContext state, for diagnostics. */
78
+ get contextState(): string;
79
+ /**
80
+ * Play a MIDI note
81
+ */
82
+ playNote(midiNote: number, duration?: number, velocity?: number): void;
83
+ /**
84
+ * Play a scale degree in a given key
85
+ */
86
+ playScaleDegree(degree: number, key: string, octave?: number, duration?: number): void;
87
+ /**
88
+ * Start a sustained background chord (tonic chord)
89
+ */
90
+ startBackgroundChord(key: string, duration?: number): void;
91
+ /**
92
+ * Play a chord (list of MIDI notes) on the piano sampler.
93
+ */
94
+ playChord(midiNotes: number[], duration?: number, velocity?: number): void;
95
+ /**
96
+ * Play a melodic phrase: a sequence of scale degrees with configurable timing.
97
+ * Each note plays for `noteDuration` seconds, with `gap` ms gap between notes.
98
+ */
99
+ playPhrase(degrees: number[], key: string, octave: number, noteDuration?: number, gap?: number): Promise<void>;
100
+ /**
101
+ * Play a sequence of scale degrees
102
+ */
103
+ playSequence(degrees: number[], key: string, tempo?: number, octave?: number, onNoteStart?: (index: number) => void, onNoteEnd?: (index: number) => void): Promise<void>;
104
+ /**
105
+ * Utility to wait for a given duration
106
+ */
107
+ private wait;
108
+ /**
109
+ * Check if audio is ready
110
+ */
111
+ get isReady(): boolean;
112
+ }
113
+ declare const audio: AudioEngine;
30
114
 
31
- export { type AudioUnlocker, SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL, createAudioUnlocker, emitUnlockBlip };
115
+ export { AudioEngine, SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL, audio };
package/dist/audio.js CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ KEYS_PREFER_FLATS,
3
+ getMidiNote,
4
+ midiToNoteName
5
+ } from "./chunk-LLMDQM4C.js";
6
+
1
7
  // src/salamander.ts
2
8
  var SALAMANDER_CDN_BASE = "https://tonejs.github.io/audio/salamander/";
3
9
  var SALAMANDER_URLS_8 = {
@@ -43,61 +49,313 @@ var SALAMANDER_URLS_FULL = {
43
49
  C8: "C8.mp3"
44
50
  };
45
51
 
46
- // src/audioUnlock.ts
47
- function emitUnlockBlip(ctx) {
48
- try {
49
- const osc = ctx.createOscillator();
50
- const gain = ctx.createGain();
51
- gain.gain.value = 5e-4;
52
- osc.type = "sine";
53
- osc.frequency.value = 440;
54
- osc.connect(gain);
55
- gain.connect(ctx.destination);
56
- const t = ctx.currentTime;
57
- osc.start(t);
58
- osc.stop(t + 0.05);
59
- } catch {
52
+ // src/engine.ts
53
+ var browser = typeof window !== "undefined";
54
+ var tonePreload = browser ? import("tone") : null;
55
+ var AudioEngine = class {
56
+ piano = null;
57
+ synthPiano = null;
58
+ strings = null;
59
+ isInitialized = false;
60
+ isLoading = false;
61
+ samplerUpgradeStarted = false;
62
+ reverbStarted = false;
63
+ padFilter = null;
64
+ Tone = null;
65
+ /** Which piano voice is currently sounding — for diagnostics. */
66
+ voice = "none";
67
+ /** Last init milestone reached — surfaced on-device to pinpoint stalls. */
68
+ lastStage = "idle";
69
+ /**
70
+ * Raw AudioContext we create + resume SYNCHRONOUSLY inside the first user
71
+ * gesture. iOS Safari only unlocks audio when resume() is called directly
72
+ * in the gesture task; awaiting the Tone import first (as init() must) loses
73
+ * that activation, so Tone.start()'s resume() hangs with the context stuck
74
+ * 'suspended'. We resume this context in the gesture, then hand it to Tone.
75
+ */
76
+ rawCtx = null;
77
+ /**
78
+ * Initialize the audio engine (must be called from a user gesture handler).
79
+ *
80
+ * Strategy: graceful degradation. We bring up a synthesized piano voice
81
+ * FIRST — it needs no downloads and decodes nothing, so it is ready
82
+ * instantly and works on every browser including iOS Safari. Audio is
83
+ * considered ready at that point. We THEN try to load the richer
84
+ * Salamander samples in the background and swap them in if they finish.
85
+ *
86
+ * Why: Tone.Sampler's MP3 decode path hangs on iOS Safari (await
87
+ * Tone.loaded() never resolves), which previously stalled init forever.
88
+ * Loading samples off the critical path means iOS always gets working
89
+ * sound (synth) and merely misses the upgrade, instead of getting silence.
90
+ *
91
+ * On iOS Safari, Tone.start() must run during the synchronous portion of
92
+ * the gesture handler, which is why we pre-import the Tone module above.
93
+ */
94
+ async init() {
95
+ if (!browser) return;
96
+ if (this.isInitialized || this.isLoading) return;
97
+ this.isLoading = true;
98
+ this.lastStage = "init-start";
99
+ try {
100
+ this.Tone = await tonePreload;
101
+ this.lastStage = "tone-imported";
102
+ if (this.rawCtx) {
103
+ try {
104
+ this.Tone.setContext(this.rawCtx);
105
+ } catch (e) {
106
+ console.warn("[audio] setContext failed", e);
107
+ }
108
+ }
109
+ this.lastStage = "context-set:" + this.contextState;
110
+ await Promise.race([
111
+ this.Tone.start(),
112
+ new Promise((r) => setTimeout(r, 1500))
113
+ ]);
114
+ this.lastStage = "context-started:" + this.contextState;
115
+ this.synthPiano = new this.Tone.PolySynth(this.Tone.Synth, {
116
+ oscillator: { type: "triangle" },
117
+ envelope: { attack: 5e-3, decay: 0.5, sustain: 0.3, release: 1.2 }
118
+ }).toDestination();
119
+ this.piano = this.synthPiano;
120
+ this.voice = "synth";
121
+ this.lastStage = "piano-synth-ready";
122
+ this.padFilter = new this.Tone.Filter({
123
+ type: "lowpass",
124
+ frequency: 2e3,
125
+ rolloff: -12
126
+ }).toDestination();
127
+ this.strings = new this.Tone.PolySynth(this.Tone.Synth, {
128
+ oscillator: { type: "fatsawtooth", count: 3, spread: 30 },
129
+ envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 2 }
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ }).connect(this.padFilter);
132
+ this.strings.volume.value = -10;
133
+ this.isInitialized = true;
134
+ this.lastStage = "ready";
135
+ this.upgradeToSamplerInBackground();
136
+ this.addReverbInBackground();
137
+ } catch (error) {
138
+ this.lastStage = "error:" + (error instanceof Error ? error.message : String(error));
139
+ console.error("Failed to initialize audio:", error);
140
+ } finally {
141
+ this.isLoading = false;
142
+ }
60
143
  }
61
- }
62
- function createAudioUnlocker() {
63
- let ctx = null;
64
- let blipped = false;
65
- function ensureContext() {
66
- if (ctx) return ctx;
67
- if (typeof window === "undefined") return null;
68
- const AC = window.AudioContext || window.webkitAudioContext;
69
- if (!AC) return null;
70
- ctx = new AC();
71
- return ctx;
144
+ /**
145
+ * Attempt to load the Salamander sampler in the background and swap it in
146
+ * for the synth piano once (and only if) every sample has decoded. On iOS
147
+ * Safari this typically never completes, so we simply stay on the synth —
148
+ * no stall is ever surfaced because init already resolved.
149
+ */
150
+ upgradeToSamplerInBackground() {
151
+ if (!this.Tone || this.samplerUpgradeStarted) return;
152
+ this.samplerUpgradeStarted = true;
153
+ try {
154
+ const sampler = new this.Tone.Sampler({
155
+ urls: {
156
+ A0: "A0.mp3",
157
+ C1: "C1.mp3",
158
+ "D#1": "Ds1.mp3",
159
+ "F#1": "Fs1.mp3",
160
+ A1: "A1.mp3",
161
+ C2: "C2.mp3",
162
+ "D#2": "Ds2.mp3",
163
+ "F#2": "Fs2.mp3",
164
+ A2: "A2.mp3",
165
+ C3: "C3.mp3",
166
+ "D#3": "Ds3.mp3",
167
+ "F#3": "Fs3.mp3",
168
+ A3: "A3.mp3",
169
+ C4: "C4.mp3",
170
+ "D#4": "Ds4.mp3",
171
+ "F#4": "Fs4.mp3",
172
+ A4: "A4.mp3",
173
+ C5: "C5.mp3",
174
+ "D#5": "Ds5.mp3",
175
+ "F#5": "Fs5.mp3",
176
+ A5: "A5.mp3",
177
+ C6: "C6.mp3",
178
+ "D#6": "Ds6.mp3",
179
+ "F#6": "Fs6.mp3",
180
+ A6: "A6.mp3",
181
+ C7: "C7.mp3",
182
+ "D#7": "Ds7.mp3",
183
+ "F#7": "Fs7.mp3",
184
+ A7: "A7.mp3",
185
+ C8: "C8.mp3"
186
+ },
187
+ release: 1,
188
+ baseUrl: "/audio/salamander/",
189
+ onload: () => {
190
+ this.piano = sampler;
191
+ this.voice = "sampler";
192
+ console.info("[audio] upgraded piano voice to Salamander samples");
193
+ },
194
+ onerror: (e) => {
195
+ console.warn("[audio] sample load failed, staying on synth voice", e);
196
+ }
197
+ }).toDestination();
198
+ void sampler;
199
+ } catch (e) {
200
+ console.warn("[audio] sampler init threw, staying on synth voice", e);
201
+ }
72
202
  }
73
- return {
74
- /** Call SYNCHRONOUSLY from a user gesture. Idempotent. */
75
- unlock(onContext) {
76
- const c = ensureContext();
77
- if (!c) return;
78
- if (c.state !== "running") c.resume().catch(() => {
79
- });
80
- if (!blipped) {
81
- emitUnlockBlip(c);
82
- blipped = true;
203
+ /**
204
+ * Generate a reverb in the background and reroute the background pad through
205
+ * it once ready: pad → filter → reverb → destination. Reverb.generate()
206
+ * runs an OfflineAudioContext render that can hang on iOS Safari, so this is
207
+ * deliberately off the init critical path — if it never resolves, the pad
208
+ * simply stays dry. Raced against a timeout so we log and move on cleanly.
209
+ */
210
+ async addReverbInBackground() {
211
+ if (!this.Tone || !this.padFilter || this.reverbStarted) return;
212
+ this.reverbStarted = true;
213
+ try {
214
+ const reverb = new this.Tone.Reverb({ decay: 3, wet: 0.5 });
215
+ const generated = await Promise.race([
216
+ reverb.generate().then(() => true),
217
+ new Promise((r) => setTimeout(() => r(false), 4e3))
218
+ ]);
219
+ if (!generated) {
220
+ console.warn("[audio] reverb generate timed out, pad stays dry");
221
+ return;
222
+ }
223
+ reverb.toDestination();
224
+ this.padFilter.disconnect();
225
+ this.padFilter.connect(reverb);
226
+ console.info("[audio] reverb enabled");
227
+ } catch (e) {
228
+ console.warn("[audio] reverb generate failed, pad stays dry", e);
229
+ }
230
+ }
231
+ /**
232
+ * MUST be called SYNCHRONOUSLY from a user-gesture handler (touchstart /
233
+ * click / keydown), before any await. Creates and resumes a raw
234
+ * AudioContext in the gesture task so iOS Safari actually unlocks audio.
235
+ * init() then adopts this context via Tone.setContext(). Idempotent.
236
+ */
237
+ unlock() {
238
+ if (!browser) return;
239
+ try {
240
+ if (!this.rawCtx) {
241
+ const AC = window.AudioContext || window.webkitAudioContext;
242
+ if (!AC) return;
243
+ this.rawCtx = new AC();
244
+ }
245
+ if (this.rawCtx.state !== "running") {
246
+ this.rawCtx.resume().catch(() => {
247
+ });
83
248
  }
84
- onContext?.(c);
85
- },
86
- /** Resume the context if it has fallen back to suspended/interrupted. */
87
- ensureRunning() {
88
- if (ctx && ctx.state !== "running") ctx.resume().catch(() => {
249
+ } catch (e) {
250
+ console.warn("[audio] unlock failed", e);
251
+ }
252
+ }
253
+ /**
254
+ * iOS Safari can silently leave the AudioContext in 'suspended' or
255
+ * 'interrupted' state even after Tone.start() resolves, especially after
256
+ * tab backgrounding or initial activation. Call this synchronously from
257
+ * a user-gesture handler (or before playback) to wake it up. No-op when
258
+ * the context is already running.
259
+ */
260
+ ensureRunning() {
261
+ if (this.rawCtx && this.rawCtx.state !== "running") {
262
+ this.rawCtx.resume().catch(() => {
89
263
  });
90
- },
91
- get context() {
92
- return ctx;
93
264
  }
94
- };
95
- }
265
+ if (!this.Tone) return;
266
+ const ctx = this.Tone.getContext().rawContext;
267
+ if (ctx && ctx.state !== "running") {
268
+ ctx.resume().catch(() => {
269
+ });
270
+ }
271
+ }
272
+ /** Current AudioContext state, for diagnostics. */
273
+ get contextState() {
274
+ if (!this.Tone) return "no-tone";
275
+ const ctx = this.Tone.getContext().rawContext;
276
+ return ctx?.state ?? "no-context";
277
+ }
278
+ /**
279
+ * Play a MIDI note
280
+ */
281
+ playNote(midiNote, duration = 0.5, velocity = 0.8) {
282
+ if (!this.piano || !this.isInitialized) return;
283
+ this.ensureRunning();
284
+ const noteName = midiToNoteName(midiNote);
285
+ this.piano.triggerAttackRelease(noteName, duration, this.Tone.now(), velocity);
286
+ }
287
+ /**
288
+ * Play a scale degree in a given key
289
+ */
290
+ playScaleDegree(degree, key, octave = 4, duration = 0.5) {
291
+ const midiNote = getMidiNote(degree, key, octave);
292
+ this.playNote(midiNote, duration);
293
+ }
294
+ /**
295
+ * Start a sustained background chord (tonic chord)
296
+ */
297
+ startBackgroundChord(key, duration = 4) {
298
+ if (!this.strings || !this.isInitialized) return;
299
+ this.ensureRunning();
300
+ const useFlats = KEYS_PREFER_FLATS.includes(key);
301
+ const root4 = getMidiNote(1, key, 4);
302
+ const fifth4 = getMidiNote(5, key, 4);
303
+ const root5 = getMidiNote(1, key, 5);
304
+ const third5 = getMidiNote(3, key, 5);
305
+ const notes = [root4, fifth4, root5, third5].map((m) => midiToNoteName(m, useFlats));
306
+ this.strings.triggerAttackRelease(notes, duration, this.Tone.now(), 0.3);
307
+ }
308
+ /**
309
+ * Play a chord (list of MIDI notes) on the piano sampler.
310
+ */
311
+ playChord(midiNotes, duration = 1.5, velocity = 0.7) {
312
+ if (!this.piano || !this.isInitialized) return;
313
+ this.ensureRunning();
314
+ const names = midiNotes.map((m) => midiToNoteName(m));
315
+ for (const n of names) {
316
+ this.piano.triggerAttackRelease(n, duration, this.Tone.now(), velocity);
317
+ }
318
+ }
319
+ /**
320
+ * Play a melodic phrase: a sequence of scale degrees with configurable timing.
321
+ * Each note plays for `noteDuration` seconds, with `gap` ms gap between notes.
322
+ */
323
+ async playPhrase(degrees, key, octave, noteDuration = 0.4, gap = 100) {
324
+ for (let i = 0; i < degrees.length; i++) {
325
+ this.playScaleDegree(degrees[i], key, octave, noteDuration);
326
+ await this.wait(Math.round(noteDuration * 1e3) + gap);
327
+ }
328
+ }
329
+ /**
330
+ * Play a sequence of scale degrees
331
+ */
332
+ async playSequence(degrees, key, tempo = 500, octave = 4, onNoteStart, onNoteEnd) {
333
+ for (let i = 0; i < degrees.length; i++) {
334
+ onNoteStart?.(i);
335
+ this.playScaleDegree(degrees[i], key, octave, 0.45);
336
+ await this.wait(tempo);
337
+ onNoteEnd?.(i);
338
+ }
339
+ }
340
+ /**
341
+ * Utility to wait for a given duration
342
+ */
343
+ wait(ms) {
344
+ return new Promise((resolve) => setTimeout(resolve, ms));
345
+ }
346
+ /**
347
+ * Check if audio is ready
348
+ */
349
+ get isReady() {
350
+ return this.isInitialized;
351
+ }
352
+ };
353
+ var audio = new AudioEngine();
96
354
  export {
355
+ AudioEngine,
97
356
  SALAMANDER_CDN_BASE,
98
357
  SALAMANDER_URLS_8,
99
358
  SALAMANDER_URLS_FULL,
100
- createAudioUnlocker,
101
- emitUnlockBlip
359
+ audio
102
360
  };
103
361
  //# sourceMappingURL=audio.js.map
package/dist/audio.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/salamander.ts","../src/audioUnlock.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","/**\n * Emit a near-silent, very short tone on the given AudioContext. iOS Safari\n * only unlocks audio if actual output is produced DURING the user gesture —\n * resuming the context alone is not enough. Call this synchronously inside a\n * click/pointerdown handler. (This is the trick RET's old unlock() omitted.)\n */\nexport function emitUnlockBlip(ctx: AudioContext): void {\n try {\n const osc = ctx.createOscillator();\n const gain = ctx.createGain();\n gain.gain.value = 0.0005; // effectively inaudible\n osc.type = 'sine';\n osc.frequency.value = 440;\n osc.connect(gain);\n gain.connect(ctx.destination);\n const t = ctx.currentTime;\n osc.start(t);\n osc.stop(t + 0.05);\n } catch {\n /* best-effort: never throw out of an unlock path */\n }\n}\n\ntype ToneContextSetter = (ctx: AudioContext) => void;\n\n/**\n * Owns a raw AudioContext that is created + resumed SYNCHRONOUSLY inside the\n * first user gesture, then emits an unlock blip on it. Hand the context to your\n * audio library (e.g. Tone.setContext) via the onContext callback so the lib\n * runs on an already-running context (so its own start()/resume() can't hang\n * on iOS). Tone-agnostic — works with any version or no Tone at all.\n */\nexport function createAudioUnlocker() {\n let ctx: AudioContext | null = null;\n let blipped = false;\n\n function ensureContext(): AudioContext | null {\n if (ctx) return ctx;\n if (typeof window === 'undefined') return null;\n const AC = (window.AudioContext ||\n (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext) as\n | typeof AudioContext\n | undefined;\n if (!AC) return null;\n ctx = new AC();\n return ctx;\n }\n\n return {\n /** Call SYNCHRONOUSLY from a user gesture. Idempotent. */\n unlock(onContext?: ToneContextSetter): void {\n const c = ensureContext();\n if (!c) return;\n if (c.state !== 'running') c.resume().catch(() => {});\n if (!blipped) { emitUnlockBlip(c); blipped = true; }\n onContext?.(c);\n },\n /** Resume the context if it has fallen back to suspended/interrupted. */\n ensureRunning(): void {\n if (ctx && ctx.state !== 'running') ctx.resume().catch(() => {});\n },\n get context(): AudioContext | null { return ctx; },\n };\n}\n\nexport type AudioUnlocker = ReturnType<typeof createAudioUnlocker>;\n"],"mappings":";AAAO,IAAM,sBAAsB;AAG5B,IAAM,oBAA4C;AAAA,EACvD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AAAA,EACrD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AACvD;AAGO,IAAM,uBAA+C;AAAA,EAC1D,IAAI;AAAA,EAAU,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACpE,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AACN;;;ACZO,SAAS,eAAe,KAAyB;AACtD,MAAI;AACF,UAAM,MAAM,IAAI,iBAAiB;AACjC,UAAM,OAAO,IAAI,WAAW;AAC5B,SAAK,KAAK,QAAQ;AAClB,QAAI,OAAO;AACX,QAAI,UAAU,QAAQ;AACtB,QAAI,QAAQ,IAAI;AAChB,SAAK,QAAQ,IAAI,WAAW;AAC5B,UAAM,IAAI,IAAI;AACd,QAAI,MAAM,CAAC;AACX,QAAI,KAAK,IAAI,IAAI;AAAA,EACnB,QAAQ;AAAA,EAER;AACF;AAWO,SAAS,sBAAsB;AACpC,MAAI,MAA2B;AAC/B,MAAI,UAAU;AAEd,WAAS,gBAAqC;AAC5C,QAAI,IAAK,QAAO;AAChB,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,UAAM,KAAM,OAAO,gBAChB,OAAmE;AAGtE,QAAI,CAAC,GAAI,QAAO;AAChB,UAAM,IAAI,GAAG;AACb,WAAO;AAAA,EACT;AAEA,SAAO;AAAA;AAAA,IAEL,OAAO,WAAqC;AAC1C,YAAM,IAAI,cAAc;AACxB,UAAI,CAAC,EAAG;AACR,UAAI,EAAE,UAAU,UAAW,GAAE,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACpD,UAAI,CAAC,SAAS;AAAE,uBAAe,CAAC;AAAG,kBAAU;AAAA,MAAM;AACnD,kBAAY,CAAC;AAAA,IACf;AAAA;AAAA,IAEA,gBAAsB;AACpB,UAAI,OAAO,IAAI,UAAU,UAAW,KAAI,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACjE;AAAA,IACA,IAAI,UAA+B;AAAE,aAAO;AAAA,IAAK;AAAA,EACnD;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/salamander.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","import { getMidiNote } from './scales';\nimport { midiToNoteName } from './notes';\nimport { KEYS_PREFER_FLATS } from './enharmonic';\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 = new this.Tone.Sampler({\n\t\t\t\turls: {\n\t\t\t\t\tA0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3',\n\t\t\t\t\tC2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3',\n\t\t\t\t\tC3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3',\n\t\t\t\t\tC4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3',\n\t\t\t\t\tC5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3',\n\t\t\t\t\tC6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3',\n\t\t\t\t\tC7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3',\n\t\t\t\t\tC8: 'C8.mp3',\n\t\t\t\t},\n\t\t\t\trelease: 1,\n\t\t\t\tbaseUrl: '/audio/salamander/',\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}).toDestination();\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 = new this.Tone.Reverb({ decay: 3, wet: 0.5 });\n\t\t\tconst generated = await Promise.race([\n\t\t\t\treverb.generate().then(() => true),\n\t\t\t\tnew Promise<boolean>((r) => setTimeout(() => r(false), 4000)),\n\t\t\t]);\n\t\t\tif (!generated) {\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":[]}
@@ -0,0 +1,75 @@
1
+ // src/notes.ts
2
+ var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
3
+ var NOTE_NAMES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
4
+ var LETTER_TO_INDEX = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
5
+ function noteNameToIndex(note) {
6
+ const letter = note[0]?.toUpperCase();
7
+ let index = LETTER_TO_INDEX[letter];
8
+ if (index === void 0) throw new Error(`Invalid note name: ${note}`);
9
+ for (const ch of note.slice(1)) {
10
+ if (ch === "#") index += 1;
11
+ else if (ch === "b") index -= 1;
12
+ }
13
+ return (index % 12 + 12) % 12;
14
+ }
15
+ function pitchClass(midi) {
16
+ return (midi % 12 + 12) % 12;
17
+ }
18
+ function midiToNoteName(midi, useFlats = false) {
19
+ const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;
20
+ const octave = Math.floor(midi / 12) - 1;
21
+ return `${names[pitchClass(midi)]}${octave}`;
22
+ }
23
+
24
+ // src/scales.ts
25
+ var MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11];
26
+ var ALL_KEYS = ["C", "G", "D", "A", "E", "B", "F#", "F", "Bb", "Eb", "Ab", "Db"];
27
+ function getMidiNote(degree, key, octave = 4) {
28
+ const rootIndex = noteNameToIndex(key);
29
+ const actualDegree = ((degree - 1) % 7 + 7) % 7;
30
+ const octaveShift = Math.floor((degree - 1) / 7);
31
+ const semitones = MAJOR_SCALE_INTERVALS[actualDegree];
32
+ return rootIndex + semitones + (octave + octaveShift) * 12;
33
+ }
34
+ function getScaleDegree(midiNote, key) {
35
+ const rootIndex = noteNameToIndex(key);
36
+ const noteInOctave = (midiNote % 12 - rootIndex + 12) % 12;
37
+ const degreeIndex = MAJOR_SCALE_INTERVALS.indexOf(noteInOctave);
38
+ return degreeIndex !== -1 ? degreeIndex + 1 : null;
39
+ }
40
+ function isInScale(midiNote, key) {
41
+ return getScaleDegree(midiNote, key) !== null;
42
+ }
43
+ function getStability(degree) {
44
+ if ([1, 3, 5].includes(degree)) return "stable";
45
+ if ([2, 4, 6].includes(degree)) return "lessStable";
46
+ if (degree === 7) return "unstable";
47
+ return null;
48
+ }
49
+
50
+ // src/enharmonic.ts
51
+ var KEYS_PREFER_FLATS = ["F", "Bb", "Eb", "Ab", "Db", "Gb"];
52
+ function useFlatsForKeyName(key) {
53
+ return KEYS_PREFER_FLATS.includes(key);
54
+ }
55
+ function useFlatsForKeyFifths(fifths) {
56
+ return fifths < 0;
57
+ }
58
+
59
+ export {
60
+ NOTE_NAMES,
61
+ NOTE_NAMES_FLAT,
62
+ noteNameToIndex,
63
+ pitchClass,
64
+ midiToNoteName,
65
+ MAJOR_SCALE_INTERVALS,
66
+ ALL_KEYS,
67
+ getMidiNote,
68
+ getScaleDegree,
69
+ isInScale,
70
+ getStability,
71
+ KEYS_PREFER_FLATS,
72
+ useFlatsForKeyName,
73
+ useFlatsForKeyFifths
74
+ };
75
+ //# sourceMappingURL=chunk-LLMDQM4C.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notes.ts","../src/scales.ts","../src/enharmonic.ts"],"sourcesContent":["export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;\nexport const NOTE_NAMES_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] as const;\n\nconst LETTER_TO_INDEX: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };\n\n/** Note name (e.g. \"C\", \"C#\", \"Db\", \"Cb\") → pitch class 0..11. */\nexport function noteNameToIndex(note: string): number {\n const letter = note[0]?.toUpperCase();\n let index = LETTER_TO_INDEX[letter];\n if (index === undefined) throw new Error(`Invalid note name: ${note}`);\n for (const ch of note.slice(1)) {\n if (ch === '#') index += 1;\n else if (ch === 'b') index -= 1;\n }\n return ((index % 12) + 12) % 12;\n}\n\n/** MIDI number → pitch class 0..11. */\nexport function pitchClass(midi: number): number {\n return ((midi % 12) + 12) % 12;\n}\n\n/** MIDI → note name with octave, e.g. 61 → \"C#4\" (or \"Db4\" with useFlats). */\nexport function midiToNoteName(midi: number, useFlats = false): string {\n const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;\n const octave = Math.floor(midi / 12) - 1;\n return `${names[pitchClass(midi)]}${octave}`;\n}\n","import { noteNameToIndex } from './notes';\n\nexport const MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11] as const;\nexport const ALL_KEYS = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'F', 'Bb', 'Eb', 'Ab', 'Db'] as const;\n\n/**\n * Get MIDI note number for a scale degree in a given key.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n * NOTE: RET uses octave*12 (not (octave+1)*12), so getMidiNote(1,'C',4)=48,\n * not 60. This is RET's established convention — consumers must account for it.\n */\nexport function getMidiNote(degree: number, key: string, octave = 4): number {\n const rootIndex = noteNameToIndex(key);\n const actualDegree = ((degree - 1) % 7 + 7) % 7;\n const octaveShift = Math.floor((degree - 1) / 7);\n const semitones = MAJOR_SCALE_INTERVALS[actualDegree];\n return rootIndex + semitones + ((octave + octaveShift) * 12);\n}\n\n/**\n * Get the scale degree (1-7) for a MIDI note in a key, or null if not in scale.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getScaleDegree(midiNote: number, key: string): number | null {\n const rootIndex = noteNameToIndex(key);\n const noteInOctave = ((midiNote % 12) - rootIndex + 12) % 12;\n const degreeIndex = (MAJOR_SCALE_INTERVALS as readonly number[]).indexOf(noteInOctave);\n return degreeIndex !== -1 ? degreeIndex + 1 : null;\n}\n\n/**\n * Check if a MIDI note is in the given key.\n */\nexport function isInScale(midiNote: number, key: string): boolean {\n return getScaleDegree(midiNote, key) !== null;\n}\n\n/**\n * Get stability category for a scale degree.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getStability(degree: number): 'stable' | 'lessStable' | 'unstable' | null {\n if ([1, 3, 5].includes(degree)) return 'stable';\n if ([2, 4, 6].includes(degree)) return 'lessStable';\n if (degree === 7) return 'unstable';\n return null;\n}\n","/** Keys conventionally spelled with flats (RET's key-name model). */\nexport const KEYS_PREFER_FLATS = ['F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb'] as const;\n\n/** Whether to use flat spellings for a key given by name (e.g. \"Eb\"). */\nexport function useFlatsForKeyName(key: string): boolean {\n return (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n}\n\n/**\n * Whether to use flat spellings for a key given by its key-signature \"fifths\"\n * value (Stave's model: -7..+7, negative = flat keys). 0 (C) and positive\n * (sharp keys) use sharps.\n */\nexport function useFlatsForKeyFifths(fifths: number): boolean {\n return fifths < 0;\n}\n"],"mappings":";AAAO,IAAM,aAAa,CAAC,KAAK,MAAM,KAAK,MAAM,KAAK,KAAK,MAAM,KAAK,MAAM,KAAK,MAAM,GAAG;AACnF,IAAM,kBAAkB,CAAC,KAAK,MAAM,KAAK,MAAM,KAAK,KAAK,MAAM,KAAK,MAAM,KAAK,MAAM,GAAG;AAE/F,IAAM,kBAA0C,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG;AAGrF,SAAS,gBAAgB,MAAsB;AACpD,QAAM,SAAS,KAAK,CAAC,GAAG,YAAY;AACpC,MAAI,QAAQ,gBAAgB,MAAM;AAClC,MAAI,UAAU,OAAW,OAAM,IAAI,MAAM,sBAAsB,IAAI,EAAE;AACrE,aAAW,MAAM,KAAK,MAAM,CAAC,GAAG;AAC9B,QAAI,OAAO,IAAK,UAAS;AAAA,aAChB,OAAO,IAAK,UAAS;AAAA,EAChC;AACA,UAAS,QAAQ,KAAM,MAAM;AAC/B;AAGO,SAAS,WAAW,MAAsB;AAC/C,UAAS,OAAO,KAAM,MAAM;AAC9B;AAGO,SAAS,eAAe,MAAc,WAAW,OAAe;AACrE,QAAM,QAAQ,WAAW,kBAAkB;AAC3C,QAAM,SAAS,KAAK,MAAM,OAAO,EAAE,IAAI;AACvC,SAAO,GAAG,MAAM,WAAW,IAAI,CAAC,CAAC,GAAG,MAAM;AAC5C;;;ACzBO,IAAM,wBAAwB,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE;AACnD,IAAM,WAAW,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,MAAM,KAAK,MAAM,MAAM,MAAM,IAAI;AAQjF,SAAS,YAAY,QAAgB,KAAa,SAAS,GAAW;AAC3E,QAAM,YAAY,gBAAgB,GAAG;AACrC,QAAM,iBAAiB,SAAS,KAAK,IAAI,KAAK;AAC9C,QAAM,cAAc,KAAK,OAAO,SAAS,KAAK,CAAC;AAC/C,QAAM,YAAY,sBAAsB,YAAY;AACpD,SAAO,YAAY,aAAc,SAAS,eAAe;AAC3D;AAMO,SAAS,eAAe,UAAkB,KAA4B;AAC3E,QAAM,YAAY,gBAAgB,GAAG;AACrC,QAAM,gBAAiB,WAAW,KAAM,YAAY,MAAM;AAC1D,QAAM,cAAe,sBAA4C,QAAQ,YAAY;AACrF,SAAO,gBAAgB,KAAK,cAAc,IAAI;AAChD;AAKO,SAAS,UAAU,UAAkB,KAAsB;AAChE,SAAO,eAAe,UAAU,GAAG,MAAM;AAC3C;AAMO,SAAS,aAAa,QAA6D;AACxF,MAAI,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,MAAM,EAAG,QAAO;AACvC,MAAI,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,MAAM,EAAG,QAAO;AACvC,MAAI,WAAW,EAAG,QAAO;AACzB,SAAO;AACT;;;AC7CO,IAAM,oBAAoB,CAAC,KAAK,MAAM,MAAM,MAAM,MAAM,IAAI;AAG5D,SAAS,mBAAmB,KAAsB;AACvD,SAAQ,kBAAwC,SAAS,GAAG;AAC9D;AAOO,SAAS,qBAAqB,QAAyB;AAC5D,SAAO,SAAS;AAClB;","names":[]}
package/dist/index.js CHANGED
@@ -1,66 +1,25 @@
1
- // src/notes.ts
2
- var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
3
- var NOTE_NAMES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
4
- var LETTER_TO_INDEX = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
5
- function noteNameToIndex(note) {
6
- const letter = note[0]?.toUpperCase();
7
- let index = LETTER_TO_INDEX[letter];
8
- if (index === void 0) throw new Error(`Invalid note name: ${note}`);
9
- for (const ch of note.slice(1)) {
10
- if (ch === "#") index += 1;
11
- else if (ch === "b") index -= 1;
12
- }
13
- return (index % 12 + 12) % 12;
14
- }
15
- function pitchClass(midi) {
16
- return (midi % 12 + 12) % 12;
17
- }
18
- function midiToNoteName(midi, useFlats = false) {
19
- const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;
20
- const octave = Math.floor(midi / 12) - 1;
21
- return `${names[pitchClass(midi)]}${octave}`;
22
- }
1
+ import {
2
+ ALL_KEYS,
3
+ KEYS_PREFER_FLATS,
4
+ MAJOR_SCALE_INTERVALS,
5
+ NOTE_NAMES,
6
+ NOTE_NAMES_FLAT,
7
+ getMidiNote,
8
+ getScaleDegree,
9
+ getStability,
10
+ isInScale,
11
+ midiToNoteName,
12
+ noteNameToIndex,
13
+ pitchClass,
14
+ useFlatsForKeyFifths,
15
+ useFlatsForKeyName
16
+ } from "./chunk-LLMDQM4C.js";
23
17
 
24
18
  // src/frequency.ts
25
19
  function midiToFrequency(midi) {
26
20
  return 440 * Math.pow(2, (midi - 69) / 12);
27
21
  }
28
22
 
29
- // src/enharmonic.ts
30
- var KEYS_PREFER_FLATS = ["F", "Bb", "Eb", "Ab", "Db", "Gb"];
31
- function useFlatsForKeyName(key) {
32
- return KEYS_PREFER_FLATS.includes(key);
33
- }
34
- function useFlatsForKeyFifths(fifths) {
35
- return fifths < 0;
36
- }
37
-
38
- // src/scales.ts
39
- var MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11];
40
- var ALL_KEYS = ["C", "G", "D", "A", "E", "B", "F#", "F", "Bb", "Eb", "Ab", "Db"];
41
- function getMidiNote(degree, key, octave = 4) {
42
- const rootIndex = noteNameToIndex(key);
43
- const actualDegree = ((degree - 1) % 7 + 7) % 7;
44
- const octaveShift = Math.floor((degree - 1) / 7);
45
- const semitones = MAJOR_SCALE_INTERVALS[actualDegree];
46
- return rootIndex + semitones + (octave + octaveShift) * 12;
47
- }
48
- function getScaleDegree(midiNote, key) {
49
- const rootIndex = noteNameToIndex(key);
50
- const noteInOctave = (midiNote % 12 - rootIndex + 12) % 12;
51
- const degreeIndex = MAJOR_SCALE_INTERVALS.indexOf(noteInOctave);
52
- return degreeIndex !== -1 ? degreeIndex + 1 : null;
53
- }
54
- function isInScale(midiNote, key) {
55
- return getScaleDegree(midiNote, key) !== null;
56
- }
57
- function getStability(degree) {
58
- if ([1, 3, 5].includes(degree)) return "stable";
59
- if ([2, 4, 6].includes(degree)) return "lessStable";
60
- if (degree === 7) return "unstable";
61
- return null;
62
- }
63
-
64
23
  // src/intervals.ts
65
24
  var INTERVALS = [
66
25
  { semitones: 1, label: "m2", mnemonic: "Jaws theme" },
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/notes.ts","../src/frequency.ts","../src/enharmonic.ts","../src/scales.ts","../src/intervals.ts","../src/chords.ts"],"sourcesContent":["export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;\nexport const NOTE_NAMES_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] as const;\n\nconst LETTER_TO_INDEX: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };\n\n/** Note name (e.g. \"C\", \"C#\", \"Db\", \"Cb\") → pitch class 0..11. */\nexport function noteNameToIndex(note: string): number {\n const letter = note[0]?.toUpperCase();\n let index = LETTER_TO_INDEX[letter];\n if (index === undefined) throw new Error(`Invalid note name: ${note}`);\n for (const ch of note.slice(1)) {\n if (ch === '#') index += 1;\n else if (ch === 'b') index -= 1;\n }\n return ((index % 12) + 12) % 12;\n}\n\n/** MIDI number → pitch class 0..11. */\nexport function pitchClass(midi: number): number {\n return ((midi % 12) + 12) % 12;\n}\n\n/** MIDI → note name with octave, e.g. 61 → \"C#4\" (or \"Db4\" with useFlats). */\nexport function midiToNoteName(midi: number, useFlats = false): string {\n const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;\n const octave = Math.floor(midi / 12) - 1;\n return `${names[pitchClass(midi)]}${octave}`;\n}\n","/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","/** Keys conventionally spelled with flats (RET's key-name model). */\nexport const KEYS_PREFER_FLATS = ['F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb'] as const;\n\n/** Whether to use flat spellings for a key given by name (e.g. \"Eb\"). */\nexport function useFlatsForKeyName(key: string): boolean {\n return (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n}\n\n/**\n * Whether to use flat spellings for a key given by its key-signature \"fifths\"\n * value (Stave's model: -7..+7, negative = flat keys). 0 (C) and positive\n * (sharp keys) use sharps.\n */\nexport function useFlatsForKeyFifths(fifths: number): boolean {\n return fifths < 0;\n}\n","import { noteNameToIndex } from './notes';\n\nexport const MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11] as const;\nexport const ALL_KEYS = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'F', 'Bb', 'Eb', 'Ab', 'Db'] as const;\n\n/**\n * Get MIDI note number for a scale degree in a given key.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n * NOTE: RET uses octave*12 (not (octave+1)*12), so getMidiNote(1,'C',4)=48,\n * not 60. This is RET's established convention — consumers must account for it.\n */\nexport function getMidiNote(degree: number, key: string, octave = 4): number {\n const rootIndex = noteNameToIndex(key);\n const actualDegree = ((degree - 1) % 7 + 7) % 7;\n const octaveShift = Math.floor((degree - 1) / 7);\n const semitones = MAJOR_SCALE_INTERVALS[actualDegree];\n return rootIndex + semitones + ((octave + octaveShift) * 12);\n}\n\n/**\n * Get the scale degree (1-7) for a MIDI note in a key, or null if not in scale.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getScaleDegree(midiNote: number, key: string): number | null {\n const rootIndex = noteNameToIndex(key);\n const noteInOctave = ((midiNote % 12) - rootIndex + 12) % 12;\n const degreeIndex = (MAJOR_SCALE_INTERVALS as readonly number[]).indexOf(noteInOctave);\n return degreeIndex !== -1 ? degreeIndex + 1 : null;\n}\n\n/**\n * Check if a MIDI note is in the given key.\n */\nexport function isInScale(midiNote: number, key: string): boolean {\n return getScaleDegree(midiNote, key) !== null;\n}\n\n/**\n * Get stability category for a scale degree.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getStability(degree: number): 'stable' | 'lessStable' | 'unstable' | null {\n if ([1, 3, 5].includes(degree)) return 'stable';\n if ([2, 4, 6].includes(degree)) return 'lessStable';\n if (degree === 7) return 'unstable';\n return null;\n}\n","export interface IntervalDef { semitones: number; label: string; mnemonic: string }\n\nexport const INTERVALS: IntervalDef[] = [\n { semitones: 1, label: 'm2', mnemonic: 'Jaws theme' },\n { semitones: 2, label: 'M2', mnemonic: 'Happy Birthday opening' },\n { semitones: 3, label: 'm3', mnemonic: \"Brahms' Lullaby\" },\n { semitones: 4, label: 'M3', mnemonic: 'When the Saints' },\n { semitones: 5, label: 'P4', mnemonic: 'Here Comes the Bride' },\n { semitones: 6, label: 'TT', mnemonic: 'The Simpsons theme' },\n { semitones: 7, label: 'P5', mnemonic: 'Twinkle Twinkle' },\n { semitones: 8, label: 'm6', mnemonic: 'Love Story theme' },\n { semitones: 9, label: 'M6', mnemonic: 'NBC chimes' },\n { semitones: 10, label: 'm7', mnemonic: 'Somewhere (West Side Story)' },\n { semitones: 11, label: 'M7', mnemonic: 'Take On Me chorus' },\n { semitones: 12, label: 'P8', mnemonic: 'Somewhere Over the Rainbow' },\n];\n\nexport function intervalBySemitones(semitones: number): IntervalDef | undefined {\n return INTERVALS.find(i => i.semitones === semitones);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/frequency.ts","../src/intervals.ts","../src/chords.ts"],"sourcesContent":["/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","export interface IntervalDef { semitones: number; label: string; mnemonic: string }\n\nexport const INTERVALS: IntervalDef[] = [\n { semitones: 1, label: 'm2', mnemonic: 'Jaws theme' },\n { semitones: 2, label: 'M2', mnemonic: 'Happy Birthday opening' },\n { semitones: 3, label: 'm3', mnemonic: \"Brahms' Lullaby\" },\n { semitones: 4, label: 'M3', mnemonic: 'When the Saints' },\n { semitones: 5, label: 'P4', mnemonic: 'Here Comes the Bride' },\n { semitones: 6, label: 'TT', mnemonic: 'The Simpsons theme' },\n { semitones: 7, label: 'P5', mnemonic: 'Twinkle Twinkle' },\n { semitones: 8, label: 'm6', mnemonic: 'Love Story theme' },\n { semitones: 9, label: 'M6', mnemonic: 'NBC chimes' },\n { semitones: 10, label: 'm7', mnemonic: 'Somewhere (West Side Story)' },\n { semitones: 11, label: 'M7', mnemonic: 'Take On Me chorus' },\n { semitones: 12, label: 'P8', mnemonic: 'Somewhere Over the Rainbow' },\n];\n\nexport function intervalBySemitones(semitones: number): IntervalDef | undefined {\n return INTERVALS.find(i => i.semitones === semitones);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;AACO,SAAS,gBAAgB,MAAsB;AACpD,SAAO,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,EAAE;AAC3C;;;ACDO,IAAM,YAA2B;AAAA,EACtC,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,yBAAyB;AAAA,EACjE,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,uBAAuB;AAAA,EAC/D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,qBAAqB;AAAA,EAC7D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,kBAAkB;AAAA,EAC1D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,mBAAmB;AAAA,EAC3D,EAAE,WAAW,GAAI,OAAO,MAAM,UAAU,aAAa;AAAA,EACrD,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,8BAA8B;AAAA,EACtE,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,oBAAoB;AAAA,EAC5D,EAAE,WAAW,IAAI,OAAO,MAAM,UAAU,6BAA6B;AACvE;AAEO,SAAS,oBAAoB,WAA4C;AAC9E,SAAO,UAAU,KAAK,OAAK,EAAE,cAAc,SAAS;AACtD;;;ACfO,IAAM,kBAAmC;AAAA,EAC9C,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,MAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,CAAC,EAAE;AAAA,EACxC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,GAAG,EAAE,EAAE;AAAA,EACzC,EAAE,MAAM,IAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,KAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,OAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,EACrC,EAAE,MAAM,QAAQ,WAAW,CAAC,GAAG,GAAG,CAAC,EAAE;AACvC;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-music-packages/web-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -22,7 +22,14 @@
22
22
  "access": "public"
23
23
  },
24
24
  "repository": { "type": "git", "url": "git+https://github.com/e7mac/web-core.git" },
25
+ "peerDependencies": {
26
+ "tone": ">=14"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "tone": { "optional": true }
30
+ },
25
31
  "devDependencies": {
32
+ "tone": "^15.1.22",
26
33
  "tsup": "^8.3.0",
27
34
  "typescript": "^5.6.0",
28
35
  "vitest": "^2.1.0"