@ugfoundation/swaralipi-js 0.9.1 → 0.10.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 (39) hide show
  1. package/README.md +17 -0
  2. package/dist/{batch-NEM7JXHV.js → batch-3PHPNX2P.js} +4 -4
  3. package/dist/{batch-NEM7JXHV.js.map → batch-3PHPNX2P.js.map} +1 -1
  4. package/dist/{chunk-DOEFSS66.js → chunk-FGMULPL2.js} +3 -3
  5. package/dist/{chunk-DOEFSS66.js.map → chunk-FGMULPL2.js.map} +1 -1
  6. package/dist/{chunk-Z6A3CP5V.js → chunk-I5GJWSD2.js} +3 -3
  7. package/dist/{chunk-Z6A3CP5V.js.map → chunk-I5GJWSD2.js.map} +1 -1
  8. package/dist/{chunk-SEN266BY.js → chunk-JEJYMKKZ.js} +26 -3
  9. package/dist/chunk-JEJYMKKZ.js.map +1 -0
  10. package/dist/cli.cjs +1 -1
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.js +2 -2
  13. package/dist/convert/index.cjs +1 -1
  14. package/dist/convert/index.cjs.map +1 -1
  15. package/dist/convert/index.js +2 -2
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/player/index.cjs +436 -50
  20. package/dist/player/index.cjs.map +1 -1
  21. package/dist/player/index.d.cts +76 -11
  22. package/dist/player/index.d.ts +76 -11
  23. package/dist/player/index.js +412 -51
  24. package/dist/player/index.js.map +1 -1
  25. package/dist/{schedule-BaovCtmf.d.ts → schedule-BUopqmWm.d.ts} +27 -1
  26. package/dist/{schedule-CU-VECDH.d.cts → schedule-IBu8lr-3.d.cts} +27 -1
  27. package/dist/spec/index.cjs +1 -1
  28. package/dist/spec/index.cjs.map +1 -1
  29. package/dist/spec/index.d.cts +1 -1
  30. package/dist/spec/index.d.ts +1 -1
  31. package/dist/spec/index.js +1 -1
  32. package/dist/viewer/index.cjs +149 -7
  33. package/dist/viewer/index.cjs.map +1 -1
  34. package/dist/viewer/index.d.cts +21 -4
  35. package/dist/viewer/index.d.ts +21 -4
  36. package/dist/viewer/index.js +149 -8
  37. package/dist/viewer/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/dist/chunk-SEN266BY.js.map +0 -1
@@ -1,15 +1,29 @@
1
- import { DEFAULT_SOUNDS_BASE, INSTRUMENTS, SAMPLED_TAALS, buildSchedule, quantizeLoopEnd, instrumentSampleUrl, probeUrl, normalizeBase, nearestTablaBpm, tablaBpms, graceTouches, midiToSampleName, glideTargets, triggerGlide, createSampler, createBuffers, tablaShiftSemitones, tablaUrl, TABLA_GRAIN_OVERLAP_SEC, TABLA_GRAIN_SEC, createDrumKit, drumPattern, createMetronome, TANPURA_PCS, createTanpura, parseTonic, GRACE_STEP, MEEND_HOLD, sCurve } from '../chunk-SEN266BY.js';
2
- export { ARC_RAW_RE, DEFAULT_SOUNDS_BASE, DRUM_KIT_BASE, GRACE_STEP, INSTRUMENTS, MEEND_HOLD, OCTAVE_OFFSET, SALAMANDER_BASE, SAMPLED_TAALS, SHUDDHA, TABLA_BPMS, TABLA_VARIATIONS, TONEJS_INSTRUMENTS_BASE, buildSchedule, createBuffers, drumPattern, glideTargets, graceTouches, instrumentSampleUrl, isArcRaw, meendContinuations, midiToSampleName, nearestSampleKey, nearestTablaBpm, normalizeBase, parseTonic, probeUrl, quantizeLoopEnd, sCurve, sampleFileName, santoorUrls, subsetUrls, svaraSemitone, svaraToMidi, tablaBpms, tablaUrl, triggerGlide } from '../chunk-SEN266BY.js';
1
+ import { DEFAULT_SOUNDS_BASE, INSTRUMENTS, SAMPLED_TAALS, buildSchedule, eventAt, quantizeLoopEnd, instrumentSampleUrl, probeUrl, scheduleLines, GRACE_STEP, normalizeBase, nearestTablaBpm, tablaBpms, graceTouches, midiToSampleName, glideTargets, triggerGlide, createSampler, createBuffers, tablaShiftSemitones, tablaUrl, TABLA_GRAIN_OVERLAP_SEC, TABLA_GRAIN_SEC, createDrumKit, drumPattern, createMetronome, TANPURA_PCS, createTanpura, parseTonic, MEEND_HOLD, sCurve } from '../chunk-JEJYMKKZ.js';
2
+ export { ARC_RAW_RE, DEFAULT_SOUNDS_BASE, DRUM_KIT_BASE, GRACE_STEP, INSTRUMENTS, MEEND_HOLD, OCTAVE_OFFSET, SALAMANDER_BASE, SAMPLED_TAALS, SHUDDHA, TABLA_BPMS, TABLA_VARIATIONS, TONEJS_INSTRUMENTS_BASE, buildSchedule, createBuffers, drumPattern, eventAt, glideTargets, graceTouches, instrumentSampleUrl, isArcRaw, meendContinuations, midiToSampleName, nearestSampleKey, nearestTablaBpm, normalizeBase, parseTonic, probeUrl, quantizeLoopEnd, sCurve, sampleFileName, santoorUrls, scheduleLines, subsetUrls, svaraSemitone, svaraToMidi, tablaBpms, tablaUrl, triggerGlide } from '../chunk-JEJYMKKZ.js';
3
3
  import { TAALS } from '../chunk-MYVIQU4O.js';
