@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.
- package/README.md +17 -0
- package/dist/{batch-NEM7JXHV.js → batch-3PHPNX2P.js} +4 -4
- package/dist/{batch-NEM7JXHV.js.map → batch-3PHPNX2P.js.map} +1 -1
- package/dist/{chunk-DOEFSS66.js → chunk-FGMULPL2.js} +3 -3
- package/dist/{chunk-DOEFSS66.js.map → chunk-FGMULPL2.js.map} +1 -1
- package/dist/{chunk-Z6A3CP5V.js → chunk-I5GJWSD2.js} +3 -3
- package/dist/{chunk-Z6A3CP5V.js.map → chunk-I5GJWSD2.js.map} +1 -1
- package/dist/{chunk-SEN266BY.js → chunk-JEJYMKKZ.js} +26 -3
- package/dist/chunk-JEJYMKKZ.js.map +1 -0
- package/dist/cli.cjs +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/convert/index.cjs +1 -1
- package/dist/convert/index.cjs.map +1 -1
- package/dist/convert/index.js +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/player/index.cjs +436 -50
- package/dist/player/index.cjs.map +1 -1
- package/dist/player/index.d.cts +76 -11
- package/dist/player/index.d.ts +76 -11
- package/dist/player/index.js +412 -51
- package/dist/player/index.js.map +1 -1
- package/dist/{schedule-BaovCtmf.d.ts → schedule-BUopqmWm.d.ts} +27 -1
- package/dist/{schedule-CU-VECDH.d.cts → schedule-IBu8lr-3.d.cts} +27 -1
- package/dist/spec/index.cjs +1 -1
- package/dist/spec/index.cjs.map +1 -1
- package/dist/spec/index.d.cts +1 -1
- package/dist/spec/index.d.ts +1 -1
- package/dist/spec/index.js +1 -1
- package/dist/viewer/index.cjs +149 -7
- package/dist/viewer/index.cjs.map +1 -1
- package/dist/viewer/index.d.cts +21 -4
- package/dist/viewer/index.d.ts +21 -4
- package/dist/viewer/index.js +149 -8
- package/dist/viewer/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-SEN266BY.js.map +0 -1
package/dist/player/index.js
CHANGED
|
@@ -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,
|
|
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-
|
|
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
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
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()
|
|
174
|
-
|
|
234
|
+
const transport = Tone.getTransport();
|
|
235
|
+
if (transport.state !== "stopped") transport.stop();
|
|
175
236
|
await this.#arrange(Tone);
|
|
176
|
-
|
|
237
|
+
transport.start(void 0, this.#startSec() - this.#preroll());
|
|
238
|
+
this.#startAt = null;
|
|
177
239
|
this.#setState("playing");
|
|
178
|
-
|
|
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
|
-
/**
|
|
298
|
-
*
|
|
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
|
|
305
|
-
if (block !== void 0 &&
|
|
306
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
858
|
+
transport.schedule((time) => {
|
|
859
|
+
if (heard(time)) this.#opts.onEvent?.(ev.path);
|
|
860
|
+
}, Math.max(at - mediaLead, 0));
|
|
504
861
|
}
|
|
505
|
-
transport.
|
|
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
|
-
|
|
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.
|
|
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[
|
|
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(
|
|
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
|
-
|
|
1041
|
+
const every = spb * 2 / seg.scale;
|
|
680
1042
|
this.#scheduleIds.push(
|
|
681
1043
|
transport.scheduleRepeat(
|
|
682
1044
|
(time) => {
|
|
683
|
-
const stem = pattern[
|
|
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
|
-
|
|
1048
|
+
every,
|
|
688
1049
|
from,
|
|
689
1050
|
to === Infinity ? void 0 : to - from
|
|
690
1051
|
)
|