@ugfoundation/swaralipi-js 0.13.1 → 0.14.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 (39) hide show
  1. package/README.md +25 -0
  2. package/dist/{batch-XVQRHLTN.js → batch-J72GGBG2.js} +4 -4
  3. package/dist/{batch-XVQRHLTN.js.map → batch-J72GGBG2.js.map} +1 -1
  4. package/dist/{chunk-MORX7G6E.js → chunk-4WDAVRET.js} +3 -3
  5. package/dist/{chunk-MORX7G6E.js.map → chunk-4WDAVRET.js.map} +1 -1
  6. package/dist/{chunk-L6YZ3GC6.js → chunk-GIOGYJMM.js} +180 -19
  7. package/dist/chunk-GIOGYJMM.js.map +1 -0
  8. package/dist/{chunk-GKS4WZZ6.js → chunk-KUZSW4FN.js} +3 -3
  9. package/dist/{chunk-GKS4WZZ6.js.map → chunk-KUZSW4FN.js.map} +1 -1
  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/{schedule-V-fgepvP.d.cts → instruments-DTIuTlda.d.cts} +174 -140
  20. package/dist/{schedule-ClyON98C.d.ts → instruments-NcUb0ozc.d.ts} +174 -140
  21. package/dist/player/index.cjs +330 -84
  22. package/dist/player/index.cjs.map +1 -1
  23. package/dist/player/index.d.cts +40 -16
  24. package/dist/player/index.d.ts +40 -16
  25. package/dist/player/index.js +150 -69
  26. package/dist/player/index.js.map +1 -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 +189 -31
  33. package/dist/viewer/index.cjs.map +1 -1
  34. package/dist/viewer/index.d.cts +62 -2
  35. package/dist/viewer/index.d.ts +62 -2
  36. package/dist/viewer/index.js +123 -4
  37. package/dist/viewer/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/dist/chunk-L6YZ3GC6.js.map +0 -1
@@ -145,6 +145,170 @@ function rowLay(row) {
145
145
  return row.lay ?? printedLay(row.notes);
146
146
  }
147
147
 
