@ugfoundation/swaralipi-js 0.9.2 → 0.10.1

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 (47) hide show
  1. package/README.md +17 -0
  2. package/dist/{batch-IHEXFEGC.js → batch-5ZFEKXKS.js} +5 -5
  3. package/dist/{batch-IHEXFEGC.js.map → batch-5ZFEKXKS.js.map} +1 -1
  4. package/dist/{chunk-TZBDIRIA.js → chunk-R6VNIBMG.js} +7 -7
  5. package/dist/chunk-R6VNIBMG.js.map +1 -0
  6. package/dist/{chunk-MYVIQU4O.js → chunk-SR7NFEGL.js} +5 -3
  7. package/dist/chunk-SR7NFEGL.js.map +1 -0
  8. package/dist/{chunk-F23LZMIU.js → chunk-UPJYLXYX.js} +3 -3
  9. package/dist/{chunk-F23LZMIU.js.map → chunk-UPJYLXYX.js.map} +1 -1
  10. package/dist/{chunk-SEN266BY.js → chunk-VCLLSNAK.js} +27 -4
  11. package/dist/chunk-VCLLSNAK.js.map +1 -0
  12. package/dist/cli.cjs +7 -5
  13. package/dist/cli.cjs.map +1 -1
  14. package/dist/cli.js +3 -3
  15. package/dist/convert/index.cjs +7 -5
  16. package/dist/convert/index.cjs.map +1 -1
  17. package/dist/convert/index.js +3 -3
  18. package/dist/index.cjs +4 -2
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +2 -2
  23. package/dist/{lay-CZIzR9cM.d.ts → lay-0LnKv1LL.d.ts} +7 -1
  24. package/dist/{lay-CfDlPhH2.d.cts → lay-J17-gS6O.d.cts} +7 -1
  25. package/dist/player/index.cjs +439 -51
  26. package/dist/player/index.cjs.map +1 -1
  27. package/dist/player/index.d.cts +78 -13
  28. package/dist/player/index.d.ts +78 -13
  29. package/dist/player/index.js +414 -53
  30. package/dist/player/index.js.map +1 -1
  31. package/dist/{schedule-CU-VECDH.d.cts → schedule-B1S53131.d.cts} +28 -2
  32. package/dist/{schedule-BaovCtmf.d.ts → schedule-DtcYB-o4.d.ts} +28 -2
  33. package/dist/spec/index.cjs +4 -2
  34. package/dist/spec/index.cjs.map +1 -1
  35. package/dist/spec/index.d.cts +2 -2
  36. package/dist/spec/index.d.ts +2 -2
  37. package/dist/spec/index.js +2 -2
  38. package/dist/viewer/index.cjs +148 -7
  39. package/dist/viewer/index.cjs.map +1 -1
  40. package/dist/viewer/index.d.cts +22 -5
  41. package/dist/viewer/index.d.ts +22 -5
  42. package/dist/viewer/index.js +146 -8
  43. package/dist/viewer/index.js.map +1 -1
  44. package/package.json +1 -1
  45. package/dist/chunk-MYVIQU4O.js.map +0 -1
  46. package/dist/chunk-SEN266BY.js.map +0 -1
  47. package/dist/chunk-TZBDIRIA.js.map +0 -1
@@ -19,8 +19,10 @@ var TAALS = {
19
19
  // আড়াঠেকা
20
20
  jhampak: { id: "jhampak", numBeats: 5, bhaags: [3, 2], sam: 0 },
21
21
  // ঝম্পক
22
- dhamaar: { id: "dhamaar", numBeats: 14, bhaags: [5, 2, 3, 4], sam: 0, khaali: [7] }
22
+ dhamaar: { id: "dhamaar", numBeats: 14, bhaags: [5, 2, 3, 4], sam: 0, khaali: [7] },
23
23
  // ধামার
24
+ rupakra: { id: "rupakra", numBeats: 8, bhaags: [3, 2, 3], sam: 0 }
25
+ // রূপকড়া
24
26
  };
25
27
  function sameTaal(a, b) {
26
28
  if (a === b) return true;
@@ -185,6 +187,29 @@ function graceTouches(ev) {
185
187
  ...ev.resh.map((midi, g) => ({ midi, beat: ev.beat + GRACE_STEP * (g + 1) }))
186
188
  ];
187
189
  }
190
+ var rowOf = (path) => path.slice(0, path.lastIndexOf("."));
191
+ function scheduleLines(schedule) {
192
+ const lines = [];
193
+ schedule.events.forEach((ev, i) => {
194
+ if (lines.at(-1)?.row !== rowOf(ev.path)) lines.push({ row: rowOf(ev.path), first: i, beat: ev.beat, endBeat: 0 });
195
+ });
196
+ lines.forEach((line, k) => line.endBeat = lines[k + 1]?.beat ?? schedule.totalBeats);
197
+ return lines;
198
+ }
199
+ function eventAt(events, path) {
200
+ const [p = 0, r = 0, n = 0] = path.split(".").map(Number);
201
+ const inRow = (ev) => ev !== void 0 && rowOf(ev.path) === `${p}.${r}`;
202
+ let i = events.findIndex((ev) => {
203
+ const [ep = 0, er = 0, en = 0] = ev.path.split(".").map(Number);
204
+ return ep > p || ep === p && (er > r || er === r && en >= n);
205
+ });
206
+ if (!inRow(events[i])) {
207
+ const last = (i < 0 ? events.length : i) - 1;
208
+ if (inRow(events[last])) i = last;
209
+ }
210
+ while (i > 0 && events[i].glide === true) i--;
211
+ return i;
212
+ }
188
213
  var SAM_TICK = /^\d+[fF]$/;
