@ugfoundation/swaralipi-js 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +347 -0
  2. package/README.md +257 -0
  3. package/dist/batch-NF2Q2CFS.js +47 -0
  4. package/dist/batch-NF2Q2CFS.js.map +1 -0
  5. package/dist/chunk-C6O7DYU5.js +115 -0
  6. package/dist/chunk-C6O7DYU5.js.map +1 -0
  7. package/dist/chunk-GWXI2HJA.js +14 -0
  8. package/dist/chunk-GWXI2HJA.js.map +1 -0
  9. package/dist/chunk-N3NNWAOW.js +593 -0
  10. package/dist/chunk-N3NNWAOW.js.map +1 -0
  11. package/dist/cli.cjs +913 -0
  12. package/dist/cli.cjs.map +1 -0
  13. package/dist/cli.d.cts +1 -0
  14. package/dist/cli.d.ts +1 -0
  15. package/dist/cli.js +91 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/convert/index.cjs +720 -0
  18. package/dist/convert/index.cjs.map +1 -0
  19. package/dist/convert/index.d.cts +270 -0
  20. package/dist/convert/index.d.ts +270 -0
  21. package/dist/convert/index.js +5 -0
  22. package/dist/convert/index.js.map +1 -0
  23. package/dist/index.cjs +143 -0
  24. package/dist/index.cjs.map +1 -0
  25. package/dist/index.d.cts +4 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/player/index.cjs +775 -0
  30. package/dist/player/index.cjs.map +1 -0
  31. package/dist/player/index.d.cts +285 -0
  32. package/dist/player/index.d.ts +285 -0
  33. package/dist/player/index.js +743 -0
  34. package/dist/player/index.js.map +1 -0
  35. package/dist/schema-gxhG45OK.d.cts +467 -0
  36. package/dist/schema-gxhG45OK.d.ts +467 -0
  37. package/dist/spec/index.cjs +143 -0
  38. package/dist/spec/index.cjs.map +1 -0
  39. package/dist/spec/index.d.cts +70 -0
  40. package/dist/spec/index.d.ts +70 -0
  41. package/dist/spec/index.js +4 -0
  42. package/dist/spec/index.js.map +1 -0
  43. package/dist/taals-DguYW0wf.d.cts +60 -0
  44. package/dist/taals-DguYW0wf.d.ts +60 -0
  45. package/dist/viewer/index.cjs +998 -0
  46. package/dist/viewer/index.cjs.map +1 -0
  47. package/dist/viewer/index.d.cts +285 -0
  48. package/dist/viewer/index.d.ts +285 -0
  49. package/dist/viewer/index.js +814 -0
  50. package/dist/viewer/index.js.map +1 -0
  51. package/package.json +93 -0