148
+ // src/player/lyrics.ts
149
+ function lyricCues(events, map, text) {
150
+ const tokens = [...text.matchAll(/\S+/g)].map((m) => ({
151
+ text: m[0],
152
+ start: m.index,
153
+ end: m.index + m[0].length,
154
+ line: text.slice(0, m.index).split("\n").length - 1
155
+ }));
156
+ const cues = [];
157
+ for (const ev of events) {
158
+ const span = map.get(ev.path);
159
+ const index = span ? tokens.findIndex((t) => t.start <= span[0] && span[0] < t.end) : -1;
160
+ if (index < 0) continue;
161
+ const token = tokens[index];
162
+ let cue = cues.at(-1);
163
+ if (!cue || cue.line !== token.line || index < cue.last) {
164
+ cue = { beat: ev.beat, line: token.line, last: -1, words: [] };
165
+ cues.push(cue);
166
+ }
167
+ if (index !== cue.last) {
168
+ cue.words.push({ beat: ev.beat, text: token.text });
169
+ cue.last = index;
170
+ }
171
+ }
172
+ return cues.map(({ beat, words }) => ({ beat, words }));
173
+ }
174
+ var MATCH = 2;
175
+ var GAP = 1;
176
+ var JUMP = 4;
177
+ var RUN = 3;
178
+ var STRAY = 8;
179
+ var EPS = 1e-6;
180
+ var [DIAG, UP, LEFT, JUMPED] = [1, 2, 3, 4];
181
+ var letters = (s) => s.normalize("NFD").replace(/ৎ/g, "\u09A4").replace(/[^\p{L}\p{M}]|[়্]/gu, "").toLowerCase();
182
+ function alignLyrics(doc, text) {
183
+ const sung = [];
184
+ doc.parts.forEach(
185
+ (part, p) => part.rows.forEach(
186
+ (row, r) => row.notes.forEach((note, i) => {
187
+ for (const letter of letters(note.lyric ?? "")) sung.push({ letter, path: `${p}.${r}.${i}` });
188
+ })
189
+ )
190
+ );
191
+ const said = [];
192
+ const starts = [];
193
+ let at = 0;
194
+ let gap = true;
195
+ for (const ch of text) {
196
+ const ls = letters(ch);
197
+ if (ls === "") {
198
+ gap ||= /[\s\p{P}]/u.test(ch);
199
+ if (/[\p{M}\p{Cf}]/u.test(ch) && said.at(-1)?.to === at) said.at(-1).to += ch.length;
200
+ } else {
201
+ if (gap) starts.push(said.length);
202
+ gap = false;
203
+ }
204
+ for (const letter of ls) said.push({ letter, from: at, to: at + ch.length });
205
+ at += ch.length;
206
+ }
207
+ const n = sung.length;
208
+ const m = said.length;
209
+ const w = m + 1;
210
+ const from = new Uint8Array((n + 1) * w);
211
+ const best = new Int32Array(n + 1);
212
+ let prev = new Int32Array(w).fill(-1073741824);
213
+ prev[0] = 0;
214
+ let cur = new Int32Array(w);
215
+ const settle = (i, row) => {
216
+ for (let j2 = 1; j2 < w; j2++) if (row[j2] > row[best[i]]) best[i] = j2;
217
+ for (const s of starts) {
218
+ if (row[best[i]] - JUMP > row[s]) {
219
+ row[s] = row[best[i]] - JUMP;
220
+ from[i * w + s] = JUMPED;
221
+ }
222
+ }
223
+ for (let j2 = 1; j2 < w; j2++) {
224
+ if (row[j2 - 1] - GAP > row[j2]) {
225
+ row[j2] = row[j2 - 1] - GAP;
226
+ from[i * w + j2] = LEFT;
227
+ }
228
+ }
229
+ };
230
+ settle(0, prev);
231
+ for (let i = 1; i <= n; i++) {
232
+ cur[0] = prev[0] - GAP;
233
+ from[i * w] = UP;
234
+ for (let j2 = 1; j2 < w; j2++) {
235
+ const diag = prev[j2 - 1] + (sung[i - 1].letter === said[j2 - 1].letter ? MATCH : -GAP);
236
+ const up = prev[j2] - GAP;
237
+ cur[j2] = Math.max(diag, up);
238
+ from[i * w + j2] = diag >= up ? DIAG : UP;
239
+ }
240
+ best[i] = 0;
241
+ settle(i, cur);
242
+ [prev, cur] = [cur, prev];
243
+ }
244
+ const pairs = [];
245
+ let j = 0;
246
+ for (let k = 1; k < w; k++) if (prev[k] > prev[j]) j = k;
247
+ for (let i = n; i > 0; ) {
248
+ const step = from[i * w + j];
249
+ if (step === JUMPED) j = best[i];
250
+ else if (step === LEFT) j--;
251
+ else if (step === UP) i--;
252
+ else {
253
+ if (sung[i - 1].letter === said[j - 1].letter) pairs.push([i - 1, j - 1]);
254
+ i--;
255
+ j--;
256
+ }
257
+ }
258
+ pairs.reverse();
259
+ const wordStart = new Set(starts);
260
+ const follows = (k) => pairs[k][0] === pairs[k - 1][0] + 1 && (pairs[k][1] === pairs[k - 1][1] + 1 || wordStart.has(pairs[k][1]));
261
+ const out = /* @__PURE__ */ new Map();
262
+ const last = /* @__PURE__ */ new Map();
263
+ for (let a = 0, b = 1; a < pairs.length; a = b++) {
264
+ while (b < pairs.length && follows(b)) b++;
265
+ if (b - a < RUN) continue;
266
+ for (const [s, t] of pairs.slice(a, b)) {
267
+ const { path } = sung[s];
268
+ const { from: lo, to: hi } = said[t];
269
+ const span = out.get(path);
270
+ if (span === void 0) out.set(path, [lo, hi]);
271
+ else if (t === last.get(path) + 1) span[1] = hi;
272
+ else continue;
273
+ last.set(path, t);
274
+ }
275
+ }
276
+ return out;
277
+ }
278
+ var mod = (a, n) => (a % n + n) % n;
279
+ var sings = (v) => v.note.svara !== void 0 || v.note.sustain === true;
280
+ function excerptVisits(order, at, { lyrics, ranges }, cycle) {
281
+ const pos = order.map((v) => lyrics.get(`${v.p}.${v.r}.${v.i}`)?.[0]);
282
+ for (let a = 0; a < order.length; a++) {
283
+ if (pos[a] !== void 0 || letters(order[a].note.lyric ?? "") === "") continue;
284
+ let b = a;
285
+ let stray = 0;
286
+ while (b < order.length && pos[b] === void 0) stray += letters(order[b++].note.lyric ?? "").length;
287
+ if (stray >= STRAY) pos.fill(-1, a, b);
288
+ a = b;
289
+ }
290
+ const visits = [];
291
+ let first;
292
+ let end = 0;
293
+ for (const [lo, hi] of ranges) {
294
+ const inside = (k) => pos[k] === void 0 ? void 0 : pos[k] >= lo && pos[k] < hi;
295
+ const a = order.findIndex((_, k) => inside(k) === true);
296
+ if (a < 0) continue;
297
+ let b = a + 1;
298
+ while (b < order.length && inside(b) !== false) b++;
299
+ while (b - 1 > a && !sings(order[b - 1])) b--;
300
+ const n = cycle(order[a]);
301
+ if (first === void 0) first = a;
302
+ else if (n !== void 0) {
303
+ const gap = mod(at[a] - at[end], n);
304
+ if (gap > EPS) visits.push({ ...order[end - 1], note: { rest: true, durationMatra: gap } });
305
+ }
306
+ visits.push(...order.slice(a, b));
307
+ end = b;
308
+ }
309
+ return { visits, matra: first === void 0 ? 0 : at[first] };
310
+ }
311
+
148
312
  // src/convert/codec.ts