4
4
  export { LAYS, LAY_SPEEDS, printedLay, resolveLaySpeeds, rowLay } from '../chunk-MYVIQU4O.js';
5
5
 
6
6
  // src/player/player.ts
7
7
  var STOP_FADE_SEC = 0.5;
8
8
  var LAY_TAIL_SEC = 0.12;
9
+ var START_PREROLL_SEC = 0.02;
9
10
  var TAAL_MODE_TEMPO = [
10
11
  { beat: 0, endBeat: Infinity, scale: 1, lay: "drut" }
11
12
  ];
12
13
  var MEDIA_LEAD_SEC = 0.25;
14
+ function lineOf(lines, path) {
15
+ const [p = 0, r = 0] = path.split(".").map(Number);
16
+ const k = lines.findIndex((line) => {
17
+ const [lp = 0, lr = 0] = line.row.split(".").map(Number);
18
+ return lp > p || lp === p && lr >= r;
19
+ });
20
+ return k < 0 ? lines.length - 1 : k;
21
+ }
22
+ var seekableGrain = (Tone) => class extends Tone.GrainPlayer {
23
+ _start(time, offset, duration) {
24
+ super._start(time, offset === void 0 ? offset : this.toSeconds(offset) * this.playbackRate, duration);
25
+ }
26
+ };
13
27
  function midiRange(schedule) {
14
28
  let lo = Infinity;
15
29
  let hi = -Infinity;
@@ -58,9 +72,28 @@ var SwaralipiPlayer = class {
58
72
  * `fileSpb` is the recorded loop's OWN beat length, which is the unit
59
73
  * `quantizeLoopEnd` must snap in (loop points are buffer time). */
60
74
  #tabla = [];
75
+ #grain;
61
76
  #drums;
62
77
  #scheduleIds = [];
63
78
  #fadeTimer;
79
+ /* Practice: where the playhead is, what repeats, whose turn it is. */
80
+ /** The last note played or sought — what "this line" means to the controls. */
81
+ #cursor = null;
82
+ /** Where the next fresh play() starts (a seek made while stopped). */
83
+ #startAt = null;
84
+ #loop = null;
85
+ #echo = false;
86
+ /** With echo on, every other pass is the learner's: the melody rests. */
87
+ #turn = false;
88
+ /** The arranged timeline, so a seek or a loop can be placed while playing. */
89
+ #timeline;
90
+ /** Listeners on the bound sheet, removed on rebind. */
91
+ #unbind;
92
+ #sheet;
93
+ /** Preview cap, counted in listening time so a seek or a loop can't dodge it. */
94
+ #listened = 0;
95
+ #listenedSince = 0;
96
+ #limitId;
64
97
  constructor(options = {}) {
65
98
  this.#opts = {
66
99
  soundsBaseUrl: options.soundsBaseUrl ?? DEFAULT_SOUNDS_BASE,
@@ -76,6 +109,7 @@ var SwaralipiPlayer = class {
76
109
  tanpura: options.tanpura ?? true,
77
110
  previewLimitSec: options.previewLimitSec ?? Infinity,
78
111
  onHighlight: options.onHighlight,
112
+ onLoop: options.onLoop,
79
113
  onEvent: options.onEvent,
80
114
  onBeat: options.onBeat,
81
115
  onState: options.onState,
@@ -128,7 +162,19 @@ var SwaralipiPlayer = class {
128
162
  /** Seconds played so far, off the Transport's own clock — so it holds while
129
163
  * paused, and reads 0 before the first play and after stop(). */
130
164
  get positionSec() {
131
- return this.#tone?.getTransport().seconds ?? 0;
165
+ return Math.max(this.#tone?.getTransport().seconds ?? 0, 0);
166
+ }
167
+ /** The note last played or sought (`part.row.note`), or null. */
168
+ get cursor() {
169
+ return this.#cursor;
170
+ }
171
+ /** The repeated lines, or null. */
172
+ get loop() {
173
+ return this.#loop && { ...this.#loop };
174
+ }
175
+ /** Whether each repeat is followed by a silent pass for the learner. */
176
+ get echo() {
177
+ return this.#echo;
132
178
  }
133
179
  /* ----------------------------------------------------------------- */
134
180
  /* Loading + transport control */
@@ -140,6 +186,7 @@ var SwaralipiPlayer = class {
140
186
  this.#taal = void 0;
141
187
  this.#taalOverride = void 0;
142
188
  this.#bpmTweak = void 0;
189
+ this.#resetPractice();
143
190
  this.#setState("ready");
144
191
  }
145
192
  /**
@@ -156,43 +203,137 @@ var SwaralipiPlayer = class {
156
203
  this.#doc = void 0;
157
204
  this.#taalOverride = void 0;
158
205
  this.#bpmTweak = void 0;
206
+ this.#resetPractice();
159
207
  this.#setState("ready");
160
208
  }
161
- /** Start (or resume) playback. Must be triggered from a user gesture so the
162
- * browser permits `AudioContext` resume. */
163
- async play() {
164
- if (this.#doc === void 0 && this.#taal === void 0 || this.#state === "playing") return;
165
- const Tone = await this.#ensureTone();
166
- await Tone.start();
167
- if (this.#state === "paused") {
209
+ /** Start (or resume) playback — from `from` (`part.row.note`, or `part.row`
210
+ * for a line's start) when given, which also jumps there mid-play. Must be
211
+ * triggered from a user gesture so the browser permits `AudioContext`
212
+ * resume. */
213
+ async play(from) {
214
+ if (this.#doc === void 0 && this.#taal === void 0) return;
215
+ if (from !== void 0) this.seek(from);
216
+ if (this.#state === "playing" || this.#state === "loading") return;
217
+ const resume = this.#state === "paused";
218
+ if (!resume) this.#setState("loading");
219
+ let Tone;
220
+ try {
221
+ Tone = await this.#ensureTone();
222
+ await Tone.start();
223
+ } catch (err) {
224
+ if (!resume) this.#setState("ready");
225
+ throw err;
226
+ }
227
+ if (resume) {
168
228
  Tone.getTransport().start();
169
229
  this.#setState("playing");
230
+ this.#armLimit();
170
231
  return;
171
232
  }
172
233
  this.#cancelFade();
173
- Tone.getTransport().stop();
174
- this.#setState("loading");
234
+ const transport = Tone.getTransport();
235
+ if (transport.state !== "stopped") transport.stop();
175
236
  await this.#arrange(Tone);
176
- Tone.getTransport().start();
237
+ transport.start(void 0, this.#startSec() - this.#preroll());
238
+ this.#startAt = null;
177
239
  this.#setState("playing");
178
- const limit = this.#opts.previewLimitSec;
179
- if (Number.isFinite(limit)) {
180
- Tone.getTransport().scheduleOnce(() => {
181
- Tone.getDraw().schedule(() => {
182
- if (this.#state === "playing") {
183
- this.stop();
184
- this.#opts.onLimit?.();
185
- }
186
- }, this.#tone.now());
187
- }, limit);
188
- }
240
+ this.#armLimit();
189
241
  }
190
242
  /** Pause; `play()` resumes from the same position. */
191
243
  pause() {
192
244
  if (this.#tone === void 0 || this.#state !== "playing") return;
193
245
  this.#tone.getTransport().pause();
246
+ this.#disarmLimit();
194
247
  this.#setState("paused");
195
248
  }
249
+ /**
250
+ * Move the playhead to `path` (`part.row.note`, or `part.row` for a line's
251
+ * start). Playing, it jumps and keeps playing; paused or stopped, the next
252
+ * `play()` starts there. A repeat follows the playhead: seeking outside the
253
+ * repeated lines moves the repeat to the new line.
254
+ */
255
+ seek(path) {
256
+ const plan = this.#plan();
257
+ const i = plan === void 0 ? -1 : eventAt(plan.schedule.events, path);
258
+ if (plan === void 0 || i < 0) return;
259
+ const { lines } = plan;
260
+ const at = plan.schedule.events[i].path;
261
+ this.#cursor = at;
262
+ this.#turn = false;
263
+ const k = lineOf(lines, at);
264
+ const [a, b] = this.#loopSpan(lines);
265
+ if (this.#loop !== null && (k < a || k > b)) this.#loop = { from: lines[k].row, to: lines[k].row };
266
+ this.#emitLoop();
267
+ if (this.#timeline !== void 0 && (this.#state === "playing" || this.#state === "paused")) {
268
+ this.#applyLoop();
269
+ this.#jump(this.#eventSec(i));
270
+ if (this.#state === "paused") this.#emitHighlight(at);
271
+ } else {
272
+ this.#startAt = at;
273
+ this.#emitHighlight(at);
274
+ }
275
+ }
276
+ /**
277
+ * Repeat lines `from`..`to` (`part.row`, either order, inclusive; `to`
278
+ * defaults to `from`). With no argument, the line under the playhead.
279
+ * `null` stops repeating, and ends the sing-back with it. A playhead
280
+ * outside the new repeat moves to its start; live while playing.
281
+ */
282
+ setLoop(from, to) {
283
+ this.#turn = false;
284
+ if (from === null) {
285
+ this.#loop = null;
286
+ this.#echo = false;
287
+ } else {
288
+ const lines = this.#lines();
289
+ if (lines.length === 0) return;
290
+ const here = this.#cursor === null ? -1 : lineOf(lines, this.#cursor);
291
+ const a = from === void 0 ? Math.max(here, 0) : lineOf(lines, from);
292
+ const b = to === void 0 ? a : lineOf(lines, to);
293
+ this.#loop = { from: lines[Math.min(a, b)].row, to: lines[Math.max(a, b)].row };
294
+ if (here < Math.min(a, b) || here > Math.max(a, b)) return this.seek(this.#loop.from);
295
+ }
296
+ this.#applyLoop();
297
+ this.#emitLoop();
298
+ }
299
+ /**
300
+ * Follow every pass of the repeat with a silent one: the tabla and tanpura
301
+ * keep time and the highlight keeps moving, but the melody rests so the
302
+ * learner can sing the line back — the teacher sings, the student answers.
303
+ * Turning it on with nothing repeating repeats the current line.
304
+ */
305
+ setEcho(on) {
306
+ this.#echo = on;
307
+ this.#turn = false;
308
+ if (on && this.#loop === null) this.setLoop();
309
+ else this.#emitLoop();
310
+ }
311
+ /**
312
+ * Move by whole lines. Like a player's previous-track button, a step back
313
+ * from more than a matra into the line restarts that line first.
314
+ * `±Infinity` goes to the first / last line.
315
+ */
316
+ stepLine(delta) {
317
+ const plan = this.#plan();
318
+ if (plan === void 0 || plan.lines.length === 0) return;
319
+ const { lines, schedule } = plan;
320
+ const k = this.#cursor === null ? -1 : lineOf(lines, this.#cursor);
321
+ const into = k < 0 ? 0 : schedule.events[eventAt(schedule.events, this.#cursor)].beat - lines[k].beat;
322
+ const step = delta < 0 && this.#state === "playing" && into > 1 ? delta + 1 : delta;
323
+ this.seek(lines[Math.min(Math.max(k + step, 0), lines.length - 1)].row);
324
+ }
325
+ /** Move by single notes (rests and holds included — each is a column). */
326
+ stepNote(delta) {
327
+ const events = this.#plan()?.schedule.events ?? [];
328
+ let i = this.#cursor === null ? -1 : eventAt(events, this.#cursor);
329
+ for (let left = Math.abs(delta); left > 0; left--) {
330
+ do
331
+ i += Math.sign(delta);
332
+ while (events[i]?.glide === true);
333
+ }
334
+ const ev = events[Math.min(Math.max(i, 0), events.length - 1)];
335
+ if (ev !== void 0) this.seek(ev.path);
336
+ }
196
337
  /**
197
338
  * Stop and rewind to the start. When sounding, the accompaniment (tabla /
198
339
  * metronome / tanpura, which otherwise loop) is gently faded out over
@@ -200,6 +341,13 @@ var SwaralipiPlayer = class {
200
341
  * playing or cut abruptly; otherwise it halts immediately.
201
342
  */
202
343
  stop() {
344
+ this.#disarmLimit();
345
+ this.#listened = 0;
346
+ this.#startAt = null;
347
+ if (this.#turn) {
348
+ this.#turn = false;
349
+ this.#emitLoop();
350
+ }
203
351
  if (this.#tone === void 0 || this.#state === "idle") {
204
352
  this.#halt();
205
353
  return;
@@ -220,11 +368,15 @@ var SwaralipiPlayer = class {
220
368
  * looping accompaniment, restore the master gain for the next play. */
221
369
  #halt() {
222
370
  this.#cancelFadeTimer();
371
+ this.#disarmLimit();
372
+ this.#timeline = void 0;
373
+ this.#fence = 0;
223
374
  if (this.#tone !== void 0) {
224
375
  const transport = this.#tone.getTransport();
225
376
  transport.stop();
226
377
  transport.cancel(0);
227
378
  transport.position = 0;
379
+ transport.loop = false;
228
380
  }
229
381
  this.#disposeTabla();
230
382
  this.#restoreMasterGain();
@@ -255,6 +407,27 @@ var SwaralipiPlayer = class {
255
407
  if (loop.buffer?.loaded) loop.loopEnd = quantizeLoopEnd(loop.buffer.duration, fileSpb);
256
408
  }
257
409
  }
410
+ /** Count down whatever listening time the preview cap has left. On the
411
+ * audio clock, which keeps running while the page is hidden. */
412
+ #armLimit() {
413
+ const left = this.#opts.previewLimitSec - this.#listened;
414
+ const context = this.#tone?.getContext();
415
+ if (!Number.isFinite(left) || context === void 0) return;
416
+ this.#listenedSince = context.now();
417
+ this.#limitId = context.setTimeout(() => {
418
+ this.#limitId = void 0;
419
+ if (this.#state !== "playing") return;
420
+ this.stop();
421
+ this.#opts.onLimit?.();
422
+ }, Math.max(left, 0));
423
+ }
424
+ #disarmLimit() {
425
+ const context = this.#tone?.getContext();
426
+ if (this.#limitId === void 0 || context === void 0) return;
427
+ context.clearTimeout(this.#limitId);
428
+ this.#limitId = void 0;
429
+ this.#listened += context.now() - this.#listenedSince;
430
+ }
258
431
  #restoreMasterGain() {
259
432
  if (this.#master !== void 0 && this.#tone !== void 0) {
260
433
  this.#master.gain.cancelScheduledValues(this.#tone.now());
@@ -294,18 +467,81 @@ var SwaralipiPlayer = class {
294
467
  this.#taalAuto = id === null;
295
468
  this.#reapply();
296
469
  }
297
- /** Wire highlight output to a `<swaralipi-sheet>` (or any highlight target).
298
- * Pass `{ autoScroll }` to keep the active note in view while playing. */
470
+ /**
471
+ * Wire highlight output to a `<swaralipi-sheet>` (or any highlight target).
472
+ * Pass `{ autoScroll }` to keep the active note in view while playing.
473
+ *
474
+ * A sheet element also becomes the practice surface: a tap on a line plays
475
+ * from the note under it (Shift stretches the repeat to that line), and
476
+ * while it has focus — only then, so no key is taken from the page — Space
477
+ * plays/pauses, ↑/↓ move by line, ←/→ by note, Home/End jump to the first /
478
+ * last line, R repeats the line, T turns sing-back on/off, Esc stops the
479
+ * repeat and Shift+↑/↓ stretch it. To gate a tap-to-play (a play quota, say),
480
+ * `preventDefault()` its `swaralipi-seek` event in a capturing listener.
481
+ */
299
482
  bindSheet(target, opts = {}) {
300
483
  const block = opts.autoScroll === true ? "center" : opts.autoScroll || void 0;
301
- this.#opts.onHighlight = (path) => {
484
+ this.#opts.onHighlight = (path, yourTurn) => {
302
485
  target.clearHighlights();
303
486
  if (path === null) return;
304
- const el = target.highlight(path);
305
- if (block !== void 0 && el !== null) {
306
- el.scrollIntoView({ block, inline: "nearest", behavior: "smooth" });
487
+ const el2 = target.highlight(path, yourTurn);
488
+ if (block !== void 0 && el2 !== null) {
489
+ el2.scrollIntoView({ block, inline: "nearest", behavior: "smooth" });
307
490
  }
308
491
  };
492
+ this.#sheet = target;
493
+ target.markLoop?.(this.#loop, this.#turn);
494
+ this.#unbind?.abort();
495
+ if (!(target instanceof EventTarget)) return;
496
+ const el = target;
497
+ const { signal } = this.#unbind = new AbortController();
498
+ el.setAttribute("seekable", "");
499
+ signal.addEventListener("abort", () => el.removeAttribute("seekable"));
500
+ el.addEventListener("swaralipi-seek", (e) => this.#onTap(e), { signal });
501
+ el.addEventListener("keydown", (e) => this.#onKey(e), { signal });
502
+ }
503
+ #onTap(e) {
504
+ if (e.defaultPrevented) return;
505
+ if (e.detail.extend) this.#extendLoop(e.detail.path);
506
+ else void this.play(e.detail.path);
507
+ }
508
+ #onKey(e) {
509
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
510
+ const shift = e.shiftKey;
511
+ const act = {
512
+ Space: () => this.#state === "playing" ? this.pause() : void this.play(),
513
+ ArrowUp: () => shift ? this.#extendBy(-1) : this.stepLine(-1),
514
+ ArrowDown: () => shift ? this.#extendBy(1) : this.stepLine(1),
515
+ ArrowLeft: () => this.stepNote(-1),
516
+ ArrowRight: () => this.stepNote(1),
517
+ Home: () => this.stepLine(-Infinity),
518
+ End: () => this.stepLine(Infinity),
519
+ KeyR: () => this.setLoop(this.#loop === null ? void 0 : null),
520
+ KeyT: () => this.setEcho(!this.#echo),
521
+ Escape: this.#loop === null ? void 0 : () => this.setLoop(null)
522
+ };
523
+ const run = act[e.code];
524
+ if (run === void 0) return;
525
+ e.preventDefault();
526
+ run();
527
+ }
528
+ /** Grow the repeat (or start one from the current line) to take in `path`'s line. */
529
+ #extendLoop(path) {
530
+ const lines = this.#lines();
531
+ if (lines.length === 0) return;
532
+ const k = lineOf(lines, path);
533
+ const here = this.#cursor === null ? k : lineOf(lines, this.#cursor);
534
+ const [a, b] = this.#loop === null ? [here, here] : this.#loopSpan(lines);
535
+ this.setLoop(lines[Math.min(a, k)].row, lines[Math.max(b, k)].row);
536
+ }
537
+ /** Shift+↑/↓: one more line above / below the repeat. */
538
+ #extendBy(delta) {
539
+ const lines = this.#lines();
540
+ if (lines.length === 0) return;
541
+ const here = this.#cursor === null ? 0 : lineOf(lines, this.#cursor);
542
+ const [a, b] = this.#loop === null ? [here, here] : this.#loopSpan(lines);
543
+ const k = Math.min(Math.max((delta < 0 ? a : b) + delta, 0), lines.length - 1);
544
+ this.#extendLoop(lines[k].row);
309
545
  }
310
546
  /**
311
547
  * Check whether an instrument's samples are reachable (its CDN is up / the
@@ -334,6 +570,8 @@ var SwaralipiPlayer = class {
334
570
  /** Release all audio resources. The instance is unusable afterward. */
335
571
  dispose() {
336
572
  this.#halt();
573
+ this.#unbind?.abort();
574
+ this.#tone?.getTransport().off("loop", this.#onPass);
337
575
  for (const { sampler } of this.#samplers.values()) sampler.dispose();
338
576
  this.#samplers.clear();
339
577
  for (const { buffers } of this.#glide.values()) buffers.dispose();
@@ -354,9 +592,110 @@ var SwaralipiPlayer = class {
354
592
  this.#state = state;
355
593
  this.#opts.onState?.(state);
356
594
  }
357
- #emitHighlight(path) {
358
- this.#opts.onHighlight?.(path);
595
+ #emitHighlight(path, yourTurn = false) {
596
+ this.#opts.onHighlight?.(path, yourTurn);
597
+ }
598
+ #emitLoop() {
599
+ this.#opts.onLoop?.({ loop: this.loop, echo: this.#echo, yourTurn: this.#turn });
600
+ this.#sheet?.markLoop?.(this.#loop, this.#turn);
601
+ }
602
+ /** A new document starts with nothing repeated, from the top. */
603
+ #resetPractice() {
604
+ this.#cursor = this.#startAt = this.#loop = null;
605
+ this.#echo = this.#turn = false;
606
+ this.#listened = 0;
607
+ this.#emitLoop();
608
+ }
609
+ /** The timeline to navigate: the arranged one while playing, else built
610
+ * fresh — it is pure and cheap, and a stopped player may have a new tonic
611
+ * or tempo since. */
612
+ #plan() {
613
+ if (this.#timeline !== void 0) return this.#timeline;
614
+ if (this.#doc === void 0) return void 0;
615
+ const schedule = buildSchedule(this.#doc, this.#scheduleOpts());
616
+ return { schedule, lines: scheduleLines(schedule) };
617
+ }
618
+ #lines() {
619
+ return this.#plan()?.lines ?? [];
620
+ }
621
+ /** The repeat as line indices (`[0, -1]`, i.e. empty, when off). */
622
+ #loopSpan(lines) {
623
+ return this.#loop === null ? [0, -1] : [lineOf(lines, this.#loop.from), lineOf(lines, this.#loop.to)];
359
624
  }
625
+ /** The repeat's lines on the arranged timeline, cached for the per-note
626
+ * checks (#hears), which run on every note. */
627
+ #loopLines = [0, -1];
628
+ /** Whether line `k` may sound: any line, unless a repeat holds others. */
629
+ #hears(k) {
630
+ return this.#loop === null || k >= this.#loopLines[0] && k <= this.#loopLines[1];
631
+ }
632
+ /** Transport seconds of event `i`, pulled back by its কণ so the grace is
633
+ * heard too. */
634
+ #eventSec(i) {
635
+ const { schedule, spb, shift } = this.#timeline;
636
+ const ev = schedule.events[i];
637
+ return Math.max((ev.beat - GRACE_STEP * ev.kan.length) * spb + shift, 0);
638
+ }
639
+ /** The repeat in transport seconds: from its first note's own start (its
640
+ * কণ included) for exactly the lines' length, so every pass keeps the
641
+ * tabla's phase. */
642
+ #loopSec() {
643
+ const { lines, spb } = this.#timeline;
644
+ const [a, b] = this.#loopLines;
645
+ const start = this.#eventSec(lines[a].first);
646
+ return [start, start + (lines[b].endBeat - lines[a].beat) * spb];
647
+ }
648
+ /** Put the repeat on the transport, or take it off. */
649
+ #applyLoop() {
650
+ const transport = this.#tone?.getTransport();
651
+ if (transport === void 0 || this.#timeline === void 0) return;
652
+ this.#loopLines = this.#loopSpan(this.#timeline.lines);
653
+ if (this.#loop === null) {
654
+ transport.loop = false;
655
+ return;
656
+ }
657
+ transport.setLoopPoints(...this.#loopSec());
658
+ transport.loop = true;
659
+ }
660
+ /** Where a fresh play() starts: a seek made while stopped, else the repeat,
661
+ * else the top. */
662
+ #startSec() {
663
+ const events = this.#timeline?.schedule.events ?? [];
664
+ if (this.#startAt !== null) return this.#eventSec(Math.max(eventAt(events, this.#startAt), 0));
665
+ return this.#loop === null ? 0 : this.#loopSec()[0];
666
+ }
667
+ /** Move the transport; notes still ringing from the old place are let go. */
668
+ #jump(sec) {
669
+ const Tone = this.#tone;
670
+ const transport = Tone.getTransport();
671
+ this.#fence = Tone.now();
672
+ if (transport.loop) {
673
+ transport.loopEnd = this.#loopSec()[1] + 3600;
674
+ Tone.getContext().setTimeout(() => this.#applyLoop(), 0.15);
675
+ }
676
+ transport.seconds = sec - this.#preroll();
677
+ this.#voice?.releaseAll();
678
+ }
679
+ /** Audio time before which a note callback belongs to the timeline a seek
680
+ * just left (see #jump). */
681
+ #fence = 0;
682
+ /** START_PREROLL_SEC in whole transport ticks: an offset between ticks
683
+ * would shift every later repeat wrap by the remainder — measured, a
684
+ * 0.8 ms splice in the tabla at each pass. */
685
+ #preroll() {
686
+ const transport = this.#tone.getTransport();
687
+ const perSec = transport.bpm.value / 60 * transport.PPQ;
688
+ return Math.ceil(START_PREROLL_SEC * perSec) / perSec;
689
+ }
690
+ /** The melody voice of the arrangement, for #jump. */
691
+ #voice;
692
+ /** The repeat wrapped. With sing-back on, the turn passes between the
693
+ * player and the learner; the sheet is told at the wrap itself. */
694
+ #onPass = (time) => {
695
+ if (this.#loop === null || !this.#echo) return;
696
+ this.#turn = !this.#turn;
697
+ this.#tone?.getDraw().schedule(() => this.#emitLoop(), time);
698
+ };
360
699
  /** The taal one accompaniment segment plays: taal mode's, an explicit
361
700
  * setTaal override (pinned across the whole song, তালফেরতা included), else
362
701
  * the segment's own. `null` = a মুক্তছন্দ passage. */
@@ -426,11 +765,13 @@ var SwaralipiPlayer = class {
426
765
  return this.#tone;
427
766
  }
428
767
  /** Hard stop → rebuild → restart, only while playing; otherwise just store.
429
- * Uses #halt (not the stop() fade) so a setting change restarts instantly. */
768
+ * Uses #halt (not the stop() fade) so a setting change restarts instantly,
769
+ * from the note it was on — slowing down a repeated line keeps repeating it. */
430
770
  #reapply() {
431
771
  if (this.#state !== "playing") return;
772
+ const at = this.#cursor;
432
773
  this.#halt();
433
- void this.play();
774
+ void this.play(at ?? void 0);
434
775
  }
435
776
  /** The master bus everything routes through (created once, → destination). */
436
777
  #ensureMaster(Tone) {
@@ -446,6 +787,7 @@ var SwaralipiPlayer = class {
446
787
  const transport = Tone.getTransport();
447
788
  transport.cancel(0);
448
789
  transport.position = 0;
790
+ transport.off("loop", this.#onPass).on("loop", this.#onPass);
449
791
  this.#scheduleIds = [];
450
792
  this.#ensureMaster(Tone);
451
793
  this.#restoreMasterGain();
@@ -453,24 +795,34 @@ var SwaralipiPlayer = class {
453
795
  transport.bpm.value = bpm;
454
796
  const spb = 60 / bpm;
455
797
  const schedule = buildSchedule(this.#doc, this.#scheduleOpts());
798
+ const lines = scheduleLines(schedule);
456
799
  const range = midiRange(schedule);
457
800
  const id = this.#effectiveInstrument();
458
801
  const def = this.#resolveDef(id) ?? INSTRUMENTS.harmonium;
459
802
  const melody = this.#buildMelody(Tone, id, base, range);
803
+ this.#voice = melody;
460
804
  const glide = schedule.events.some((ev) => ev.glide === true) ? this.#buildGlide(Tone, id, base, range) : void 0;
461
805
  const shift = this.#opts.rhythm !== "none" ? schedule.pickupBeats * spb : 0;
806
+ this.#timeline = { schedule, lines, spb, shift };
462
807
  this.#buildRhythm(Tone, transport, base, spb, schedule.tempo, shift);
463
808
  this.#buildTanpura(Tone, transport, base, spb, schedule.tempo, shift);
464
809
  await loadedOrWarn(Tone);
465
810
  this.#quantizeTablaLoops();
466
811
  const draw = Tone.getDraw();
467
812
  const mediaLead = Math.min(MEDIA_LEAD_SEC, spb);
813
+ let line = 0;
468
814
  schedule.events.forEach((ev, i) => {
815
+ while (lines[line + 1] !== void 0 && lines[line + 1].first <= i) line++;
816
+ const k = line;
817
+ const heard = (time) => time >= this.#fence && this.#hears(k);
818
+ const sounds = (time) => heard(time) && !this.#turn;
469
819
  const at = ev.beat * spb + shift;
470
820
  if (ev.glide !== true) {
471
821
  for (const g of graceTouches(ev)) {
472
822
  const graceName = midiToSampleName(g.midi);
473
- transport.scheduleOnce((time) => melody.triggerAttackRelease(graceName, spb * 0.4, time), Math.max(g.beat * spb + shift, 0));
823
+ transport.schedule((time) => {
824
+ if (sounds(time)) melody.triggerAttackRelease(graceName, spb * 0.4, time);
825
+ }, Math.max(g.beat * spb + shift, 0));
474
826
  }
475
827
  }
476
828
  if (ev.midi !== null && ev.glide !== true) {
@@ -484,7 +836,8 @@ var SwaralipiPlayer = class {
484
836
  ...targets.map((t) => ({ midi: t.midi, at: (t.beat - ev.beat) * spb }))
485
837
  ];
486
838
  const end = (last.beat + last.durationBeats - ev.beat) * spb;
487
- transport.scheduleOnce((time) => {
839
+ transport.schedule((time) => {
840
+ if (!sounds(time)) return;
488
841
  triggerGlide(
489
842
  Tone,
490
843
  glide.buffers,
@@ -496,14 +849,24 @@ var SwaralipiPlayer = class {
496
849
  );
497
850
  }, at);
498
851
  } else {
499
- transport.scheduleOnce((time) => melody.triggerAttackRelease(name, dur, time), at);
852
+ transport.schedule((time) => {
853
+ if (sounds(time)) melody.triggerAttackRelease(name, dur, time);
854
+ }, at);
500
855
  }
501
856
  }
502
857
  if (this.#opts.onEvent !== void 0) {
503
- transport.scheduleOnce(() => this.#opts.onEvent?.(ev.path), Math.max(at - mediaLead, 0));
858
+ transport.schedule((time) => {
859
+ if (heard(time)) this.#opts.onEvent?.(ev.path);
860
+ }, Math.max(at - mediaLead, 0));
504
861
  }
505
- transport.scheduleOnce((time) => draw.schedule(() => this.#emitHighlight(ev.path), time), at);
862
+ transport.schedule((time) => {
863
+ if (!heard(time)) return;
864
+ const turn = this.#turn;
865
+ this.#cursor = ev.path;
866
+ draw.schedule(() => this.#emitHighlight(ev.path, turn), time);
867
+ }, at);
506
868
  });
869
+ this.#applyLoop();
507
870
  const endAt = playDurationSec(schedule, spb, this.#opts.rhythm);
508
871
  transport.scheduleOnce((time) => {
509
872
  this.#opts.onEvent?.(null);
@@ -595,13 +958,15 @@ var SwaralipiPlayer = class {
595
958
  const to = i === segments.length - 1 ? Infinity : seg.endBeat * spb + shift;
596
959
  const beatSec = spb / seg.scale;
597
960
  const span = to === Infinity ? void 0 : to - from;
961
+ const beatAt = (time) => Math.round((transport.getSecondsAtTime(time) - from) / beatSec);
598
962
  if (rhythm === "tabla" && taalId !== void 0 && SAMPLED_TAALS.has(taalId) && base !== void 0) {
599
963
  const bpm = 60 / beatSec;
600
964
  const fileBpm = nearestTablaBpm(bpm, tablaBpms(taalId));
601
965
  const rate = bpm / fileBpm;
602
966
  const semis = tablaShiftSemitones(this.#tonicSemitone());
603
967
  const url = tablaUrl(base, taalId, fileBpm, this.#variation(own));
604
- const loop = (rate === 1 && semis === 0 ? new Tone.Player({ url, loop: true }) : new Tone.GrainPlayer({
968
+ this.#grain ??= seekableGrain(Tone);
969
+ const loop = (rate === 1 && semis === 0 ? new Tone.Player({ url, loop: true }) : new this.#grain({
605
970
  url,
606
971
  loop: true,
607
972
  playbackRate: rate,
@@ -612,9 +977,10 @@ var SwaralipiPlayer = class {
612
977
  loop.sync().start(from);
613
978
  if (span !== void 0) {
614
979
  this.#scheduleIds.push(
615
- transport.scheduleOnce((t) => {
980
+ transport.schedule((t) => {
616
981
  loop.volume.setValueAtTime(0, t);
617
982
  loop.volume.linearRampToValueAtTime(-40, t + LAY_TAIL_SEC);
983
+ loop.volume.setValueAtTime(0, t + LAY_TAIL_SEC + 0.05);
618
984
  }, to)
619
985
  );
620
986
  loop.stop(to + LAY_TAIL_SEC);
@@ -626,13 +992,11 @@ var SwaralipiPlayer = class {
626
992
  this.#drums ??= createDrumKit(Tone).connect(master);
627
993
  const drums = this.#drums;
628
994
  const pattern = drumPattern(this.#taal ?? (taalId !== void 0 ? TAALS[taalId] : own) ?? { numBeats: 4, sam: 0 });
629
- let beat2 = 0;
630
995
  this.#scheduleIds.push(
631
996
  transport.scheduleRepeat(
632
997
  (time) => {
633
- const voice = pattern[beat2 % pattern.length];
998
+ const voice = pattern[beatAt(time) % pattern.length];
634
999
  if (drums.has(voice)) drums.player(voice).start(time);
635
- beat2 += 1;
636
1000
  },
637
1001
  beatSec,
638
1002
  from,
@@ -644,13 +1008,11 @@ var SwaralipiPlayer = class {
644
1008
  this.#metronome ??= createMetronome(Tone, base).connect(master);
645
1009
  const metro = this.#metronome;
646
1010
  const cycle = (this.#taal ?? seg.taal)?.numBeats ?? 4;
647
- let beat = 0;
648
1011
  this.#scheduleIds.push(
649
1012
  transport.scheduleRepeat(
650
1013
  (time) => {
651
- metro.volume.setValueAtTime(beat % cycle === 0 ? 0 : -8, time);
1014
+ metro.volume.setValueAtTime(beatAt(time) % cycle === 0 ? 0 : -8, time);
652
1015
  metro.start(time);
653
- beat += 1;
654
1016
  },
655
1017
  beatSec,
656
1018
  from,
@@ -676,15 +1038,14 @@ var SwaralipiPlayer = class {
676
1038
  segments.forEach((seg, k) => {
677
1039
  const from = k === 0 ? 0 : seg.beat * spb + shift;
678
1040
  const to = k === segments.length - 1 ? Infinity : seg.endBeat * spb + shift;
679
- let i = 0;
1041
+ const every = spb * 2 / seg.scale;
680
1042
  this.#scheduleIds.push(
681
1043
  transport.scheduleRepeat(
682
1044
  (time) => {
683
- const stem = pattern[i % pattern.length];
1045
+ const stem = pattern[Math.round((transport.getSecondsAtTime(time) - from) / every) % pattern.length];
684
1046
  if (players.has(stem)) players.player(stem).start(time);
685
- i += 1;
686
1047
  },
687
- spb * 2 / seg.scale,
1048
+ every,
688
1049
  from,
689
1050
  to === Infinity ? void 0 : to - from
690
1051
  )