@@ -0,0 +1,743 @@
1
+ import { TAALS } from '../chunk-GWXI2HJA.js';
2
+
3
+ // src/player/pitch.ts
4
+ var OCTAVE_OFFSET = {
5
+ "ati-mandra": -2,
6
+ mandra: -1,
7
+ madhyam: 0,
8
+ taar: 1,
9
+ "ati-taar": 2
10
+ };
11
+ var SHUDDHA = {
12
+ s: 0,
13
+ r: 2,
14
+ g: 4,
15
+ m: 5,
16
+ p: 7,
17
+ d: 9,
18
+ n: 11
19
+ };
20
+ var SHARP_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
21
+ var PITCH_CLASS = {
22
+ c: 0,
23
+ "c#": 1,
24
+ db: 1,
25
+ d: 2,
26
+ "d#": 3,
27
+ eb: 3,
28
+ e: 4,
29
+ "e#": 5,
30
+ fb: 4,
31
+ f: 5,
32
+ "f#": 6,
33
+ gb: 6,
34
+ g: 7,
35
+ "g#": 8,
36
+ ab: 8,
37
+ a: 9,
38
+ "a#": 10,
39
+ bb: 10,
40
+ b: 11,
41
+ "b#": 0,
42
+ cb: 11
43
+ };
44
+ function parseTonic(tonic) {
45
+ if (!tonic) return 0;
46
+ const key = tonic.trim().toLowerCase().replace(/s/g, "#");
47
+ return PITCH_CLASS[key] ?? 0;
48
+ }
49
+ function svaraSemitone(note) {
50
+ if (note.svara === void 0) return null;
51
+ const [, letter] = note.svara;
52
+ let semi = SHUDDHA[letter];
53
+ if (note.komal === true || note.microtone !== void 0) semi -= 1;
54
+ if (note.tivra === true) semi += 1;
55
+ return semi;
56
+ }
57
+ function svaraToMidi(note, tonicPc, baseOctave = 4) {
58
+ const semi = svaraSemitone(note);
59
+ if (semi === null || note.svara === void 0) return null;
60
+ const [octave] = note.svara;
61
+ return (baseOctave + 1 + OCTAVE_OFFSET[octave]) * 12 + tonicPc + semi;
62
+ }
63
+ function kanToMidi(svara, tonicPc, baseOctave = 4) {
64
+ if (svara === void 0) return null;
65
+ const [octave, letter] = svara;
66
+ return (baseOctave + 1 + OCTAVE_OFFSET[octave]) * 12 + tonicPc + SHUDDHA[letter];
67
+ }
68
+ function midiToSampleName(midi) {
69
+ const pc = (midi % 12 + 12) % 12;
70
+ const octave = Math.floor(midi / 12) - 1;
71
+ return `${SHARP_NAMES[pc]}${octave}`;
72
+ }
73
+ function sampleFileName(midi) {
74
+ return midiToSampleName(midi).toLowerCase().replace("#", "s");
75
+ }
76
+
77
+ // src/player/schedule.ts
78
+ var isSilent = (n) => n.svara === void 0 && n.rest !== true && n.sustain !== true;
79
+ function buildSchedule(doc, opts = {}) {
80
+ const tonicPc = parseTonic(opts.tonic ?? doc.meta.tonic);
81
+ const baseOctave = opts.baseOctave ?? 4;
82
+ const events = [];
83
+ let cursor = 0;
84
+ let lastPitched = -1;
85
+ doc.parts.forEach((part, p) => {
86
+ part.rows.forEach((row, r) => {
87
+ row.notes.forEach((note, i) => {
88
+ if (note.bar !== void 0 || isSilent(note)) return;
89
+ const path = `${p}.${r}.${i}`;
90
+ const dur = note.durationMatra ?? 1;
91
+ if (note.svara !== void 0) {
92
+ events.push({
93
+ beat: cursor,
94
+ durationBeats: dur,
95
+ midi: svaraToMidi(note, tonicPc, baseOctave),
96
+ kan: kanToMidi(note.kan, tonicPc, baseOctave),
97
+ path,
98
+ kind: note.sustain === true ? "sustain" : "note"
99
+ });
100
+ lastPitched = events.length - 1;
101
+ } else if (note.sustain === true) {
102
+ if (lastPitched >= 0) events[lastPitched].durationBeats += dur;
103
+ events.push({ beat: cursor, durationBeats: dur, midi: null, kan: null, path, kind: "sustain" });
104
+ } else {
105
+ events.push({ beat: cursor, durationBeats: dur, midi: null, kan: null, path, kind: "rest" });
106
+ }
107
+ cursor += dur;
108
+ });
109
+ });
110
+ });
111
+ return { events, totalBeats: cursor };
112
+ }
113
+
114
+ // src/player/instruments.ts
115
+ var DEFAULT_SOUNDS_BASE = "https://cdn.daserve.in/swaralipijs-instruments/";
116
+ var SALAMANDER_BASE = "https://tonejs.github.io/audio/salamander/";
117
+ var TONEJS_INSTRUMENTS_BASE = "https://nbrosowsky.github.io/tonejs-instruments/samples/";
118
+ function keyMap(keys) {
119
+ const m = {};
120
+ for (const k of keys) m[k] = `${k.replace("#", "s")}.mp3`;
121
+ return m;
122
+ }
123
+ var PIANO_KEYS = [
124
+ "A0",
125
+ "C1",
126
+ "D#1",
127
+ "F#1",
128
+ "A1",
129
+ "C2",
130
+ "D#2",
131
+ "F#2",
132
+ "A2",
133
+ "C3",
134
+ "D#3",
135
+ "F#3",
136
+ "A3",
137
+ "C4",
138
+ "D#4",
139
+ "F#4",
140
+ "A4",
141
+ "C5",
142
+ "D#5",
143
+ "F#5",
144
+ "A5",
145
+ "C6",
146
+ "D#6",
147
+ "F#6",
148
+ "A6",
149
+ "C7",
150
+ "D#7",
151
+ "F#7",
152
+ "A7",
153
+ "C8"
154
+ ];
155
+ var HARMONIUM_KEYS = [
156
+ "C2",
157
+ "C#2",
158
+ "D2",
159
+ "D#2",
160
+ "E2",
161
+ "F2",
162
+ "F#2",
163
+ "G2",
164
+ "G#2",
165
+ "A2",
166
+ "A#2",
167
+ "C3",
168
+ "C#3",
169
+ "D3",
170
+ "D#3",
171
+ "E3",
172
+ "F3",
173
+ "F#3",
174
+ "G3",
175
+ "G#3",
176
+ "A3",
177
+ "A#3",
178
+ "C4",
179
+ "C#4",
180
+ "D4",
181
+ "D#4",
182
+ "E4",
183
+ "F4",
184
+ "F#4",
185
+ "G4",
186
+ "G#4",
187
+ "A4",
188
+ "A#4",
189
+ "C5",
190
+ "C#5",
191
+ "D5"
192
+ ];
193
+ var VIOLIN_KEYS = ["A3", "C4", "E4", "G4", "A4", "C5", "E5", "G5", "A5", "C6", "E6", "G6", "A6", "C7"];
194
+ var FLUTE_KEYS = ["C4", "E4", "A4", "C5", "E5", "A5", "C6", "E6", "A6", "C7"];
195
+ var INSTRUMENTS = {
196
+ piano: { label: "Grand piano", urls: keyMap(PIANO_KEYS), baseUrl: SALAMANDER_BASE, release: 1 },
197
+ santoor: { label: "Santoor", urls: santoorUrls(), baseUrl: "santoor/", local: true, release: 1.2 },
198
+ harmonium: { label: "Harmonium", urls: keyMap(HARMONIUM_KEYS), baseUrl: `${TONEJS_INSTRUMENTS_BASE}harmonium/`, release: 1.4 },
199
+ violin: { label: "Violin", urls: keyMap(VIOLIN_KEYS), baseUrl: `${TONEJS_INSTRUMENTS_BASE}violin/`, release: 1 },
200
+ flute: { label: "Flute", urls: keyMap(FLUTE_KEYS), baseUrl: `${TONEJS_INSTRUMENTS_BASE}flute/`, release: 1 }
201
+ };
202
+ var SAMPLED_TAALS = new Set(
203
+ Object.keys(TAALS)
204
+ );
205
+ var TABLA_BPMS = Array.from({ length: 17 }, (_, i) => 60 + i * 15);
206
+ function normalizeBase(url) {
207
+ return url.endsWith("/") ? url : `${url}/`;
208
+ }
209
+ function santoorUrls() {
210
+ const urls = {};
211
+ const add = (midi) => {
212
+ const stem = sampleFileName(midi);
213
+ const name = stem.replace("s", "#").replace(/^(.)/, (c) => c.toUpperCase());
214
+ urls[name] = `${stem}.mp3`;
215
+ };
216
+ for (let midi = 60; midi <= 95; midi++) add(midi);
217
+ add(96);
218
+ return urls;
219
+ }
220
+ var PC_BASE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
221
+ function noteToMidi(name) {
222
+ const m = /^([a-g])([#sb]?)(-?\d+)$/i.exec(name.trim());
223
+ if (!m) return NaN;
224
+ const base = PC_BASE[m[1].toLowerCase()];
225
+ const acc = m[2] === "b" ? -1 : m[2] === "" ? 0 : 1;
226
+ return (Number(m[3]) + 1) * 12 + base + acc;
227
+ }
228
+ function subsetUrls(urls, lo, hi) {
229
+ const entries = Object.entries(urls).map(([k, f]) => ({ k, f, midi: noteToMidi(k) })).filter((e) => Number.isFinite(e.midi)).sort((a, b) => a.midi - b.midi);
230
+ if (entries.length === 0) return { ...urls };
231
+ const keep = /* @__PURE__ */ new Set();
232
+ for (const e of entries) if (e.midi >= lo && e.midi <= hi) keep.add(e.k);
233
+ let floor;
234
+ let ceil;
235
+ for (const e of entries) if (e.midi <= lo) floor = e.k;
236
+ for (const e of entries) if (e.midi >= hi) {
237
+ ceil = e.k;
238
+ break;
239
+ }
240
+ if (floor) keep.add(floor);
241
+ if (ceil) keep.add(ceil);
242
+ if (keep.size === 0) {
243
+ const mid = (lo + hi) / 2;
244
+ const nearest = entries.reduce((a, b) => Math.abs(b.midi - mid) < Math.abs(a.midi - mid) ? b : a);
245
+ keep.add(nearest.k);
246
+ }
247
+ const out = {};
248
+ for (const e of entries) if (keep.has(e.k)) out[e.k] = e.f;
249
+ return out;
250
+ }
251
+ function instrumentSampleUrl(def, soundsBaseUrl) {
252
+ if (def.local && soundsBaseUrl === void 0) return void 0;
253
+ const base = def.local ? `${normalizeBase(soundsBaseUrl ?? "")}${def.baseUrl}` : def.baseUrl;
254
+ const file = Object.values(def.urls)[0];
255
+ return file === void 0 ? void 0 : `${normalizeBase(base)}${file}`;
256
+ }
257
+ async function probeUrl(url, timeoutMs = 6e3) {
258
+ if (typeof fetch === "undefined") return false;
259
+ const ctrl = typeof AbortController !== "undefined" ? new AbortController() : void 0;
260
+ const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs) : void 0;
261
+ try {
262
+ const res = await fetch(url, {
263
+ method: "GET",
264
+ headers: { Range: "bytes=0-0" },
265
+ cache: "no-store",
266
+ signal: ctrl?.signal
267
+ });
268
+ return res.ok;
269
+ } catch {
270
+ return false;
271
+ } finally {
272
+ if (timer !== void 0) clearTimeout(timer);
273
+ }
274
+ }
275
+ function nearestTablaBpm(bpm) {
276
+ return TABLA_BPMS.reduce((best, b) => Math.abs(b - bpm) < Math.abs(best - bpm) ? b : best, TABLA_BPMS[0]);
277
+ }
278
+ function tablaUrl(base, taalId, bpm) {
279
+ return `${normalizeBase(base)}tabla/${taalId}/${taalId}${bpm}bpm.mp3`;
280
+ }
281
+ function metronomeUrl(base) {
282
+ return `${normalizeBase(base)}metronome/metro2.mp3`;
283
+ }
284
+ function createMetronome(Tone, base) {
285
+ return new Tone.Player(metronomeUrl(base));
286
+ }
287
+ var TANPURA_PCS = ["c", "cs", "d", "ds", "e", "f", "fs", "g", "gs", "a", "as", "b"];
288
+ function createSampler(Tone, def, soundsBaseUrl, range) {
289
+ const base = def.local ? `${normalizeBase(soundsBaseUrl ?? "")}${def.baseUrl}` : def.baseUrl;
290
+ const urls = range ? subsetUrls(def.urls, range.lo, range.hi) : def.urls;
291
+ return new Tone.Sampler({ urls, baseUrl: normalizeBase(base), release: def.release ?? 1 });
292
+ }
293
+ function createTanpura(Tone, base, stems = TANPURA_PCS) {
294
+ const urls = {};
295
+ for (const s of stems) urls[s] = `${s}.mp3`;
296
+ return new Tone.Players({ urls, baseUrl: `${normalizeBase(base)}tanpura/` });
297
+ }
298
+
299
+ // src/player/player.ts
300
+ var STOP_FADE_SEC = 0.5;
301
+ function midiRange(schedule) {
302
+ let lo = Infinity;
303
+ let hi = -Infinity;
304
+ for (const ev of schedule.events) {
305
+ for (const m of [ev.midi, ev.kan]) {
306
+ if (m !== null) {
307
+ if (m < lo) lo = m;
308
+ if (m > hi) hi = m;
309
+ }
310
+ }
311
+ }
312
+ return lo <= hi ? { lo, hi } : void 0;
313
+ }
314
+ var SwaralipiPlayer = class {
315
+ #opts;
316
+ /** Per-song setBpm tweak: beats meta.bpm but is cleared by the next load(). */
317
+ #bpmTweak;
318
+ #doc;
319
+ #state = "idle";
320
+ #tone;
321
+ /** Master bus everything routes through, so stop() can fade it out. */
322
+ #master;
323
+ /** Cached melody samplers keyed by instrument id, with the MIDI range each
324
+ * was loaded for (rebuilt when a song needs a wider range). */
325
+ #samplers = /* @__PURE__ */ new Map();
326
+ #tanpura;
327
+ #tanpuraKey = "";
328
+ #metronome;
329
+ #tabla;
330
+ #scheduleIds = [];
331
+ #fadeTimer;
332
+ constructor(options = {}) {
333
+ this.#opts = {
334
+ soundsBaseUrl: options.soundsBaseUrl ?? DEFAULT_SOUNDS_BASE,
335
+ pianoBaseUrl: options.pianoBaseUrl,
336
+ customInstruments: options.customInstruments,
337
+ tonic: options.tonic,
338
+ bpm: options.bpm,
339
+ baseOctave: options.baseOctave ?? 4,
340
+ instrument: options.instrument ?? "piano",
341
+ instruments: options.instruments ?? Object.keys(INSTRUMENTS),
342
+ rhythm: options.rhythm ?? "tabla",
343
+ tanpura: options.tanpura ?? true,
344
+ previewLimitSec: options.previewLimitSec ?? Infinity,
345
+ onHighlight: options.onHighlight,
346
+ onState: options.onState,
347
+ onLimit: options.onLimit
348
+ };
349
+ }
350
+ /* ----------------------------------------------------------------- */
351
+ /* Public getters */
352
+ /* ----------------------------------------------------------------- */
353
+ get state() {
354
+ return this.#state;
355
+ }
356
+ get bpm() {
357
+ return this.#bpmTweak ?? this.#opts.bpm ?? this.#doc?.meta.bpm ?? 90;
358
+ }
359
+ /** The preview cap in seconds (`Infinity` = unlimited). */
360
+ get previewLimitSec() {
361
+ return this.#opts.previewLimitSec;
362
+ }
363
+ get instrument() {
364
+ return this.#effectiveInstrument();
365
+ }
366
+ get instruments() {
367
+ return [...this.#opts.instruments];
368
+ }
369
+ /** The taal currently driving the rhythm (auto from the doc, or an override). */
370
+ get taalId() {
371
+ return this.#taalId();
372
+ }
373
+ /** Whether the effective rhythm will actually be a sampled tabla. */
374
+ get usesTabla() {
375
+ const id = this.#taalId();
376
+ return this.#opts.rhythm === "tabla" && id !== void 0 && SAMPLED_TAALS.has(id) && !!this.#opts.soundsBaseUrl;
377
+ }
378
+ /* ----------------------------------------------------------------- */
379
+ /* Loading + transport control */
380
+ /* ----------------------------------------------------------------- */
381
+ /** Set the document to play. Stops any current playback. */
382
+ load(doc) {
383
+ this.#halt();
384
+ this.#doc = doc;
385
+ this.#taalOverride = void 0;
386
+ this.#bpmTweak = void 0;
387
+ this.#setState("ready");
388
+ }
389
+ /** Start (or resume) playback. Must be triggered from a user gesture so the
390
+ * browser permits `AudioContext` resume. */
391
+ async play() {
392
+ if (this.#doc === void 0 || this.#state === "playing") return;
393
+ const Tone = await this.#ensureTone();
394
+ await Tone.start();
395
+ if (this.#state === "paused") {
396
+ Tone.getTransport().start();
397
+ this.#setState("playing");
398
+ return;
399
+ }
400
+ this.#cancelFade();
401
+ Tone.getTransport().stop();
402
+ this.#setState("loading");
403
+ await this.#arrange(Tone);
404
+ Tone.getTransport().start();
405
+ this.#setState("playing");
406
+ const limit = this.#opts.previewLimitSec;
407
+ if (Number.isFinite(limit)) {
408
+ Tone.getTransport().scheduleOnce(() => {
409
+ Tone.getDraw().schedule(() => {
410
+ if (this.#state === "playing") {
411
+ this.stop();
412
+ this.#opts.onLimit?.();
413
+ }
414
+ }, this.#tone.now());
415
+ }, limit);
416
+ }
417
+ }
418
+ /** Pause; `play()` resumes from the same position. */
419
+ pause() {
420
+ if (this.#tone === void 0 || this.#state !== "playing") return;
421
+ this.#tone.getTransport().pause();
422
+ this.#setState("paused");
423
+ }
424
+ /**
425
+ * Stop and rewind to the start. When sounding, the accompaniment (tabla /
426
+ * metronome / tanpura, which otherwise loop) is gently faded out over
427
+ * STOP_FADE_SEC before the transport is halted, so the rhythm doesn't keep
428
+ * playing or cut abruptly; otherwise it halts immediately.
429
+ */
430
+ stop() {
431
+ if (this.#tone === void 0 || this.#state === "idle") {
432
+ this.#halt();
433
+ return;
434
+ }
435
+ if ((this.#state === "playing" || this.#state === "paused") && this.#master !== void 0) {
436
+ const now = this.#tone.now();
437
+ this.#master.gain.cancelScheduledValues(now);
438
+ this.#master.gain.rampTo(0, STOP_FADE_SEC, now);
439
+ this.#emitHighlight(null);
440
+ this.#setState(this.#doc ? "ready" : "idle");
441
+ this.#cancelFadeTimer();
442
+ this.#fadeTimer = setTimeout(() => this.#halt(), STOP_FADE_SEC * 1e3 + 60);
443
+ return;
444
+ }
445
+ this.#halt();
446
+ }
447
+ /** Immediate, synchronous teardown: stop+rewind the transport, kill the
448
+ * looping accompaniment, restore the master gain for the next play. */
449
+ #halt() {
450
+ this.#cancelFadeTimer();
451
+ if (this.#tone !== void 0) {
452
+ const transport = this.#tone.getTransport();
453
+ transport.stop();
454
+ transport.cancel(0);
455
+ transport.position = 0;
456
+ }
457
+ this.#tabla?.dispose();
458
+ this.#tabla = void 0;
459
+ this.#restoreMasterGain();
460
+ this.#scheduleIds = [];
461
+ this.#emitHighlight(null);
462
+ if (this.#state !== "idle") this.#setState(this.#doc ? "ready" : "idle");
463
+ }
464
+ #cancelFadeTimer() {
465
+ if (this.#fadeTimer !== void 0) {
466
+ clearTimeout(this.#fadeTimer);
467
+ this.#fadeTimer = void 0;
468
+ }
469
+ }
470
+ /** Abort an in-flight stop-fade (timer + gain ramp) without touching the
471
+ * transport — used when play() interrupts a fade. */
472
+ #cancelFade() {
473
+ this.#cancelFadeTimer();
474
+ this.#restoreMasterGain();
475
+ }
476
+ #restoreMasterGain() {
477
+ if (this.#master !== void 0 && this.#tone !== void 0) {
478
+ this.#master.gain.cancelScheduledValues(this.#tone.now());
479
+ this.#master.gain.value = 1;
480
+ }
481
+ }
482
+ /* ----------------------------------------------------------------- */
483
+ /* Settings (re-arrange from the top if mid-playback) */
484
+ /* ----------------------------------------------------------------- */
485
+ setTonic(tonic) {
486
+ this.#opts.tonic = tonic;
487
+ this.#reapply();
488
+ }
489
+ setBpm(bpm) {
490
+ this.#bpmTweak = bpm;
491
+ this.#reapply();
492
+ }
493
+ /** Update the preview cap (`Infinity` lifts it). Takes effect on next play. */
494
+ setPreviewLimit(sec) {
495
+ this.#opts.previewLimitSec = sec;
496
+ }
497
+ setInstrument(instrument) {
498
+ this.#opts.instrument = instrument;
499
+ this.#reapply();
500
+ }
501
+ setRhythm(rhythm) {
502
+ this.#opts.rhythm = rhythm;
503
+ this.#reapply();
504
+ }
505
+ setTanpura(on) {
506
+ this.#opts.tanpura = on;
507
+ this.#reapply();
508
+ }
509
+ /** Override the taal (null restores auto-detection from `meta.taal`). */
510
+ setTaal(id) {
511
+ this.#taalOverride = id ?? void 0;
512
+ this.#taalAuto = id === null;
513
+ this.#reapply();
514
+ }
515
+ /** Wire highlight output to a `<swaralipi-sheet>` (or any highlight target).
516
+ * Pass `{ autoScroll }` to keep the active note in view while playing. */
517
+ bindSheet(target, opts = {}) {
518
+ const block = opts.autoScroll === true ? "center" : opts.autoScroll || void 0;
519
+ this.#opts.onHighlight = (path) => {
520
+ target.clearHighlights();
521
+ if (path === null) return;
522
+ const el = target.highlight(path);
523
+ if (block !== void 0 && el !== null) {
524
+ el.scrollIntoView({ block, inline: "nearest", behavior: "smooth" });
525
+ }
526
+ };
527
+ }
528
+ /**
529
+ * Check whether an instrument's samples are reachable (its CDN is up / the
530
+ * local files exist), via a one-byte ranged fetch of a representative sample.
531
+ * A pass means Tone can actually load it. `false` for unknown ids, local
532
+ * instruments with no `soundsBaseUrl`, or any network failure/timeout.
533
+ */
534
+ async checkInstrument(id, timeoutMs) {
535
+ const def = this.#resolveDef(id);
536
+ if (def === void 0) return false;
537
+ const url = instrumentSampleUrl(def, this.#opts.soundsBaseUrl);
538
+ if (url === void 0) return false;
539
+ return probeUrl(url, timeoutMs);
540
+ }
541
+ /**
542
+ * Probe several instruments at once (default: the exposed `instruments`),
543
+ * returning `{ id: available }`. Use it to disable picker options whose CDN
544
+ * or files are down. Probes run concurrently; a check never throws.
545
+ */
546
+ async checkInstruments(ids = this.#opts.instruments, timeoutMs) {
547
+ const entries = await Promise.all(
548
+ ids.map(async (id) => [id, await this.checkInstrument(id, timeoutMs)])
549
+ );
550
+ return Object.fromEntries(entries);
551
+ }
552
+ /** Release all audio resources. The instance is unusable afterward. */
553
+ dispose() {
554
+ this.#halt();
555
+ for (const { sampler } of this.#samplers.values()) sampler.dispose();
556
+ this.#samplers.clear();
557
+ this.#tanpura?.dispose();
558
+ this.#metronome?.dispose();
559
+ this.#tabla?.dispose();
560
+ this.#master?.dispose();
561
+ this.#tanpura = this.#metronome = this.#tabla = this.#master = void 0;
562
+ }
563
+ /* ----------------------------------------------------------------- */
564
+ /* Internals */
565
+ /* ----------------------------------------------------------------- */
566
+ #taalOverride;
567
+ #taalAuto = true;
568
+ #setState(state) {
569
+ this.#state = state;
570
+ this.#opts.onState?.(state);
571
+ }
572
+ #emitHighlight(path) {
573
+ this.#opts.onHighlight?.(path);
574
+ }
575
+ /** Resolve the active taal: an explicit override, else the doc's. */
576
+ #taalId() {
577
+ if (!this.#taalAuto && this.#taalOverride !== void 0) return this.#taalOverride;
578
+ const id = this.#doc?.meta.taal?.id;
579
+ return id !== void 0 && id in TAALS ? id : void 0;
580
+ }
581
+ /** Resolve an instrument id to its def (custom overrides built-ins; the
582
+ * `pianoBaseUrl` shortcut self-hosts the built-in piano). */
583
+ #resolveDef(id) {
584
+ const custom = this.#opts.customInstruments?.[id];
585
+ if (custom) return custom;
586
+ const def = INSTRUMENTS[id];
587
+ if (def && id === "piano" && this.#opts.pianoBaseUrl) {
588
+ return { ...def, baseUrl: normalizeBase(this.#opts.pianoBaseUrl) };
589
+ }
590
+ return def;
591
+ }
592
+ /** A CDN-backed instrument that needs no `soundsBaseUrl` — used when the
593
+ * chosen one is local-only and no samples host is set, or is unknown. */
594
+ #fallbackInstrument() {
595
+ const exposed = this.#opts.instruments.find((id) => {
596
+ const d = this.#resolveDef(id);
597
+ return d !== void 0 && d.local !== true;
598
+ });
599
+ return exposed ?? "harmonium";
600
+ }
601
+ /** The instrument actually used: the chosen one, unless it's local-only with
602
+ * no `soundsBaseUrl` (or unknown), in which case a CDN fallback. */
603
+ #effectiveInstrument() {
604
+ const id = this.#opts.instrument;
605
+ const def = this.#resolveDef(id);
606
+ if (def === void 0) return this.#fallbackInstrument();
607
+ if (def.local === true && !this.#opts.soundsBaseUrl) return this.#fallbackInstrument();
608
+ return id;
609
+ }
610
+ /** Effective tempo: snapped to the tabla grid while a tabla actually plays. */
611
+ #effectiveBpm() {
612
+ return this.usesTabla ? nearestTablaBpm(this.bpm) : this.bpm;
613
+ }
614
+ async #ensureTone() {
615
+ this.#tone ??= await import('tone');
616
+ return this.#tone;
617
+ }
618
+ /** Hard stop → rebuild → restart, only while playing; otherwise just store.
619
+ * Uses #halt (not the stop() fade) so a setting change restarts instantly. */
620
+ #reapply() {
621
+ if (this.#state !== "playing") return;
622
+ this.#halt();
623
+ void this.play();
624
+ }
625
+ /** The master bus everything routes through (created once, → destination). */
626
+ #ensureMaster(Tone) {
627
+ this.#master ??= new Tone.Gain(1).toDestination();
628
+ return this.#master;
629
+ }
630
+ /** Build the melody instrument(s)/rhythm/drone for the current settings and
631
+ * wait for their buffers, then schedule everything on the Transport. */
632
+ async #arrange(Tone) {
633
+ if (this.#doc === void 0) return;
634
+ const base = this.#opts.soundsBaseUrl;
635
+ const transport = Tone.getTransport();
636
+ transport.cancel(0);
637
+ transport.position = 0;
638
+ this.#scheduleIds = [];
639
+ this.#ensureMaster(Tone);
640
+ this.#restoreMasterGain();
641
+ const bpm = this.#effectiveBpm();
642
+ transport.bpm.value = bpm;
643
+ const spb = 60 / bpm;
644
+ const schedule = buildSchedule(this.#doc, {
645
+ tonic: this.#opts.tonic,
646
+ baseOctave: this.#opts.baseOctave
647
+ });
648
+ const range = midiRange(schedule);
649
+ const melody = this.#buildMelody(Tone, this.#effectiveInstrument(), base, range);
650
+ this.#buildRhythm(Tone, transport, base, spb);
651
+ this.#buildTanpura(Tone, transport, base, spb);
652
+ await Tone.loaded();
653
+ const draw = Tone.getDraw();
654
+ for (const ev of schedule.events) {
655
+ const at = ev.beat * spb;
656
+ if (ev.midi !== null) {
657
+ const name = midiToSampleName(ev.midi);
658
+ const dur = Math.max(ev.durationBeats * spb, 0.05);
659
+ if (ev.kan !== null) {
660
+ const kanName = midiToSampleName(ev.kan);
661
+ transport.scheduleOnce((time) => melody.triggerAttackRelease(kanName, spb * 0.4, time), Math.max(at - spb * 0.12, 0));
662
+ }
663
+ transport.scheduleOnce((time) => melody.triggerAttackRelease(name, dur, time), at);
664
+ }
665
+ transport.scheduleOnce((time) => {
666
+ draw.schedule(() => this.#emitHighlight(ev.path), time);
667
+ }, at);
668
+ }
669
+ const endAt = schedule.totalBeats * spb + spb;
670
+ transport.scheduleOnce((time) => {
671
+ draw.schedule(() => this.#emitHighlight(null), time);
672
+ }, schedule.totalBeats * spb);
673
+ transport.scheduleOnce(() => this.stop(), endAt);
674
+ }
675
+ /** Build (or reuse) the melody sampler for `id`, loading only the samples
676
+ * covering `range`. A cached sampler is reused while it still covers the
677
+ * range; otherwise it's rebuilt (so changing songs/tonic widens it lazily). */
678
+ #buildMelody(Tone, id, base, range) {
679
+ const def = this.#resolveDef(id) ?? INSTRUMENTS.harmonium;
680
+ const master = this.#ensureMaster(Tone);
681
+ const cached = this.#samplers.get(id);
682
+ if (cached && (range === void 0 || cached.lo <= range.lo && cached.hi >= range.hi)) {
683
+ return cached.sampler;
684
+ }
685
+ cached?.sampler.dispose();
686
+ const sampler = createSampler(Tone, def, base, range).connect(master);
687
+ this.#samplers.set(id, { sampler, lo: range?.lo ?? -Infinity, hi: range?.hi ?? Infinity });
688
+ return sampler;
689
+ }
690
+ #buildRhythm(Tone, transport, base, spb) {
691
+ this.#tabla?.dispose();
692
+ this.#tabla = void 0;
693
+ const master = this.#ensureMaster(Tone);
694
+ const taalId = this.#taalId();
695
+ const wantsTabla = this.#opts.rhythm === "tabla" && taalId !== void 0 && SAMPLED_TAALS.has(taalId) && base !== void 0;
696
+ if (wantsTabla) {
697
+ const url = tablaUrl(base, taalId, nearestTablaBpm(this.bpm));
698
+ this.#tabla = new Tone.Player({ url, loop: true }).connect(master);
699
+ this.#tabla.sync().start(0);
700
+ return;
701
+ }
702
+ if (this.#opts.rhythm === "none" || base === void 0) return;
703
+ this.#metronome ??= createMetronome(Tone, base).connect(master);
704
+ const metro = this.#metronome;
705
+ const cycle = taalId !== void 0 ? TAALS[taalId].numBeats : 4;
706
+ let beat = 0;
707
+ const id = transport.scheduleRepeat((time) => {
708
+ metro.volume.setValueAtTime(beat % cycle === 0 ? 0 : -8, time);
709
+ metro.start(time);
710
+ beat += 1;
711
+ }, spb, 0);
712
+ this.#scheduleIds.push(id);
713
+ }
714
+ #buildTanpura(Tone, transport, base, spb) {
715
+ if (!this.#opts.tanpura || base === void 0) return;
716
+ const tonicPc = (this.#tonicSemitone() % 12 + 12) % 12;
717
+ const sa = TANPURA_PCS[tonicPc];
718
+ const pa = TANPURA_PCS[(tonicPc + 7) % 12];
719
+ const key = `${sa},${pa}`;
720
+ if (this.#tanpura === void 0 || this.#tanpuraKey !== key) {
721
+ this.#tanpura?.dispose();
722
+ this.#tanpura = createTanpura(Tone, base, [sa, pa]).connect(this.#ensureMaster(Tone));
723
+ this.#tanpuraKey = key;
724
+ }
725
+ const players = this.#tanpura;
726
+ players.volume.value = -14;
727
+ const pattern = [pa, sa, sa, sa];
728
+ let i = 0;
729
+ const id = transport.scheduleRepeat((time) => {
730
+ const stem = pattern[i % pattern.length];
731
+ if (players.has(stem)) players.player(stem).start(time);
732
+ i += 1;
733
+ }, spb * 2, 0);
734
+ this.#scheduleIds.push(id);
735
+ }
736
+ #tonicSemitone() {
737
+ return parseTonic(this.#opts.tonic ?? this.#doc?.meta.tonic);
738
+ }
739
+ };
740
+
741
+ export { DEFAULT_SOUNDS_BASE, INSTRUMENTS, OCTAVE_OFFSET, SALAMANDER_BASE, SAMPLED_TAALS, SHUDDHA, SwaralipiPlayer, TABLA_BPMS, TONEJS_INSTRUMENTS_BASE, buildSchedule, instrumentSampleUrl, midiToSampleName, nearestTablaBpm, normalizeBase, parseTonic, probeUrl, sampleFileName, santoorUrls, subsetUrls, svaraSemitone, svaraToMidi, tablaUrl };
742
+ //# sourceMappingURL=index.js.map
743
+ //# sourceMappingURL=index.js.map