149
313
  var LETTERS = {
150
314
  s: { letter: "s" },
@@ -310,7 +474,7 @@ function decodeCell(text) {
310
474
  var UNDECODED_CELL = /[srgmpqnvtduka]/i;
311
475
  var isSilent = (n) => n.svara === void 0 && n.rest !== true && n.sustain !== true && !(n.raw !== void 0 && UNDECODED_CELL.test(n.raw));
312
476
  var STOP_MARK = /^\|+$/;
313
- var EPS = 1e-6;
477
+ var EPS2 = 1e-6;
314
478
  var edges = (n, kind, edge) => n.brackets?.filter((b) => b.kind === kind && b.edge === edge).length ?? 0;
315
479
  var sounds = (n) => n.svara !== void 0 || n.rest === true || n.sustain === true;
316
480
  var matra = (notes) => notes.reduce((sum, n) => sum + (n.durationMatra ?? 1), 0);
@@ -339,10 +503,10 @@ function playOrder(doc) {
339
503
  owed = 0;
340
504
  continue;
341
505
  }
342
- if (owed > EPS && t.timed) owed -= matra([t.note]);
506
+ if (owed > EPS2 && t.timed) owed -= matra([t.note]);
343
507
  else {
344
508
  const sub = useChanged || again ? changed.get(k) : void 0;
345
- const fits = sub !== void 0 && matra(sub) >= matra([t.note]) - EPS;
509
+ const fits = sub !== void 0 && matra(sub) >= matra([t.note]) - EPS2;
346
510
  for (const note of fits ? sub : [t.note]) out.push({ p: t.p, r: t.r, i: t.i, note });
347
511
  if (t.timed) last = k;
348
512
  owed = fits ? matra(sub) - matra([t.note]) : 0;
@@ -535,8 +699,6 @@ function buildSchedule(doc, opts = {}) {
535
699
  const events = [];
536
700
  let cursor = 0;
537
701
  let lastPitched = -1;
538
- let samMatra;
539
- let matra2 = 0;
540
702
  const speeds = resolveLaySpeeds(doc.meta.laySpeeds, opts.laySpeeds);
541
703
  const withLay = opts.lay !== false;
542
704
  const tempo = [{ beat: 0, endBeat: 0, scale: 1, lay: "drut", taal: doc.meta.taal }];
@@ -555,7 +717,15 @@ function buildSchedule(doc, opts = {}) {
555
717
  rows.set(`${p}.${r}`, { scale, lay: layNow, taal, glides: meendContinuations(row.notes) });
556
718
  })
557
719
  );
558
- const visits = opts.repeats === true ? playOrder(doc) : doc.parts.flatMap((part, p) => part.rows.flatMap((row, r) => row.notes.map((note, i) => ({ p, r, i, note }))));
720
+ const order = opts.repeats === true ? playOrder(doc) : doc.parts.flatMap((part, p) => part.rows.flatMap((row, r) => row.notes.map((note, i) => ({ p, r, i, note }))));
721
+ const at = [0];
722
+ let samMatra;
723
+ for (const { note } of order) {
724
+ const m = at[at.length - 1];
725
+ if (samMatra === void 0 && note.above?.some((a) => a.raw !== void 0 && SAM_TICK.test(a.raw))) samMatra = m;
726
+ at.push(note.bar === void 0 && !isSilent(note) ? m + (note.durationMatra ?? 1) : m);
727
+ }
728
+ const { visits, matra: matra2 } = opts.excerpt ? excerptVisits(order, at, opts.excerpt, (v) => rows.get(`${v.p}.${v.r}`).taal?.numBeats) : { visits: order, matra: 0 };
559
729
  let rowNow = "";
560
730
  for (const { p, r, i, note } of visits) {
561
731
  const row = rows.get(`${p}.${r}`);
@@ -571,14 +741,6 @@ function buildSchedule(doc, opts = {}) {
571
741
  }
572
742
  }
573
743
  }
574
- if (samMatra === void 0 && note.above !== void 0) {
575
- for (const a of note.above) {
576
- if (a.raw !== void 0 && SAM_TICK.test(a.raw)) {
577
- samMatra = matra2;
578
- break;
579
- }
580
- }
581
- }
582
744
  if (note.bar !== void 0 || isSilent(note)) continue;
583
745
  const path = `${p}.${r}.${i}`;
584
746
  const written = note.durationMatra ?? 1;
@@ -604,10 +766,9 @@ function buildSchedule(doc, opts = {}) {
604
766
  events.push({ beat: cursor, durationBeats: dur, midi: null, kan, resh, path, kind: "rest" });
605
767
  }
606
768
  cursor += dur;
607
- matra2 += written;
608
769
  }
