@real-music-packages/web-core 0.1.0 → 0.3.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 +127 -23
- package/dist/audio.js +292 -46
- package/dist/audio.js.map +1 -1
- package/dist/chunk-LLMDQM4C.js +75 -0
- package/dist/chunk-LLMDQM4C.js.map +1 -0
- package/dist/index.js +16 -57
- package/dist/index.js.map +1 -1
- package/package.json +8 -1
package/dist/audio.d.ts
CHANGED
|
@@ -4,28 +4,132 @@ 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
115
|
+
type ToneModule = any;
|
|
116
|
+
interface SalamanderSamplerOpts {
|
|
117
|
+
urls: Record<string, string>;
|
|
118
|
+
baseUrl: string;
|
|
119
|
+
release: number;
|
|
120
|
+
volumeDb?: number;
|
|
121
|
+
onload?: () => void;
|
|
122
|
+
onerror?: (e: unknown) => void;
|
|
123
|
+
}
|
|
124
|
+
/** Create a Salamander Tone.Sampler routed to destination. Mirrors the exact
|
|
125
|
+
* `new Tone.Sampler({...}).toDestination()` both engines did inline. */
|
|
126
|
+
declare function createSalamanderSampler(Tone: ToneModule, opts: SalamanderSamplerOpts): any;
|
|
127
|
+
/** Generate a Tone.Reverb, racing reverb.generate() against a timeout so it can
|
|
128
|
+
* never hang init on iOS. Returns the reverb if generated, else null. Caller
|
|
129
|
+
* wires routing (toDestination / reroute). Mirrors RET's addReverbInBackground. */
|
|
130
|
+
declare function generateReverb(Tone: ToneModule, opts: {
|
|
131
|
+
decay: number;
|
|
132
|
+
wet: number;
|
|
133
|
+
}, timeoutMs: number): Promise<any | null>;
|
|
134
|
+
|
|
135
|
+
export { AudioEngine, SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL, type SalamanderSamplerOpts, audio, createSalamanderSampler, generateReverb };
|
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,301 @@ var SALAMANDER_URLS_FULL = {
|
|
|
43
49
|
C8: "C8.mp3"
|
|
44
50
|
};
|
|
45
51
|
|
|
46
|
-
// src/
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
osc.start(t);
|
|
58
|
-
osc.stop(t + 0.05);
|
|
59
|
-
} catch {
|
|
60
|
-
}
|
|
52
|
+
// src/audioHelpers.ts
|
|
53
|
+
function createSalamanderSampler(Tone, opts) {
|
|
54
|
+
const sampler = new Tone.Sampler({
|
|
55
|
+
urls: opts.urls,
|
|
56
|
+
baseUrl: opts.baseUrl,
|
|
57
|
+
release: opts.release,
|
|
58
|
+
onload: opts.onload,
|
|
59
|
+
onerror: opts.onerror
|
|
60
|
+
}).toDestination();
|
|
61
|
+
if (opts.volumeDb !== void 0) sampler.volume.value = opts.volumeDb;
|
|
62
|
+
return sampler;
|
|
61
63
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
async function generateReverb(Tone, opts, timeoutMs) {
|
|
65
|
+
const reverb = new Tone.Reverb(opts);
|
|
66
|
+
const ok = await Promise.race([
|
|
67
|
+
reverb.generate().then(() => true),
|
|
68
|
+
new Promise((r) => setTimeout(() => r(false), timeoutMs))
|
|
69
|
+
]);
|
|
70
|
+
return ok ? reverb : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/engine.ts
|
|
74
|
+
var browser = typeof window !== "undefined";
|
|
75
|
+
var tonePreload = browser ? import("tone") : null;
|
|
76
|
+
var AudioEngine = class {
|
|
77
|
+
piano = null;
|
|
78
|
+
synthPiano = null;
|
|
79
|
+
strings = null;
|
|
80
|
+
isInitialized = false;
|
|
81
|
+
isLoading = false;
|
|
82
|
+
samplerUpgradeStarted = false;
|
|
83
|
+
reverbStarted = false;
|
|
84
|
+
padFilter = null;
|
|
85
|
+
Tone = null;
|
|
86
|
+
/** Which piano voice is currently sounding — for diagnostics. */
|
|
87
|
+
voice = "none";
|
|
88
|
+
/** Last init milestone reached — surfaced on-device to pinpoint stalls. */
|
|
89
|
+
lastStage = "idle";
|
|
90
|
+
/**
|
|
91
|
+
* Raw AudioContext we create + resume SYNCHRONOUSLY inside the first user
|
|
92
|
+
* gesture. iOS Safari only unlocks audio when resume() is called directly
|
|
93
|
+
* in the gesture task; awaiting the Tone import first (as init() must) loses
|
|
94
|
+
* that activation, so Tone.start()'s resume() hangs with the context stuck
|
|
95
|
+
* 'suspended'. We resume this context in the gesture, then hand it to Tone.
|
|
96
|
+
*/
|
|
97
|
+
rawCtx = null;
|
|
98
|
+
/**
|
|
99
|
+
* Initialize the audio engine (must be called from a user gesture handler).
|
|
100
|
+
*
|
|
101
|
+
* Strategy: graceful degradation. We bring up a synthesized piano voice
|
|
102
|
+
* FIRST — it needs no downloads and decodes nothing, so it is ready
|
|
103
|
+
* instantly and works on every browser including iOS Safari. Audio is
|
|
104
|
+
* considered ready at that point. We THEN try to load the richer
|
|
105
|
+
* Salamander samples in the background and swap them in if they finish.
|
|
106
|
+
*
|
|
107
|
+
* Why: Tone.Sampler's MP3 decode path hangs on iOS Safari (await
|
|
108
|
+
* Tone.loaded() never resolves), which previously stalled init forever.
|
|
109
|
+
* Loading samples off the critical path means iOS always gets working
|
|
110
|
+
* sound (synth) and merely misses the upgrade, instead of getting silence.
|
|
111
|
+
*
|
|
112
|
+
* On iOS Safari, Tone.start() must run during the synchronous portion of
|
|
113
|
+
* the gesture handler, which is why we pre-import the Tone module above.
|
|
114
|
+
*/
|
|
115
|
+
async init() {
|
|
116
|
+
if (!browser) return;
|
|
117
|
+
if (this.isInitialized || this.isLoading) return;
|
|
118
|
+
this.isLoading = true;
|
|
119
|
+
this.lastStage = "init-start";
|
|
120
|
+
try {
|
|
121
|
+
this.Tone = await tonePreload;
|
|
122
|
+
this.lastStage = "tone-imported";
|
|
123
|
+
if (this.rawCtx) {
|
|
124
|
+
try {
|
|
125
|
+
this.Tone.setContext(this.rawCtx);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.warn("[audio] setContext failed", e);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.lastStage = "context-set:" + this.contextState;
|
|
131
|
+
await Promise.race([
|
|
132
|
+
this.Tone.start(),
|
|
133
|
+
new Promise((r) => setTimeout(r, 1500))
|
|
134
|
+
]);
|
|
135
|
+
this.lastStage = "context-started:" + this.contextState;
|
|
136
|
+
this.synthPiano = new this.Tone.PolySynth(this.Tone.Synth, {
|
|
137
|
+
oscillator: { type: "triangle" },
|
|
138
|
+
envelope: { attack: 5e-3, decay: 0.5, sustain: 0.3, release: 1.2 }
|
|
139
|
+
}).toDestination();
|
|
140
|
+
this.piano = this.synthPiano;
|
|
141
|
+
this.voice = "synth";
|
|
142
|
+
this.lastStage = "piano-synth-ready";
|
|
143
|
+
this.padFilter = new this.Tone.Filter({
|
|
144
|
+
type: "lowpass",
|
|
145
|
+
frequency: 2e3,
|
|
146
|
+
rolloff: -12
|
|
147
|
+
}).toDestination();
|
|
148
|
+
this.strings = new this.Tone.PolySynth(this.Tone.Synth, {
|
|
149
|
+
oscillator: { type: "fatsawtooth", count: 3, spread: 30 },
|
|
150
|
+
envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 2 }
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
}).connect(this.padFilter);
|
|
153
|
+
this.strings.volume.value = -10;
|
|
154
|
+
this.isInitialized = true;
|
|
155
|
+
this.lastStage = "ready";
|
|
156
|
+
this.upgradeToSamplerInBackground();
|
|
157
|
+
this.addReverbInBackground();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.lastStage = "error:" + (error instanceof Error ? error.message : String(error));
|
|
160
|
+
console.error("Failed to initialize audio:", error);
|
|
161
|
+
} finally {
|
|
162
|
+
this.isLoading = false;
|
|
163
|
+
}
|
|
72
164
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Attempt to load the Salamander sampler in the background and swap it in
|
|
167
|
+
* for the synth piano once (and only if) every sample has decoded. On iOS
|
|
168
|
+
* Safari this typically never completes, so we simply stay on the synth —
|
|
169
|
+
* no stall is ever surfaced because init already resolved.
|
|
170
|
+
*/
|
|
171
|
+
upgradeToSamplerInBackground() {
|
|
172
|
+
if (!this.Tone || this.samplerUpgradeStarted) return;
|
|
173
|
+
this.samplerUpgradeStarted = true;
|
|
174
|
+
try {
|
|
175
|
+
const sampler = createSalamanderSampler(this.Tone, {
|
|
176
|
+
urls: SALAMANDER_URLS_FULL,
|
|
177
|
+
baseUrl: "/audio/salamander/",
|
|
178
|
+
release: 1,
|
|
179
|
+
onload: () => {
|
|
180
|
+
this.piano = sampler;
|
|
181
|
+
this.voice = "sampler";
|
|
182
|
+
console.info("[audio] upgraded piano voice to Salamander samples");
|
|
183
|
+
},
|
|
184
|
+
onerror: (e) => {
|
|
185
|
+
console.warn("[audio] sample load failed, staying on synth voice", e);
|
|
186
|
+
}
|
|
79
187
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
188
|
+
void sampler;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.warn("[audio] sampler init threw, staying on synth voice", e);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Generate a reverb in the background and reroute the background pad through
|
|
195
|
+
* it once ready: pad → filter → reverb → destination. Reverb.generate()
|
|
196
|
+
* runs an OfflineAudioContext render that can hang on iOS Safari, so this is
|
|
197
|
+
* deliberately off the init critical path — if it never resolves, the pad
|
|
198
|
+
* simply stays dry. Raced against a timeout so we log and move on cleanly.
|
|
199
|
+
*/
|
|
200
|
+
async addReverbInBackground() {
|
|
201
|
+
if (!this.Tone || !this.padFilter || this.reverbStarted) return;
|
|
202
|
+
this.reverbStarted = true;
|
|
203
|
+
try {
|
|
204
|
+
const reverb = await generateReverb(this.Tone, { decay: 3, wet: 0.5 }, 4e3);
|
|
205
|
+
if (!reverb) {
|
|
206
|
+
console.warn("[audio] reverb generate timed out, pad stays dry");
|
|
207
|
+
return;
|
|
83
208
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
209
|
+
reverb.toDestination();
|
|
210
|
+
this.padFilter.disconnect();
|
|
211
|
+
this.padFilter.connect(reverb);
|
|
212
|
+
console.info("[audio] reverb enabled");
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.warn("[audio] reverb generate failed, pad stays dry", e);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* MUST be called SYNCHRONOUSLY from a user-gesture handler (touchstart /
|
|
219
|
+
* click / keydown), before any await. Creates and resumes a raw
|
|
220
|
+
* AudioContext in the gesture task so iOS Safari actually unlocks audio.
|
|
221
|
+
* init() then adopts this context via Tone.setContext(). Idempotent.
|
|
222
|
+
*/
|
|
223
|
+
unlock() {
|
|
224
|
+
if (!browser) return;
|
|
225
|
+
try {
|
|
226
|
+
if (!this.rawCtx) {
|
|
227
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
228
|
+
if (!AC) return;
|
|
229
|
+
this.rawCtx = new AC();
|
|
230
|
+
}
|
|
231
|
+
if (this.rawCtx.state !== "running") {
|
|
232
|
+
this.rawCtx.resume().catch(() => {
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.warn("[audio] unlock failed", e);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* iOS Safari can silently leave the AudioContext in 'suspended' or
|
|
241
|
+
* 'interrupted' state even after Tone.start() resolves, especially after
|
|
242
|
+
* tab backgrounding or initial activation. Call this synchronously from
|
|
243
|
+
* a user-gesture handler (or before playback) to wake it up. No-op when
|
|
244
|
+
* the context is already running.
|
|
245
|
+
*/
|
|
246
|
+
ensureRunning() {
|
|
247
|
+
if (this.rawCtx && this.rawCtx.state !== "running") {
|
|
248
|
+
this.rawCtx.resume().catch(() => {
|
|
89
249
|
});
|
|
90
|
-
},
|
|
91
|
-
get context() {
|
|
92
|
-
return ctx;
|
|
93
250
|
}
|
|
94
|
-
|
|
95
|
-
|
|
251
|
+
if (!this.Tone) return;
|
|
252
|
+
const ctx = this.Tone.getContext().rawContext;
|
|
253
|
+
if (ctx && ctx.state !== "running") {
|
|
254
|
+
ctx.resume().catch(() => {
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Current AudioContext state, for diagnostics. */
|
|
259
|
+
get contextState() {
|
|
260
|
+
if (!this.Tone) return "no-tone";
|
|
261
|
+
const ctx = this.Tone.getContext().rawContext;
|
|
262
|
+
return ctx?.state ?? "no-context";
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Play a MIDI note
|
|
266
|
+
*/
|
|
267
|
+
playNote(midiNote, duration = 0.5, velocity = 0.8) {
|
|
268
|
+
if (!this.piano || !this.isInitialized) return;
|
|
269
|
+
this.ensureRunning();
|
|
270
|
+
const noteName = midiToNoteName(midiNote);
|
|
271
|
+
this.piano.triggerAttackRelease(noteName, duration, this.Tone.now(), velocity);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Play a scale degree in a given key
|
|
275
|
+
*/
|
|
276
|
+
playScaleDegree(degree, key, octave = 4, duration = 0.5) {
|
|
277
|
+
const midiNote = getMidiNote(degree, key, octave);
|
|
278
|
+
this.playNote(midiNote, duration);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Start a sustained background chord (tonic chord)
|
|
282
|
+
*/
|
|
283
|
+
startBackgroundChord(key, duration = 4) {
|
|
284
|
+
if (!this.strings || !this.isInitialized) return;
|
|
285
|
+
this.ensureRunning();
|
|
286
|
+
const useFlats = KEYS_PREFER_FLATS.includes(key);
|
|
287
|
+
const root4 = getMidiNote(1, key, 4);
|
|
288
|
+
const fifth4 = getMidiNote(5, key, 4);
|
|
289
|
+
const root5 = getMidiNote(1, key, 5);
|
|
290
|
+
const third5 = getMidiNote(3, key, 5);
|
|
291
|
+
const notes = [root4, fifth4, root5, third5].map((m) => midiToNoteName(m, useFlats));
|
|
292
|
+
this.strings.triggerAttackRelease(notes, duration, this.Tone.now(), 0.3);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Play a chord (list of MIDI notes) on the piano sampler.
|
|
296
|
+
*/
|
|
297
|
+
playChord(midiNotes, duration = 1.5, velocity = 0.7) {
|
|
298
|
+
if (!this.piano || !this.isInitialized) return;
|
|
299
|
+
this.ensureRunning();
|
|
300
|
+
const names = midiNotes.map((m) => midiToNoteName(m));
|
|
301
|
+
for (const n of names) {
|
|
302
|
+
this.piano.triggerAttackRelease(n, duration, this.Tone.now(), velocity);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Play a melodic phrase: a sequence of scale degrees with configurable timing.
|
|
307
|
+
* Each note plays for `noteDuration` seconds, with `gap` ms gap between notes.
|
|
308
|
+
*/
|
|
309
|
+
async playPhrase(degrees, key, octave, noteDuration = 0.4, gap = 100) {
|
|
310
|
+
for (let i = 0; i < degrees.length; i++) {
|
|
311
|
+
this.playScaleDegree(degrees[i], key, octave, noteDuration);
|
|
312
|
+
await this.wait(Math.round(noteDuration * 1e3) + gap);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Play a sequence of scale degrees
|
|
317
|
+
*/
|
|
318
|
+
async playSequence(degrees, key, tempo = 500, octave = 4, onNoteStart, onNoteEnd) {
|
|
319
|
+
for (let i = 0; i < degrees.length; i++) {
|
|
320
|
+
onNoteStart?.(i);
|
|
321
|
+
this.playScaleDegree(degrees[i], key, octave, 0.45);
|
|
322
|
+
await this.wait(tempo);
|
|
323
|
+
onNoteEnd?.(i);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Utility to wait for a given duration
|
|
328
|
+
*/
|
|
329
|
+
wait(ms) {
|
|
330
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Check if audio is ready
|
|
334
|
+
*/
|
|
335
|
+
get isReady() {
|
|
336
|
+
return this.isInitialized;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
var audio = new AudioEngine();
|
|
96
340
|
export {
|
|
341
|
+
AudioEngine,
|
|
97
342
|
SALAMANDER_CDN_BASE,
|
|
98
343
|
SALAMANDER_URLS_8,
|
|
99
344
|
SALAMANDER_URLS_FULL,
|
|
100
|
-
|
|
101
|
-
|
|
345
|
+
audio,
|
|
346
|
+
createSalamanderSampler,
|
|
347
|
+
generateReverb
|
|
102
348
|
};
|
|
103
349
|
//# 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/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":[]}
|
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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/
|
|
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.
|
|
3
|
+
"version": "0.3.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"
|