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