189
214
  function buildSchedule(doc, opts = {}) {
190
215
  const tonicPc = parseTonic(opts.tonic ?? doc.meta.tonic);
@@ -557,10 +582,24 @@ function createTanpura(Tone, base, stems = TANPURA_PCS) {
557
582
  // src/player/player.ts
558
583
  var STOP_FADE_SEC = 0.5;
559
584
  var LAY_TAIL_SEC = 0.12;
585
+ var START_PREROLL_SEC = 0.02;
560
586
  var TAAL_MODE_TEMPO = [
561
587
  { beat: 0, endBeat: Infinity, scale: 1, lay: "drut" }
562
588
  ];
563
589
  var MEDIA_LEAD_SEC = 0.25;
590
+ function lineOf(lines, path) {
591
+ const [p = 0, r = 0] = path.split(".").map(Number);
592
+ const k = lines.findIndex((line) => {
593
+ const [lp = 0, lr = 0] = line.row.split(".").map(Number);
594
+ return lp > p || lp === p && lr >= r;
595
+ });
596
+ return k < 0 ? lines.length - 1 : k;
597
+ }
598
+ var seekableGrain = (Tone) => class extends Tone.GrainPlayer {
599
+ _start(time, offset, duration) {
600
+ super._start(time, offset === void 0 ? offset : this.toSeconds(offset) * this.playbackRate, duration);
601
+ }
602
+ };
564
603
  function midiRange(schedule) {
565
604
  let lo = Infinity;
566
605
  let hi = -Infinity;
@@ -609,9 +648,28 @@ var SwaralipiPlayer = class {
609
648
  * `fileSpb` is the recorded loop's OWN beat length, which is the unit
610
649
  * `quantizeLoopEnd` must snap in (loop points are buffer time). */
611
650
  #tabla = [];
651
+ #grain;
612
652
  #drums;
613
653
  #scheduleIds = [];
614
654
  #fadeTimer;
655
+ /* Practice: where the playhead is, what repeats, whose turn it is. */
656
+ /** The last note played or sought — what "this line" means to the controls. */
657
+ #cursor = null;
658
+ /** Where the next fresh play() starts (a seek made while stopped). */
659
+ #startAt = null;
660
+ #loop = null;
661
+ #echo = false;
662
+ /** With echo on, every other pass is the learner's: the melody rests. */
663
+ #turn = false;
664
+ /** The arranged timeline, so a seek or a loop can be placed while playing. */
665
+ #timeline;
666
+ /** Listeners on the bound sheet, removed on rebind. */
667
+ #unbind;
668
+ #sheet;
669
+ /** Preview cap, counted in listening time so a seek or a loop can't dodge it. */
670
+ #listened = 0;
671
+ #listenedSince = 0;
672
+ #limitId;
615
673
  constructor(options = {}) {
616
674
  this.#opts = {
617
675
  soundsBaseUrl: options.soundsBaseUrl ?? DEFAULT_SOUNDS_BASE,
@@ -627,6 +685,7 @@ var SwaralipiPlayer = class {
627
685
  tanpura: options.tanpura ?? true,
628
686
  previewLimitSec: options.previewLimitSec ?? Infinity,
629
687
  onHighlight: options.onHighlight,
688
+ onLoop: options.onLoop,
630
689
  onEvent: options.onEvent,
631
690
  onBeat: options.onBeat,
632
691
  onState: options.onState,
@@ -679,7 +738,19 @@ var SwaralipiPlayer = class {
679
738
  /** Seconds played so far, off the Transport's own clock — so it holds while
680
739
  * paused, and reads 0 before the first play and after stop(). */
681
740
  get positionSec() {
682
- return this.#tone?.getTransport().seconds ?? 0;
741
+ return Math.max(this.#tone?.getTransport().seconds ?? 0, 0);
742
+ }
743
+ /** The note last played or sought (`part.row.note`), or null. */
744
+ get cursor() {
745
+ return this.#cursor;
746
+ }
747
+ /** The repeated lines, or null. */
748
+ get loop() {
749
+ return this.#loop && { ...this.#loop };
750
+ }
751
+ /** Whether each repeat is followed by a silent pass for the learner. */
752
+ get echo() {
753
+ return this.#echo;
683
754
  }
684
755
  /* ----------------------------------------------------------------- */
685
756
  /* Loading + transport control */
@@ -691,6 +762,7 @@ var SwaralipiPlayer = class {
691
762
  this.#taal = void 0;
692
763
  this.#taalOverride = void 0;
693
764
  this.#bpmTweak = void 0;
765
+ this.#resetPractice();
694
766
  this.#setState("ready");
695
767
  }
696
768
  /**
@@ -707,43 +779,137 @@ var SwaralipiPlayer = class {
707
779
  this.#doc = void 0;
708
780
  this.#taalOverride = void 0;
709
781
  this.#bpmTweak = void 0;
782
+ this.#resetPractice();
710
783
  this.#setState("ready");
711
784
  }
712
- /** Start (or resume) playback. Must be triggered from a user gesture so the
713
- * browser permits `AudioContext` resume. */
714
- async play() {
715
- if (this.#doc === void 0 && this.#taal === void 0 || this.#state === "playing") return;
716
- const Tone = await this.#ensureTone();
717
- await Tone.start();
718
- if (this.#state === "paused") {
785
+ /** Start (or resume) playback — from `from` (`part.row.note`, or `part.row`
786
+ * for a line's start) when given, which also jumps there mid-play. Must be
787
+ * triggered from a user gesture so the browser permits `AudioContext`
788
+ * resume. */
789
+ async play(from) {
790
+ if (this.#doc === void 0 && this.#taal === void 0) return;
791
+ if (from !== void 0) this.seek(from);
792
+ if (this.#state === "playing" || this.#state === "loading") return;
793
+ const resume = this.#state === "paused";
794
+ if (!resume) this.#setState("loading");
795
+ let Tone;
796
+ try {
797
+ Tone = await this.#ensureTone();
798
+ await Tone.start();
799
+ } catch (err) {
800
+ if (!resume) this.#setState("ready");
801
+ throw err;
802
+ }
803
+ if (resume) {
719
804
  Tone.getTransport().start();
720
805
  this.#setState("playing");
806
+ this.#armLimit();
721
807
  return;
722
808
  }
723
809
  this.#cancelFade();
724
- Tone.getTransport().stop();
725
- this.#setState("loading");
810
+ const transport = Tone.getTransport();
811
+ if (transport.state !== "stopped") transport.stop();
726
812
  await this.#arrange(Tone);
727
- Tone.getTransport().start();
813
+ transport.start(void 0, this.#startSec() - this.#preroll());
814
+ this.#startAt = null;
728
815
  this.#setState("playing");
729
- const limit = this.#opts.previewLimitSec;
730
- if (Number.isFinite(limit)) {
731
- Tone.getTransport().scheduleOnce(() => {
732
- Tone.getDraw().schedule(() => {
733
- if (this.#state === "playing") {
734
- this.stop();
735
- this.#opts.onLimit?.();
736
- }
737
- }, this.#tone.now());
738
- }, limit);
739
- }
816
+ this.#armLimit();
740
817
  }
741
818
  /** Pause; `play()` resumes from the same position. */
742
819
  pause() {
743
820
  if (this.#tone === void 0 || this.#state !== "playing") return;
744
821
  this.#tone.getTransport().pause();
822
+ this.#disarmLimit();
745
823
  this.#setState("paused");
746
824
  }
825
+ /**
826
+ * Move the playhead to `path` (`part.row.note`, or `part.row` for a line's
827
+ * start). Playing, it jumps and keeps playing; paused or stopped, the next
828
+ * `play()` starts there. A repeat follows the playhead: seeking outside the
829
+ * repeated lines moves the repeat to the new line.
830
+ */
831
+ seek(path) {
832
+ const plan = this.#plan();
833
+ const i = plan === void 0 ? -1 : eventAt(plan.schedule.events, path);
834
+ if (plan === void 0 || i < 0) return;
835
+ const { lines } = plan;
836
+ const at = plan.schedule.events[i].path;
837
+ this.#cursor = at;
838
+ this.#turn = false;
839
+ const k = lineOf(lines, at);
840
+ const [a, b] = this.#loopSpan(lines);
841
+ if (this.#loop !== null && (k < a || k > b)) this.#loop = { from: lines[k].row, to: lines[k].row };
842
+ this.#emitLoop();
843
+ if (this.#timeline !== void 0 && (this.#state === "playing" || this.#state === "paused")) {
844
+ this.#applyLoop();
845
+ this.#jump(this.#eventSec(i));
846
+ if (this.#state === "paused") this.#emitHighlight(at);
847
+ } else {
848
+ this.#startAt = at;
849
+ this.#emitHighlight(at);
850
+ }
851
+ }
852
+ /**
853
+ * Repeat lines `from`..`to` (`part.row`, either order, inclusive; `to`
854
+ * defaults to `from`). With no argument, the line under the playhead.
855
+ * `null` stops repeating, and ends the sing-back with it. A playhead
856
+ * outside the new repeat moves to its start; live while playing.
857
+ */
858
+ setLoop(from, to) {
859
+ this.#turn = false;
860
+ if (from === null) {
861
+ this.#loop = null;
862
+ this.#echo = false;
863
+ } else {
864
+ const lines = this.#lines();
865
+ if (lines.length === 0) return;
866
+ const here = this.#cursor === null ? -1 : lineOf(lines, this.#cursor);
867
+ const a = from === void 0 ? Math.max(here, 0) : lineOf(lines, from);
868
+ const b = to === void 0 ? a : lineOf(lines, to);
869
+ this.#loop = { from: lines[Math.min(a, b)].row, to: lines[Math.max(a, b)].row };
870
+ if (here < Math.min(a, b) || here > Math.max(a, b)) return this.seek(this.#loop.from);
871
+ }
872
+ this.#applyLoop();
873
+ this.#emitLoop();
874
+ }
875
+ /**
876
+ * Follow every pass of the repeat with a silent one: the tabla and tanpura
877
+ * keep time and the highlight keeps moving, but the melody rests so the
878
+ * learner can sing the line back — the teacher sings, the student answers.
879
+ * Turning it on with nothing repeating repeats the current line.
880
+ */
881
+ setEcho(on) {
882
+ this.#echo = on;
883
+ this.#turn = false;
884
+ if (on && this.#loop === null) this.setLoop();
885
+ else this.#emitLoop();
886
+ }
887
+ /**
888
+ * Move by whole lines. Like a player's previous-track button, a step back
889
+ * from more than a matra into the line restarts that line first.
890
+ * `±Infinity` goes to the first / last line.
891
+ */
892
+ stepLine(delta) {
893
+ const plan = this.#plan();
894
+ if (plan === void 0 || plan.lines.length === 0) return;
895
+ const { lines, schedule } = plan;
896
+ const k = this.#cursor === null ? -1 : lineOf(lines, this.#cursor);
897
+ const into = k < 0 ? 0 : schedule.events[eventAt(schedule.events, this.#cursor)].beat - lines[k].beat;
898
+ const step = delta < 0 && this.#state === "playing" && into > 1 ? delta + 1 : delta;
899
+ this.seek(lines[Math.min(Math.max(k + step, 0), lines.length - 1)].row);
900
+ }
901
+ /** Move by single notes (rests and holds included — each is a column). */
902
+ stepNote(delta) {
903
+ const events = this.#plan()?.schedule.events ?? [];
904
+ let i = this.#cursor === null ? -1 : eventAt(events, this.#cursor);
905
+ for (let left = Math.abs(delta); left > 0; left--) {
906
+ do
907
+ i += Math.sign(delta);
908
+ while (events[i]?.glide === true);
909
+ }
910
+ const ev = events[Math.min(Math.max(i, 0), events.length - 1)];
911
+ if (ev !== void 0) this.seek(ev.path);
912
+ }
747
913
  /**
748
914
  * Stop and rewind to the start. When sounding, the accompaniment (tabla /
749
915
  * metronome / tanpura, which otherwise loop) is gently faded out over
@@ -751,6 +917,13 @@ var SwaralipiPlayer = class {
751
917
  * playing or cut abruptly; otherwise it halts immediately.
752
918
  */
753
919
  stop() {
920
+ this.#disarmLimit();
921
+ this.#listened = 0;
922
+ this.#startAt = null;
923
+ if (this.#turn) {
924
+ this.#turn = false;
925
+ this.#emitLoop();
926
+ }
754
927
  if (this.#tone === void 0 || this.#state === "idle") {
755
928
  this.#halt();
756
929
  return;
@@ -771,11 +944,15 @@ var SwaralipiPlayer = class {
771
944
  * looping accompaniment, restore the master gain for the next play. */
772
945
  #halt() {
773
946
  this.#cancelFadeTimer();
947
+ this.#disarmLimit();
948
+ this.#timeline = void 0;
949
+ this.#fence = 0;
774
950
  if (this.#tone !== void 0) {
775
951
  const transport = this.#tone.getTransport();
776
952
  transport.stop();
777
953
  transport.cancel(0);
778
954
  transport.position = 0;
955
+ transport.loop = false;
779
956
  }
780
957
  this.#disposeTabla();
781
958
  this.#restoreMasterGain();
@@ -806,6 +983,27 @@ var SwaralipiPlayer = class {
806
983
  if (loop.buffer?.loaded) loop.loopEnd = quantizeLoopEnd(loop.buffer.duration, fileSpb);
807
984
  }
808
985
  }
986
+ /** Count down whatever listening time the preview cap has left. On the
987
+ * audio clock, which keeps running while the page is hidden. */
988
+ #armLimit() {
989
+ const left = this.#opts.previewLimitSec - this.#listened;
990
+ const context = this.#tone?.getContext();
991
+ if (!Number.isFinite(left) || context === void 0) return;
992
+ this.#listenedSince = context.now();
993
+ this.#limitId = context.setTimeout(() => {
994
+ this.#limitId = void 0;
995
+ if (this.#state !== "playing") return;
996
+ this.stop();
997
+ this.#opts.onLimit?.();
998
+ }, Math.max(left, 0));
999
+ }
1000
+ #disarmLimit() {
1001
+ const context = this.#tone?.getContext();
1002
+ if (this.#limitId === void 0 || context === void 0) return;
1003
+ context.clearTimeout(this.#limitId);
1004
+ this.#limitId = void 0;
1005
+ this.#listened += context.now() - this.#listenedSince;
1006
+ }
809
1007
  #restoreMasterGain() {
810
1008
  if (this.#master !== void 0 && this.#tone !== void 0) {
811
1009
  this.#master.gain.cancelScheduledValues(this.#tone.now());
@@ -845,18 +1043,81 @@ var SwaralipiPlayer = class {
845
1043
  this.#taalAuto = id === null;
846
1044
  this.#reapply();
847
1045
  }
848
- /** Wire highlight output to a `<swaralipi-sheet>` (or any highlight target).
849
- * Pass `{ autoScroll }` to keep the active note in view while playing. */
1046
+ /**
1047
+ * Wire highlight output to a `<swaralipi-sheet>` (or any highlight target).
1048
+ * Pass `{ autoScroll }` to keep the active note in view while playing.
1049
+ *
1050
+ * A sheet element also becomes the practice surface: a tap on a line plays
1051
+ * from the note under it (Shift stretches the repeat to that line), and
1052
+ * while it has focus — only then, so no key is taken from the page — Space
1053
+ * plays/pauses, ↑/↓ move by line, ←/→ by note, Home/End jump to the first /
1054
+ * last line, R repeats the line, T turns sing-back on/off, Esc stops the
1055
+ * repeat and Shift+↑/↓ stretch it. To gate a tap-to-play (a play quota, say),
1056
+ * `preventDefault()` its `swaralipi-seek` event in a capturing listener.
1057
+ */
850
1058
  bindSheet(target, opts = {}) {
851
1059
  const block = opts.autoScroll === true ? "center" : opts.autoScroll || void 0;
852
- this.#opts.onHighlight = (path) => {
1060
+ this.#opts.onHighlight = (path, yourTurn) => {
853
1061
  target.clearHighlights();
854
1062
  if (path === null) return;
855
- const el = target.highlight(path);
856
- if (block !== void 0 && el !== null) {
857
- el.scrollIntoView({ block, inline: "nearest", behavior: "smooth" });
1063
+ const el2 = target.highlight(path, yourTurn);
1064
+ if (block !== void 0 && el2 !== null) {
1065
+ el2.scrollIntoView({ block, inline: "nearest", behavior: "smooth" });
858
1066
  }
859
1067
  };
1068
+ this.#sheet = target;
1069
+ target.markLoop?.(this.#loop, this.#turn);
1070
+ this.#unbind?.abort();
1071
+ if (!(target instanceof EventTarget)) return;
1072
+ const el = target;
1073
+ const { signal } = this.#unbind = new AbortController();
1074
+ el.setAttribute("seekable", "");
1075
+ signal.addEventListener("abort", () => el.removeAttribute("seekable"));
1076
+ el.addEventListener("swaralipi-seek", (e) => this.#onTap(e), { signal });
1077
+ el.addEventListener("keydown", (e) => this.#onKey(e), { signal });
1078
+ }
1079
+ #onTap(e) {
1080
+ if (e.defaultPrevented) return;
1081
+ if (e.detail.extend) this.#extendLoop(e.detail.path);
1082
+ else void this.play(e.detail.path);
1083
+ }
1084
+ #onKey(e) {
1085
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1086
+ const shift = e.shiftKey;
1087
+ const act = {
1088
+ Space: () => this.#state === "playing" ? this.pause() : void this.play(),
1089
+ ArrowUp: () => shift ? this.#extendBy(-1) : this.stepLine(-1),
1090
+ ArrowDown: () => shift ? this.#extendBy(1) : this.stepLine(1),
1091
+ ArrowLeft: () => this.stepNote(-1),
1092
+ ArrowRight: () => this.stepNote(1),
1093
+ Home: () => this.stepLine(-Infinity),
1094
+ End: () => this.stepLine(Infinity),
1095
+ KeyR: () => this.setLoop(this.#loop === null ? void 0 : null),
1096
+ KeyT: () => this.setEcho(!this.#echo),
1097
+ Escape: this.#loop === null ? void 0 : () => this.setLoop(null)
1098
+ };
1099
+ const run = act[e.code];
1100
+ if (run === void 0) return;
1101
+ e.preventDefault();
1102
+ run();
1103
+ }
1104
+ /** Grow the repeat (or start one from the current line) to take in `path`'s line. */
1105
+ #extendLoop(path) {
1106
+ const lines = this.#lines();
1107
+ if (lines.length === 0) return;
1108
+ const k = lineOf(lines, path);
1109
+ const here = this.#cursor === null ? k : lineOf(lines, this.#cursor);
1110
+ const [a, b] = this.#loop === null ? [here, here] : this.#loopSpan(lines);
1111
+ this.setLoop(lines[Math.min(a, k)].row, lines[Math.max(b, k)].row);
1112
+ }
1113
+ /** Shift+↑/↓: one more line above / below the repeat. */
1114
+ #extendBy(delta) {
1115
+ const lines = this.#lines();
1116
+ if (lines.length === 0) return;
1117
+ const here = this.#cursor === null ? 0 : lineOf(lines, this.#cursor);
1118
+ const [a, b] = this.#loop === null ? [here, here] : this.#loopSpan(lines);
1119
+ const k = Math.min(Math.max((delta < 0 ? a : b) + delta, 0), lines.length - 1);
1120
+ this.#extendLoop(lines[k].row);
860
1121
  }
861
1122
  /**
862
1123
  * Check whether an instrument's samples are reachable (its CDN is up / the
@@ -885,6 +1146,8 @@ var SwaralipiPlayer = class {
885
1146
  /** Release all audio resources. The instance is unusable afterward. */
886
1147
  dispose() {
887
1148
  this.#halt();
1149
+ this.#unbind?.abort();
1150
+ this.#tone?.getTransport().off("loop", this.#onPass);
888
1151
  for (const { sampler } of this.#samplers.values()) sampler.dispose();
889
1152
  this.#samplers.clear();
890
1153
  for (const { buffers } of this.#glide.values()) buffers.dispose();
@@ -905,9 +1168,110 @@ var SwaralipiPlayer = class {
905
1168
  this.#state = state;
906
1169
  this.#opts.onState?.(state);
907
1170
  }
908
- #emitHighlight(path) {
909
- this.#opts.onHighlight?.(path);
910
- }
1171
+ #emitHighlight(path, yourTurn = false) {
1172
+ this.#opts.onHighlight?.(path, yourTurn);
1173
+ }
1174
+ #emitLoop() {
1175
+ this.#opts.onLoop?.({ loop: this.loop, echo: this.#echo, yourTurn: this.#turn });
1176
+ this.#sheet?.markLoop?.(this.#loop, this.#turn);
1177
+ }
1178
+ /** A new document starts with nothing repeated, from the top. */
1179
+ #resetPractice() {
1180
+ this.#cursor = this.#startAt = this.#loop = null;
1181
+ this.#echo = this.#turn = false;
1182
+ this.#listened = 0;
1183
+ this.#emitLoop();
1184
+ }
1185
+ /** The timeline to navigate: the arranged one while playing, else built
1186
+ * fresh — it is pure and cheap, and a stopped player may have a new tonic
1187
+ * or tempo since. */
1188
+ #plan() {
1189
+ if (this.#timeline !== void 0) return this.#timeline;
1190
+ if (this.#doc === void 0) return void 0;
1191
+ const schedule = buildSchedule(this.#doc, this.#scheduleOpts());
1192
+ return { schedule, lines: scheduleLines(schedule) };
1193
+ }
1194
+ #lines() {
1195
+ return this.#plan()?.lines ?? [];
1196
+ }
1197
+ /** The repeat as line indices (`[0, -1]`, i.e. empty, when off). */
1198
+ #loopSpan(lines) {
1199
+ return this.#loop === null ? [0, -1] : [lineOf(lines, this.#loop.from), lineOf(lines, this.#loop.to)];
1200
+ }
1201
+ /** The repeat's lines on the arranged timeline, cached for the per-note
1202
+ * checks (#hears), which run on every note. */
1203
+ #loopLines = [0, -1];
1204
+ /** Whether line `k` may sound: any line, unless a repeat holds others. */
1205
+ #hears(k) {
1206
+ return this.#loop === null || k >= this.#loopLines[0] && k <= this.#loopLines[1];
1207
+ }
1208
+ /** Transport seconds of event `i`, pulled back by its কণ so the grace is
1209
+ * heard too. */
1210
+ #eventSec(i) {
1211
+ const { schedule, spb, shift } = this.#timeline;
1212
+ const ev = schedule.events[i];
1213
+ return Math.max((ev.beat - GRACE_STEP * ev.kan.length) * spb + shift, 0);
1214
+ }
1215
+ /** The repeat in transport seconds: from its first note's own start (its
1216
+ * কণ included) for exactly the lines' length, so every pass keeps the
1217
+ * tabla's phase. */
1218
+ #loopSec() {
1219
+ const { lines, spb } = this.#timeline;
1220
+ const [a, b] = this.#loopLines;
1221
+ const start = this.#eventSec(lines[a].first);
1222
+ return [start, start + (lines[b].endBeat - lines[a].beat) * spb];
1223
+ }
1224
+ /** Put the repeat on the transport, or take it off. */
1225
+ #applyLoop() {
1226
+ const transport = this.#tone?.getTransport();
1227
+ if (transport === void 0 || this.#timeline === void 0) return;
1228
+ this.#loopLines = this.#loopSpan(this.#timeline.lines);
1229
+ if (this.#loop === null) {
1230
+ transport.loop = false;
1231
+ return;
1232
+ }
1233
+ transport.setLoopPoints(...this.#loopSec());
1234
+ transport.loop = true;
1235
+ }
1236
+ /** Where a fresh play() starts: a seek made while stopped, else the repeat,
1237
+ * else the top. */
1238
+ #startSec() {
1239
+ const events = this.#timeline?.schedule.events ?? [];
1240
+ if (this.#startAt !== null) return this.#eventSec(Math.max(eventAt(events, this.#startAt), 0));
1241
+ return this.#loop === null ? 0 : this.#loopSec()[0];
1242
+ }
1243
+ /** Move the transport; notes still ringing from the old place are let go. */
1244
+ #jump(sec) {
1245
+ const Tone = this.#tone;
1246
+ const transport = Tone.getTransport();
1247
+ this.#fence = Tone.now();
1248
+ if (transport.loop) {
1249
+ transport.loopEnd = this.#loopSec()[1] + 3600;
1250
+ Tone.getContext().setTimeout(() => this.#applyLoop(), 0.15);
1251
+ }
1252
+ transport.seconds = sec - this.#preroll();
1253
+ this.#voice?.releaseAll();
1254
+ }
1255
+ /** Audio time before which a note callback belongs to the timeline a seek
1256
+ * just left (see #jump). */
1257
+ #fence = 0;
1258
+ /** START_PREROLL_SEC in whole transport ticks: an offset between ticks
1259
+ * would shift every later repeat wrap by the remainder — measured, a
1260
+ * 0.8 ms splice in the tabla at each pass. */
1261
+ #preroll() {
1262
+ const transport = this.#tone.getTransport();
1263
+ const perSec = transport.bpm.value / 60 * transport.PPQ;
1264
+ return Math.ceil(START_PREROLL_SEC * perSec) / perSec;
1265
+ }
1266
+ /** The melody voice of the arrangement, for #jump. */
1267
+ #voice;
1268
+ /** The repeat wrapped. With sing-back on, the turn passes between the
1269
+ * player and the learner; the sheet is told at the wrap itself. */
1270
+ #onPass = (time) => {
1271
+ if (this.#loop === null || !this.#echo) return;
1272
+ this.#turn = !this.#turn;
1273
+ this.#tone?.getDraw().schedule(() => this.#emitLoop(), time);
1274
+ };
911
1275
  /** The taal one accompaniment segment plays: taal mode's, an explicit
912
1276
  * setTaal override (pinned across the whole song, তালফেরতা included), else
913
1277
  * the segment's own. `null` = a মুক্তছন্দ passage. */
@@ -977,11 +1341,13 @@ var SwaralipiPlayer = class {
977
1341
  return this.#tone;
978
1342
  }
979
1343
  /** Hard stop → rebuild → restart, only while playing; otherwise just store.
980
- * Uses #halt (not the stop() fade) so a setting change restarts instantly. */
1344
+ * Uses #halt (not the stop() fade) so a setting change restarts instantly,
1345
+ * from the note it was on — slowing down a repeated line keeps repeating it. */
981
1346
  #reapply() {
982
1347
  if (this.#state !== "playing") return;
1348
+ const at = this.#cursor;
983
1349
  this.#halt();
984
- void this.play();
1350
+ void this.play(at ?? void 0);
985
1351
  }
986
1352
  /** The master bus everything routes through (created once, → destination). */
987
1353
  #ensureMaster(Tone) {
@@ -997,6 +1363,7 @@ var SwaralipiPlayer = class {
997
1363
  const transport = Tone.getTransport();
998
1364
  transport.cancel(0);
999
1365
  transport.position = 0;
1366
+ transport.off("loop", this.#onPass).on("loop", this.#onPass);
1000
1367
  this.#scheduleIds = [];
1001
1368
  this.#ensureMaster(Tone);
1002
1369
  this.#restoreMasterGain();
@@ -1004,24 +1371,34 @@ var SwaralipiPlayer = class {
1004
1371
  transport.bpm.value = bpm;
1005
1372
  const spb = 60 / bpm;
1006
1373
  const schedule = buildSchedule(this.#doc, this.#scheduleOpts());
1374
+ const lines = scheduleLines(schedule);
1007
1375
  const range = midiRange(schedule);
1008
1376
  const id = this.#effectiveInstrument();
1009
1377
  const def = this.#resolveDef(id) ?? INSTRUMENTS.harmonium;
1010
1378
  const melody = this.#buildMelody(Tone, id, base, range);
1379
+ this.#voice = melody;
1011
1380
  const glide = schedule.events.some((ev) => ev.glide === true) ? this.#buildGlide(Tone, id, base, range) : void 0;
1012
1381
  const shift = this.#opts.rhythm !== "none" ? schedule.pickupBeats * spb : 0;
1382
+ this.#timeline = { schedule, lines, spb, shift };
1013
1383
  this.#buildRhythm(Tone, transport, base, spb, schedule.tempo, shift);
1014
1384
  this.#buildTanpura(Tone, transport, base, spb, schedule.tempo, shift);
1015
1385
  await loadedOrWarn(Tone);
1016
1386
  this.#quantizeTablaLoops();
1017
1387
  const draw = Tone.getDraw();
1018
1388
  const mediaLead = Math.min(MEDIA_LEAD_SEC, spb);
1389
+ let line = 0;
1019
1390
  schedule.events.forEach((ev, i) => {
1391
+ while (lines[line + 1] !== void 0 && lines[line + 1].first <= i) line++;
1392
+ const k = line;
1393
+ const heard = (time) => time >= this.#fence && this.#hears(k);
1394
+ const sounds = (time) => heard(time) && !this.#turn;
1020
1395
  const at = ev.beat * spb + shift;
1021
1396
  if (ev.glide !== true) {
1022
1397
  for (const g of graceTouches(ev)) {
1023
1398
  const graceName = midiToSampleName(g.midi);
1024
- transport.scheduleOnce((time) => melody.triggerAttackRelease(graceName, spb * 0.4, time), Math.max(g.beat * spb + shift, 0));
1399
+ transport.schedule((time) => {
1400
+ if (sounds(time)) melody.triggerAttackRelease(graceName, spb * 0.4, time);
1401
+ }, Math.max(g.beat * spb + shift, 0));
1025
1402
  }
1026
1403
  }
1027
1404
  if (ev.midi !== null && ev.glide !== true) {
@@ -1035,7 +1412,8 @@ var SwaralipiPlayer = class {
1035
1412
  ...targets.map((t) => ({ midi: t.midi, at: (t.beat - ev.beat) * spb }))
1036
1413
  ];
1037
1414
  const end = (last.beat + last.durationBeats - ev.beat) * spb;
1038
- transport.scheduleOnce((time) => {
1415
+ transport.schedule((time) => {
1416
+ if (!sounds(time)) return;
1039
1417
  triggerGlide(
1040
1418
  Tone,
1041
1419
  glide.buffers,
@@ -1047,14 +1425,24 @@ var SwaralipiPlayer = class {
1047
1425
  );
1048
1426
  }, at);
1049
1427
  } else {
1050
- transport.scheduleOnce((time) => melody.triggerAttackRelease(name, dur, time), at);
1428
+ transport.schedule((time) => {
1429
+ if (sounds(time)) melody.triggerAttackRelease(name, dur, time);
1430
+ }, at);
1051
1431
  }
1052
1432
  }
1053
1433
  if (this.#opts.onEvent !== void 0) {
1054
- transport.scheduleOnce(() => this.#opts.onEvent?.(ev.path), Math.max(at - mediaLead, 0));
1434
+ transport.schedule((time) => {
1435
+ if (heard(time)) this.#opts.onEvent?.(ev.path);
1436
+ }, Math.max(at - mediaLead, 0));
1055
1437
  }
1056
- transport.scheduleOnce((time) => draw.schedule(() => this.#emitHighlight(ev.path), time), at);
1438
+ transport.schedule((time) => {
1439
+ if (!heard(time)) return;
1440
+ const turn = this.#turn;
1441
+ this.#cursor = ev.path;
1442
+ draw.schedule(() => this.#emitHighlight(ev.path, turn), time);
1443
+ }, at);
1057
1444
  });
1445
+ this.#applyLoop();
1058
1446
  const endAt = playDurationSec(schedule, spb, this.#opts.rhythm);
1059
1447
  transport.scheduleOnce((time) => {
1060
1448
  this.#opts.onEvent?.(null);
@@ -1146,13 +1534,15 @@ var SwaralipiPlayer = class {
1146
1534
  const to = i === segments.length - 1 ? Infinity : seg.endBeat * spb + shift;
1147
1535
  const beatSec = spb / seg.scale;
1148
1536
  const span = to === Infinity ? void 0 : to - from;
1537
+ const beatAt = (time) => Math.round((transport.getSecondsAtTime(time) - from) / beatSec);
1149
1538
  if (rhythm === "tabla" && taalId !== void 0 && SAMPLED_TAALS.has(taalId) && base !== void 0) {
1150
1539
  const bpm = 60 / beatSec;
1151
1540
  const fileBpm = nearestTablaBpm(bpm, tablaBpms(taalId));
1152
1541
  const rate = bpm / fileBpm;
1153
1542
  const semis = tablaShiftSemitones(this.#tonicSemitone());
1154
1543
  const url = tablaUrl(base, taalId, fileBpm, this.#variation(own));
1155
- const loop = (rate === 1 && semis === 0 ? new Tone.Player({ url, loop: true }) : new Tone.GrainPlayer({
1544
+ this.#grain ??= seekableGrain(Tone);
1545
+ const loop = (rate === 1 && semis === 0 ? new Tone.Player({ url, loop: true }) : new this.#grain({
1156
1546
  url,
1157
1547
  loop: true,
1158
1548
  playbackRate: rate,
@@ -1163,9 +1553,10 @@ var SwaralipiPlayer = class {
1163
1553
  loop.sync().start(from);
1164
1554
  if (span !== void 0) {
1165
1555
  this.#scheduleIds.push(
1166
- transport.scheduleOnce((t) => {
1556
+ transport.schedule((t) => {
1167
1557
  loop.volume.setValueAtTime(0, t);
1168
1558
  loop.volume.linearRampToValueAtTime(-40, t + LAY_TAIL_SEC);
1559
+ loop.volume.setValueAtTime(0, t + LAY_TAIL_SEC + 0.05);
1169
1560
  }, to)
1170
1561
  );
1171
1562
  loop.stop(to + LAY_TAIL_SEC);
@@ -1177,13 +1568,11 @@ var SwaralipiPlayer = class {
1177
1568
  this.#drums ??= createDrumKit(Tone).connect(master);
1178
1569
  const drums = this.#drums;
1179
1570
  const pattern = drumPattern(this.#taal ?? (taalId !== void 0 ? TAALS[taalId] : own) ?? { numBeats: 4, sam: 0 });
1180
- let beat2 = 0;
1181
1571
  this.#scheduleIds.push(
1182
1572
  transport.scheduleRepeat(
1183
1573
  (time) => {
1184
- const voice = pattern[beat2 % pattern.length];
1574
+ const voice = pattern[beatAt(time) % pattern.length];
1185
1575
  if (drums.has(voice)) drums.player(voice).start(time);
1186
- beat2 += 1;
1187
1576
  },
1188
1577
  beatSec,
1189
1578
  from,
@@ -1195,13 +1584,11 @@ var SwaralipiPlayer = class {
1195
1584
  this.#metronome ??= createMetronome(Tone, base).connect(master);
1196
1585
  const metro = this.#metronome;
1197
1586
  const cycle = (this.#taal ?? seg.taal)?.numBeats ?? 4;
1198
- let beat = 0;
1199
1587
  this.#scheduleIds.push(
1200
1588
  transport.scheduleRepeat(
1201
1589
  (time) => {
1202
- metro.volume.setValueAtTime(beat % cycle === 0 ? 0 : -8, time);
1590
+ metro.volume.setValueAtTime(beatAt(time) % cycle === 0 ? 0 : -8, time);
1203
1591
  metro.start(time);
1204
- beat += 1;
1205
1592
  },
1206
1593
  beatSec,
1207
1594
  from,
@@ -1227,15 +1614,14 @@ var SwaralipiPlayer = class {
1227
1614
  segments.forEach((seg, k) => {
1228
1615
  const from = k === 0 ? 0 : seg.beat * spb + shift;
1229
1616
  const to = k === segments.length - 1 ? Infinity : seg.endBeat * spb + shift;
1230
- let i = 0;
1617
+ const every = spb * 2 / seg.scale;
1231
1618
  this.#scheduleIds.push(
1232
1619
  transport.scheduleRepeat(
1233
1620
  (time) => {
1234
- const stem = pattern[i % pattern.length];
1621
+ const stem = pattern[Math.round((transport.getSecondsAtTime(time) - from) / every) % pattern.length];
1235
1622
  if (players.has(stem)) players.player(stem).start(time);
1236
- i += 1;
1237
1623
  },
1238
- spb * 2 / seg.scale,
1624
+ every,
1239
1625
  from,
1240
1626
  to === Infinity ? void 0 : to - from
1241
1627
  )
@@ -1700,6 +2086,7 @@ exports.drumPattern = drumPattern;
1700
2086
  exports.encodeMidi = encodeMidi;
1701
2087
  exports.encodeMp3 = encodeMp3;
1702
2088
  exports.encodeWav = encodeWav;
2089
+ exports.eventAt = eventAt;
1703
2090
  exports.exportAudio = exportAudio;
1704
2091
  exports.exportMidi = exportMidi;
1705
2092
  exports.glideTargets = glideTargets;
@@ -1720,6 +2107,7 @@ exports.rowLay = rowLay;
1720
2107
  exports.sCurve = sCurve;
1721
2108
  exports.sampleFileName = sampleFileName;
1722
2109
  exports.santoorUrls = santoorUrls;
2110
+ exports.scheduleLines = scheduleLines;
1723
2111
  exports.subsetUrls = subsetUrls;
1724
2112
  exports.svaraSemitone = svaraSemitone;
1725
2113
  exports.svaraToMidi = svaraToMidi;