609
770
  const numBeats = tempo[0].taal?.numBeats;
610
- const pickupMatra = samMatra !== void 0 && numBeats !== void 0 && numBeats > 0 ? (numBeats - samMatra % numBeats) % numBeats : 0;
771
+ const pickupMatra = numBeats !== void 0 && numBeats > 0 ? ((matra2 - (samMatra ?? 0)) % numBeats + numBeats) % numBeats : 0;
611
772
  tempo[tempo.length - 1].endBeat = cursor;
612
773
  return { events, totalBeats: cursor, pickupBeats: pickupMatra / tempo[0].scale, tempo };
613
774
  }
@@ -946,6 +1107,8 @@ var SwaralipiPlayer = class {
946
1107
  /** Per-song setBpm tweak: beats meta.bpm but is cleared by the next load(). */
947
1108
  #bpmTweak;
948
1109
  #doc;
1110
+ /** Play only these stretches of the song's text (see `load`). */
1111
+ #excerpt;
949
1112
  /** Taal mode (`loadTaal`): loops one avartan of this taal's theka with no
950
1113
  * melody. Mutually exclusive with `#doc`. */
951
1114
  #taal;
@@ -988,6 +1151,8 @@ var SwaralipiPlayer = class {
988
1151
  /** Listeners on the bound sheet, removed on rebind. */
989
1152
  #unbind;
990
1153
  #sheet;
1154
+ /** The bound sheet's highlight — beside, never instead of, the consumer's `onHighlight`. */
1155
+ #paint;
991
1156
  /** Preview cap, counted in listening time so a seek or a loop can't dodge it. */
992
1157
  #listened = 0;
993
1158
  #listenedSince = 0;
@@ -1078,10 +1243,13 @@ var SwaralipiPlayer = class {
1078
1243
  /* ----------------------------------------------------------------- */
1079
1244
  /* Loading + transport control */
1080
1245
  /* ----------------------------------------------------------------- */
1081
- /** Set the document to play. Stops any current playback. */
1082
- load(doc) {
1246
+ /** Set the document to play. Stops any current playback. With `excerpt`,
1247
+ * only the notes singing those stretches of the song's text play, in order
1248
+ * (see `alignLyrics`); the length, seeks and repeats all keep to them. */
1249
+ load(doc, { excerpt } = {}) {
1083
1250
  this.#halt();
1084
1251
  this.#doc = doc;
1252
+ this.#excerpt = excerpt;
1085
1253
  this.#taal = void 0;
1086
1254
  this.#taalOverride = void 0;
1087
1255
  this.#bpmTweak = void 0;
@@ -1404,7 +1572,7 @@ var SwaralipiPlayer = class {
1404
1572
  */
1405
1573
  bindSheet(target, opts = {}) {
1406
1574
  const block = opts.autoScroll === true ? "center" : opts.autoScroll || void 0;
1407
- this.#opts.onHighlight = (path, yourTurn) => {
1575
+ this.#paint = (path, yourTurn) => {
1408
1576
  target.clearHighlights();
1409
1577
  if (path === null) return;
1410
1578
  const el2 = target.highlight(path, yourTurn);
@@ -1516,6 +1684,7 @@ var SwaralipiPlayer = class {
1516
1684
  this.#opts.onState?.(state);
1517
1685
  }
1518
1686
  #emitHighlight(path, yourTurn = false) {
1687
+ this.#paint?.(path, yourTurn);
1519
1688
  this.#opts.onHighlight?.(path, yourTurn);
1520
1689
  }
1521
1690
  #emitLoop() {
@@ -1673,7 +1842,8 @@ var SwaralipiPlayer = class {
1673
1842
  tonic: this.#opts.tonic,
1674
1843
  baseOctave: this.#opts.baseOctave,
1675
1844
  laySpeeds: this.#opts.laySpeeds,
1676
- repeats: this.#opts.repeats
1845
+ repeats: this.#opts.repeats,
1846
+ excerpt: this.#excerpt
1677
1847
  };
1678
1848
  }
1679
1849
  /** Effective tempo: snapped to the taal's available tabla tempos while a
@@ -1996,6 +2166,38 @@ function midiRange2(schedule) {
1996
2166
  }
1997
2167
  return lo <= hi ? { lo, hi } : void 0;
1998
2168
  }
2169
+ var AHEAD_SEC = 2;
2170
+ async function renderOffline(Tone, build, durationSec, channels, sampleRate) {
2171
+ const chunked = "suspend" in OfflineAudioContext.prototype && "positionX" in AudioListener.prototype;
2172
+ const context = chunked ? new Tone.OfflineContext(new OfflineAudioContext(channels, Math.ceil(durationSec * sampleRate), sampleRate)) : new Tone.OfflineContext(channels, durationSec, sampleRate);
2173
+ const raw = context.rawContext;
2174
+ const original = Tone.getContext();
2175
+ Tone.setContext(context);
2176
+ try {
2177
+ await build(context);
2178
+ } finally {
2179
+ Tone.setContext(original);
2180
+ }
2181
+ const clock = context;
2182
+ const run = (until) => {
2183
+ while (clock._currentTime <= Math.min(until, durationSec)) {
2184
+ clock.emit("tick");
2185
+ clock._currentTime += 128 / sampleRate;
2186
+ }
2187
+ };
2188
+ if (chunked) {
2189
+ run(2 * AHEAD_SEC);
2190
+ for (let t = AHEAD_SEC; t < durationSec; t += AHEAD_SEC) {
2191
+ void raw.suspend(t).then(() => {
2192
+ run(t + 2 * AHEAD_SEC);
2193
+ void raw.resume();
2194
+ });
2195
+ }
2196
+ } else {
2197
+ run(Infinity);
2198
+ }
2199
+ return raw.startRendering();
2200
+ }
1999
2201
  function pickDef(id, o) {
2000
2202
  const def = o.customInstruments?.[id] ?? INSTRUMENTS[id];
2001
2203
  if (def === void 0) return INSTRUMENTS.harmonium;
@@ -2024,7 +2226,8 @@ async function exportAudio(doc, options = {}) {
2024
2226
  tonic: options.tonic,
2025
2227
  baseOctave: options.baseOctave,
2026
2228
  laySpeeds: options.laySpeeds,
2027
- repeats: options.repeats ?? true
2229
+ repeats: options.repeats ?? true,
2230
+ excerpt: options.excerpt
2028
2231
  });
2029
2232
  const range = midiRange2(schedule);
2030
2233
  const taalId = resolveTaal(doc, options.taal);
@@ -2037,7 +2240,8 @@ async function exportAudio(doc, options = {}) {
2037
2240
  const def = pickDef(id, options);
2038
2241
  const shift = rhythm !== "none" ? schedule.pickupBeats * spb : 0;
2039
2242
  const durationSec = Math.max(schedule.totalBeats * spb + shift + (def.release ?? 1) + fadeOutSec + 0.5, 0.5);
2040
- const rendered = await Tone.Offline(
2243
+ const rendered = await renderOffline(
2244
+ Tone,
2041
2245
  async ({ transport }) => {
2042
2246
  transport.bpm.value = bpm;
2043
2247
  const master = new Tone.Gain(1).toDestination();
@@ -2184,11 +2388,16 @@ async function exportAudio(doc, options = {}) {
2184
2388
  for (let c = 0; c < rendered.numberOfChannels; c++) chans.push(rendered.getChannelData(c));
2185
2389
  const bytes = format === "mp3" ? await encodeMp3(chans, rendered.sampleRate, options.mp3Kbps) : encodeWav(chans, rendered.sampleRate);
2186
2390
  const mimeType = format === "mp3" ? "audio/mpeg" : "audio/wav";
2391
+ const cues = options.lyrics === void 0 ? void 0 : lyricCues(schedule.events, alignLyrics(doc, options.lyrics), options.lyrics).map((cue) => ({
2392
+ sec: cue.beat * spb + shift,
2393
+ words: cue.words.map((word) => ({ sec: word.beat * spb + shift, text: word.text }))
2394
+ }));
2187
2395
  return {
2188
2396
  blob: new Blob([bytes], { type: mimeType }),
2189
2397
  filename: `${safeName(doc.meta.title)}.${format}`,
2190
2398
  mimeType,
2191
- durationSec: rendered.duration
2399
+ durationSec: rendered.duration,
2400
+ ...cues === void 0 ? {} : { cues }
2192
2401
  };
2193
2402
  }
2194
2403
  function toInt16(f) {
@@ -2245,8 +2454,8 @@ function concatBytes(chunks) {
2245
2454
  }
2246
2455
  async function loadLame() {
2247
2456
  try {
2248
- const mod = await import('@breezystack/lamejs');
2249
- const Mp3Encoder = mod.Mp3Encoder ?? mod.default?.Mp3Encoder;
2457
+ const mod2 = await import('@breezystack/lamejs');
2458
+ const Mp3Encoder = mod2.Mp3Encoder ?? mod2.default?.Mp3Encoder;
2250
2459
  if (!Mp3Encoder) throw new Error("no Mp3Encoder export");
2251
2460
  return Mp3Encoder;
2252
2461
  } catch {
@@ -2255,22 +2464,32 @@ async function loadLame() {
2255
2464
  );
2256
2465
  }
2257
2466
  }
2258
- async function encodeMp3(channels, sampleRate, kbps = 192) {
2467
+ async function mp3Stream(sampleRate = 44100, kbps = 192, channels = 1) {
2259
2468
  const Mp3Encoder = await loadLame();
2260
- const numCh = Math.min(channels.length, 2);
2261
- const enc = new Mp3Encoder(numCh, sampleRate, kbps);
2262
- const left = floatToInt16(channels[0] ?? new Float32Array());
2263
- const right = numCh > 1 ? floatToInt16(channels[1]) : void 0;
2264
- const block = 1152;
2469
+ const enc = new Mp3Encoder(channels, sampleRate, kbps);
2265
2470
  const out = [];
2266
- for (let i = 0; i < left.length; i += block) {
2267
- const l = left.subarray(i, i + block);
2268
- const chunk = right ? enc.encodeBuffer(l, right.subarray(i, i + block)) : enc.encodeBuffer(l);
2471
+ const keep = (chunk) => {
2269
2472
  if (chunk.length > 0) out.push(new Uint8Array(chunk));
2270
- }
2271
- const tail = enc.flush();
2272
- if (tail.length > 0) out.push(new Uint8Array(tail));
2273
- return concatBytes(out);
2473
+ };
2474
+ const block = 1152;
2475
+ return {
2476
+ push(left, right) {
2477
+ for (let i = 0; i < left.length; i += block) {
2478
+ const l = left.subarray(i, i + block);
2479
+ keep(right ? enc.encodeBuffer(l, right.subarray(i, i + block)) : enc.encodeBuffer(l));
2480
+ }
2481
+ },
2482
+ end() {
2483
+ keep(enc.flush());
2484
+ return out;
2485
+ }
2486
+ };
2487
+ }
2488
+ async function encodeMp3(channels, sampleRate, kbps = 192) {
2489
+ const numCh = Math.min(channels.length, 2);
2490
+ const mp3 = await mp3Stream(sampleRate, kbps, numCh);
2491
+ mp3.push(floatToInt16(channels[0] ?? new Float32Array()), numCh > 1 ? floatToInt16(channels[1]) : void 0);
2492
+ return concatBytes(mp3.end());
2274
2493
  }
2275
2494
 
2276
2495
  // src/player/midi.ts
@@ -2316,6 +2535,11 @@ function trackChunk(raw, head) {
2316
2535
  const track = [...head];
2317
2536
  let last = 0;
2318
2537
  for (const e of raw) {
2538
+ if (e.meta) {
2539
+ track.push(...vlq(e.tick - last), ...e.meta);
2540
+ last = e.tick;
2541
+ continue;
2542
+ }
2319
2543
  const status = e.status !== void 0 ? e.status | e.ch & 15 : (e.on ? 144 : 128) | e.ch & 15;
2320
2544
  track.push(...vlq(e.tick - last), status, e.note & 127, e.vel & 127);
2321
2545
  last = e.tick;
@@ -2323,22 +2547,22 @@ function trackChunk(raw, head) {
2323
2547
  track.push(...vlq(0), 255, 47, 0);
2324
2548
  return [77, 84, 114, 107, ...u32be(track.length), ...track];
2325
2549
  }
2326
- function encodeMidi(schedule, opts) {
2550
+ function melodyEvents(schedule, offset = 0) {
2327
2551
  const melody = [];
2328
2552
  const hasGlide = schedule.events.some((ev) => ev.glide === true);
2329
2553
  if (hasGlide) melody.push(...bendRangeSetup());
2330
2554
  schedule.events.forEach((ev, i) => {
2331
2555
  if (ev.glide === true) return;
2332
2556
  for (const g of graceTouches(ev)) {
2333
- const on = Math.max(Math.round(g.beat * PPQ), 0);
2557
+ const on = Math.max(Math.round(g.beat * PPQ) + offset, 0);
2334
2558
  melody.push({ tick: on, on: true, note: g.midi, vel: 70, ch: 0 });
2335
- melody.push({ tick: Math.max(on + 1, Math.round((g.beat + GRACE_STEP) * PPQ)), on: false, note: g.midi, vel: 0, ch: 0 });
2559
+ melody.push({ tick: Math.max(on + 1, Math.round((g.beat + GRACE_STEP) * PPQ) + offset), on: false, note: g.midi, vel: 0, ch: 0 });
2336
2560
  }
2337
2561
  if (ev.midi === null) return;
2338
- const start = Math.round(ev.beat * PPQ);
2562
+ const start = Math.round(ev.beat * PPQ) + offset;
2339
2563
  const targets = glideTargets(schedule.events, i);
2340
2564
  const last = targets[targets.length - 1] ?? ev;
2341
- const end = Math.max(Math.round((last.beat + last.durationBeats) * PPQ), start + 1);
2565
+ const end = Math.max(Math.round((last.beat + last.durationBeats) * PPQ) + offset, start + 1);
2342
2566
  melody.push({ tick: start, on: true, note: ev.midi, vel: 100, ch: 0 });
2343
2567
  melody.push({ tick: end, on: false, note: ev.midi, vel: 0, ch: 0 });
2344
2568
  let from = ev;
@@ -2348,71 +2572,89 @@ function encodeMidi(schedule, opts) {
2348
2572
  const u = n / BEND_STEPS;
2349
2573
  const beat = glideStart + (to.beat - glideStart) * u;
2350
2574
  const semis = from.midi - ev.midi + (to.midi - from.midi) * sCurve(u);
2351
- melody.push(bendEvent(Math.round(beat * PPQ), semis));
2575
+ melody.push(bendEvent(Math.round(beat * PPQ) + offset, semis));
2352
2576
  }
2353
2577
  from = to;
2354
2578
  }
2355
2579
  if (targets.length > 0) melody.push(bendEvent(end, 0));
2356
2580
  });
2581
+ return melody;
2582
+ }
2583
+ function drumEvents(schedule, drums, offset = 0) {
2584
+ const drum = [];
2585
+ for (const seg of schedule.tempo) {
2586
+ const pattern = typeof drums === "function" ? drums(seg) : drums;
2587
+ if (!pattern?.length) continue;
2588
+ const end = seg === schedule.tempo[schedule.tempo.length - 1] ? Math.ceil(schedule.totalBeats) : seg.endBeat;
2589
+ for (let k = 0; seg.beat + k / seg.scale < end - 1e-6; k++) {
2590
+ const note = DRUM_NOTE[pattern[k % pattern.length]];
2591
+ const t = Math.round((seg.beat + k / seg.scale) * PPQ) + offset;
2592
+ drum.push({ tick: t, on: true, note, vel: 100, ch: 9 });
2593
+ drum.push({ tick: t + Math.round(PPQ / 4), on: false, note, vel: 0, ch: 9 });
2594
+ }
2595
+ }
2596
+ return drum;
2597
+ }
2598
+ function midiFile(chunks) {
2599
+ const format = chunks.length > 1 ? 1 : 0;
2600
+ const header = [77, 84, 104, 100, ...u32be(6), 0, format, chunks.length >> 8 & 255, chunks.length & 255, PPQ >> 8 & 255, PPQ & 255];
2601
+ return new Uint8Array([...header, ...chunks.flat()]);
2602
+ }
2603
+ function meta(type, data) {
2604
+ return [255, type, ...vlq(data.length), ...data];
2605
+ }
2606
+ function encodeMidi(schedule, opts) {
2357
2607
  const usPerBeat = Math.round(6e7 / opts.bpm);
2358
2608
  const head = [];
2359
2609
  if (opts.name) head.push(...vlq(0), 255, 3, ...vlq(utf8(opts.name).length), ...utf8(opts.name));
2360
2610
  head.push(...vlq(0), 255, 81, 3, usPerBeat >> 16 & 255, usPerBeat >> 8 & 255, usPerBeat & 255);
2361
- const chunks = [trackChunk(melody, head)];
2611
+ const chunks = [trackChunk(melodyEvents(schedule), head)];
2362
2612
  if (typeof opts.drums === "function" || opts.drums && opts.drums.length > 0) {
2363
- const drum = [];
2364
- for (const seg of schedule.tempo) {
2365
- const pattern = typeof opts.drums === "function" ? opts.drums(seg) : opts.drums;
2366
- if (!pattern?.length) continue;
2367
- const end = seg === schedule.tempo[schedule.tempo.length - 1] ? Math.ceil(schedule.totalBeats) : seg.endBeat;
2368
- for (let k = 0; seg.beat + k / seg.scale < end - 1e-6; k++) {
2369
- const note = DRUM_NOTE[pattern[k % pattern.length]];
2370
- const t = Math.round((seg.beat + k / seg.scale) * PPQ);
2371
- drum.push({ tick: t, on: true, note, vel: 100, ch: 9 });
2372
- drum.push({ tick: t + Math.round(PPQ / 4), on: false, note, vel: 0, ch: 9 });
2373
- }
2374
- }
2613
+ const drum = drumEvents(schedule, opts.drums);
2375
2614
  chunks.push(trackChunk(drum, [...vlq(0), 255, 3, ...vlq(5), 68, 114, 117, 109, 115]));
2376
2615
  }
2377
- const format = chunks.length > 1 ? 1 : 0;
2378
- const header = [
2379
- 77,
2380
- 84,
2381
- 104,
2382
- 100,
2383
- ...u32be(6),
2384
- 0,
2385
- format,
2386
- chunks.length >> 8 & 255,
2387
- chunks.length & 255,
2388
- PPQ >> 8 & 255,
2389
- PPQ & 255
2390
- ];
2391
- return new Uint8Array([...header, ...chunks.flat()]);
2616
+ return midiFile(chunks);
2617
+ }
2618
+ function exportSchedule(doc, o) {
2619
+ return buildSchedule(doc, { tonic: o.tonic, baseOctave: o.baseOctave, laySpeeds: o.laySpeeds, repeats: o.repeats ?? true, excerpt: o.excerpt });
2620
+ }
2621
+ function taalDrums(seg) {
2622
+ if (seg.taal === null) return void 0;
2623
+ const id = seg.taal?.id;
2624
+ return drumPattern(id !== void 0 && id in TAALS ? TAALS[id] : { numBeats: 4, sam: 0 });
2392
2625
  }
2393
2626
  function safeName2(title) {
2394
2627
  return (title ?? "").replace(/[\\/:*?"<>|]+/g, "").trim() || "swaralipi";
2395
2628
  }
2396
2629
  function exportMidi(doc, options = {}) {
2397
- const schedule = buildSchedule(doc, {
2398
- tonic: options.tonic,
2399
- baseOctave: options.baseOctave,
2400
- laySpeeds: options.laySpeeds,
2401
- repeats: options.repeats ?? true
2402
- });
2630
+ const schedule = exportSchedule(doc, options);
2403
2631
  const bpm = options.bpm ?? doc.meta.bpm ?? 90;
2404
- const drums = options.drums ? (seg) => {
2405
- const id = seg.taal?.id;
2406
- if (seg.taal === null) return void 0;
2407
- return drumPattern(id !== void 0 && id in TAALS ? TAALS[id] : { numBeats: 4, sam: 0 });
2408
- } : void 0;
2409
- const bytes = encodeMidi(schedule, { bpm, name: options.name ?? doc.meta.title, drums });
2632
+ const bytes = encodeMidi(schedule, { bpm, name: options.name ?? doc.meta.title, drums: options.drums ? taalDrums : void 0 });
2410
2633
  return {
2411
2634
  blob: new Blob([bytes], { type: "audio/midi" }),
2412
2635
  filename: `${safeName2(doc.meta.title)}.mid`,
2413
2636
  mimeType: "audio/midi"
2414
2637
  };
2415
2638
  }
2639
+ function exportMidiSet(items, name) {
2640
+ const melody = [];
2641
+ const drum = [];
2642
+ let beat = 0;
2643
+ for (const { doc, options = {} } of items) {
2644
+ const schedule = exportSchedule(doc, options);
2645
+ const tick = beat * PPQ;
2646
+ const usPerBeat = Math.round(6e7 / (options.bpm ?? doc.meta.bpm ?? 90));
2647
+ const at = (bytes2) => ({ tick, on: false, note: 0, vel: 0, ch: 0, meta: bytes2 });
2648
+ melody.push(at(meta(81, [usPerBeat >> 16 & 255, usPerBeat >> 8 & 255, usPerBeat & 255])));
2649
+ melody.push(at(meta(6, utf8(options.name ?? doc.meta.title ?? ""))), ...melodyEvents(schedule, tick));
2650
+ if (options.drums) drum.push(...drumEvents(schedule, taalDrums, tick));
2651
+ beat += Math.ceil(schedule.totalBeats) + 2;
2652
+ }
2653
+ const chunks = [trackChunk(melody, [...vlq(0), ...meta(3, utf8(name))])];
2654
+ if (drum.length > 0) chunks.push(trackChunk(drum, [...vlq(0), ...meta(3, utf8("Drums"))]));
2655
+ const bytes = midiFile(chunks);
2656
+ return { blob: new Blob([bytes], { type: "audio/midi" }), filename: `${safeName2(name)}.mid`, mimeType: "audio/midi" };
2657
+ }
2416
2658
 
2417
2659
  exports.ARC_RAW_RE = ARC_RAW_RE;
2418
2660
  exports.DEFAULT_SOUNDS_BASE = DEFAULT_SOUNDS_BASE;
@@ -2430,6 +2672,7 @@ exports.SwaralipiPlayer = SwaralipiPlayer;
2430
2672
  exports.TABLA_BPMS = TABLA_BPMS;
2431
2673
  exports.TABLA_VARIATIONS = TABLA_VARIATIONS;
2432
2674
  exports.TONEJS_INSTRUMENTS_BASE = TONEJS_INSTRUMENTS_BASE;
2675
+ exports.alignLyrics = alignLyrics;
2433
2676
  exports.buildSchedule = buildSchedule;
2434
2677
  exports.createBuffers = createBuffers;
2435
2678
  exports.drumPattern = drumPattern;
@@ -2439,12 +2682,15 @@ exports.encodeWav = encodeWav;
2439
2682
  exports.eventAt = eventAt;
2440
2683
  exports.exportAudio = exportAudio;
2441
2684
  exports.exportMidi = exportMidi;
2685
+ exports.exportMidiSet = exportMidiSet;
2442
2686
  exports.glideTargets = glideTargets;
2443
2687
  exports.graceTouches = graceTouches;
2444
2688
  exports.instrumentSampleUrl = instrumentSampleUrl;
2445
2689
  exports.isArcRaw = isArcRaw;
2690
+ exports.lyricCues = lyricCues;
2446
2691
  exports.meendContinuations = meendContinuations;
2447
2692
  exports.midiToSampleName = midiToSampleName;
2693
+ exports.mp3Stream = mp3Stream;
2448
2694
  exports.nearestSampleKey = nearestSampleKey;
2449
2695
  exports.nearestTablaBpm = nearestTablaBpm;
2450
2696
  exports.normalizeBase = normalizeBase;