@gondola-lyrics/kit 0.3.0 → 0.4.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.
@@ -0,0 +1,1048 @@
1
+ import { LineType, Timing, WordType, deriveLanguageUsages, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, makeTimeRange, makeWordEmphasis, makeWordSpace, validateTimeRange } from "@gondola-lyrics/model/runtime";
2
+ //#region src/utils/array.ts
3
+ /**
4
+ * Preserves the input array when every element keeps its identity, so unchanged parent nodes can also be reused.
5
+ *
6
+ * The callback must leave inputs untouched and return the original element when unchanged.
7
+ */
8
+ var mapShared = (items, transform) => {
9
+ let result = items;
10
+ for (const [index, item] of items.entries()) {
11
+ const transformed = transform(item);
12
+ if (transformed !== item) {
13
+ if (result === items) result = [...items];
14
+ result[index] = transformed;
15
+ }
16
+ }
17
+ return result;
18
+ };
19
+ //#endregion
20
+ //#region src/transforms/background/utils.ts
21
+ var OPEN_BRACKETS = /* @__PURE__ */ new Set(["(", "("]);
22
+ var CLOSE_BRACKETS = /* @__PURE__ */ new Set([")", ")"]);
23
+ var isOpenBracket = (char) => {
24
+ return OPEN_BRACKETS.has(char);
25
+ };
26
+ var isCloseBracket = (char) => {
27
+ return CLOSE_BRACKETS.has(char);
28
+ };
29
+ var isNormalWord = (word) => {
30
+ return word.type === WordType.NORMAL;
31
+ };
32
+ /**
33
+ * Content carries plain text or words, never both, so exactly one of the two is read.
34
+ */
35
+ var readLineText = (line) => {
36
+ return line.content?.text ?? (line.content?.words ?? []).map((word) => word.text).join("");
37
+ };
38
+ var readTimeRange = (line) => {
39
+ return line.time ? makeTimeRange({
40
+ start: line.time.start,
41
+ end: line.time.end
42
+ }) : void 0;
43
+ };
44
+ //#endregion
45
+ //#region src/transforms/background/brackets.ts
46
+ /**
47
+ * Removes one leading bracket from the first sung word and one trailing bracket from the last, so an inner pair stays as written.
48
+ *
49
+ * Space words at the same edge go with the bracket, and a word left with no text goes too, since a sung word must carry something.
50
+ */
51
+ var stripWordEdges = (words) => {
52
+ const firstIndex = words.findIndex(isNormalWord);
53
+ const first = words[firstIndex];
54
+ const last = words[words.findLastIndex(isNormalWord)];
55
+ const stripStart = first ? isOpenBracket(first.text.charAt(0)) : false;
56
+ const stripEnd = last ? isCloseBracket(last.text.charAt(last.text.length - 1)) : false;
57
+ if (!stripStart && !stripEnd) return words;
58
+ const result = [...words];
59
+ if (stripStart && first) {
60
+ const text = first.text.slice(1);
61
+ if (text) result[firstIndex] = {
62
+ ...first,
63
+ text
64
+ };
65
+ else result.splice(firstIndex, 1);
66
+ while (result[0]?.type === WordType.SPACE) result.shift();
67
+ }
68
+ const endIndex = result.findLastIndex(isNormalWord);
69
+ const end = result[endIndex];
70
+ if (stripEnd && end) {
71
+ const text = end.text.slice(0, -1);
72
+ if (text) result[endIndex] = {
73
+ ...end,
74
+ text
75
+ };
76
+ else result.splice(endIndex, 1);
77
+ while (result.at(-1)?.type === WordType.SPACE) result.pop();
78
+ }
79
+ return result;
80
+ };
81
+ /**
82
+ * Plain text is read literally, so a source that padded it with spaces keeps them; only word content
83
+ * treats space words as the structural separators they are.
84
+ */
85
+ var stripContentBrackets = (content) => {
86
+ if (!content) return content;
87
+ if (!content.words.length) {
88
+ const text = content.text;
89
+ if (!text) return content;
90
+ const start = isOpenBracket(text.charAt(0)) ? 1 : 0;
91
+ const end = isCloseBracket(text.charAt(text.length - 1)) ? -1 : 0;
92
+ if (!start && !end) return content;
93
+ const stripped = text.slice(start, end === 0 ? void 0 : end);
94
+ return stripped ? makeLineContentText(stripped) : void 0;
95
+ }
96
+ const words = stripWordEdges(content.words);
97
+ if (words === content.words) return content;
98
+ return words.length ? makeLineContentWords(words) : void 0;
99
+ };
100
+ var stripLineBackgrounds = (line) => {
101
+ if (!line.backgrounds.length) return line;
102
+ let changed = false;
103
+ const kept = [];
104
+ for (const background of line.backgrounds) {
105
+ const content = stripContentBrackets(background.content);
106
+ if (content === background.content) {
107
+ kept.push(background);
108
+ continue;
109
+ }
110
+ changed = true;
111
+ if (content) kept.push({
112
+ ...background,
113
+ content
114
+ });
115
+ }
116
+ return changed ? {
117
+ ...line,
118
+ backgrounds: kept
119
+ } : line;
120
+ };
121
+ /**
122
+ * Removes the brackets some sources wrap around background vocals.
123
+ *
124
+ * One layer is removed from each edge of every background line, leaving a nested pair as `(x)`.
125
+ * A line left with no content is dropped, since a background line must carry something.
126
+ *
127
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
128
+ */
129
+ var stripBackgroundBrackets = (lyric) => {
130
+ const lines = mapShared(lyric.lines, stripLineBackgrounds);
131
+ return lines === lyric.lines ? lyric : {
132
+ ...lyric,
133
+ lines
134
+ };
135
+ };
136
+ //#endregion
137
+ //#region src/transforms/background/extract.ts
138
+ /**
139
+ * An unclosed bracket never became a run, so its text returns to the line it came from.
140
+ * An empty pair yields no run, since a background line must carry text.
141
+ */
142
+ var splitText = (text) => {
143
+ const chars = [...text];
144
+ let main = "";
145
+ let current = "";
146
+ let opened = "";
147
+ let openedAt = 0;
148
+ let depth = 0;
149
+ const runs = [];
150
+ for (const [index, char] of chars.entries()) if (isOpenBracket(char)) {
151
+ if (depth) current += char;
152
+ else {
153
+ opened = char;
154
+ openedAt = index;
155
+ }
156
+ depth += 1;
157
+ } else if (isCloseBracket(char)) {
158
+ if (depth === 0) {
159
+ main += char;
160
+ continue;
161
+ }
162
+ depth -= 1;
163
+ if (depth) current += char;
164
+ else {
165
+ runs.push({
166
+ text: current,
167
+ start: openedAt + 1,
168
+ end: index
169
+ });
170
+ current = "";
171
+ }
172
+ } else if (depth) current += char;
173
+ else main += char;
174
+ if (depth) main += opened + current;
175
+ return {
176
+ main: main.trim().replaceAll(/\s{2,}/gu, " "),
177
+ runs: runs.filter((run) => {
178
+ return run.text.trim() !== "";
179
+ }),
180
+ length: chars.length
181
+ };
182
+ };
183
+ /**
184
+ * A word straddling a bracket is cut into pieces sharing its time, since the source gave no finer boundary.
185
+ * An unclosed bracket never became a run, so its text returns to the line it came from.
186
+ * An empty pair yields no run, since a background line must carry text.
187
+ */
188
+ var splitWords = (words) => {
189
+ const main = [];
190
+ const runs = [];
191
+ let current = [];
192
+ let opened;
193
+ let depth = 0;
194
+ for (const word of words) {
195
+ if (!isNormalWord(word)) {
196
+ (depth ? current : main).push(word);
197
+ continue;
198
+ }
199
+ let buffer = "";
200
+ for (const char of word.text) if (isOpenBracket(char)) {
201
+ if (depth) buffer += char;
202
+ else {
203
+ if (buffer) {
204
+ main.push({
205
+ ...word,
206
+ text: buffer
207
+ });
208
+ buffer = "";
209
+ }
210
+ opened = {
211
+ ...word,
212
+ text: char
213
+ };
214
+ }
215
+ depth += 1;
216
+ } else if (isCloseBracket(char)) {
217
+ if (depth === 0) {
218
+ buffer += char;
219
+ continue;
220
+ }
221
+ depth -= 1;
222
+ if (depth) buffer += char;
223
+ else {
224
+ if (buffer) {
225
+ current.push({
226
+ ...word,
227
+ text: buffer
228
+ });
229
+ buffer = "";
230
+ }
231
+ if (current.length) {
232
+ runs.push(current);
233
+ current = [];
234
+ }
235
+ }
236
+ } else buffer += char;
237
+ if (buffer) (depth ? current : main).push({
238
+ ...word,
239
+ text: buffer
240
+ });
241
+ }
242
+ if (depth) {
243
+ if (opened) main.push(opened);
244
+ main.push(...current);
245
+ }
246
+ return {
247
+ main,
248
+ runs: runs.filter((run) => {
249
+ return run.some((word) => {
250
+ return word.text.trim() !== "";
251
+ });
252
+ })
253
+ };
254
+ };
255
+ var mapSpan = (range, length, run) => {
256
+ if (range.end === void 0 || length === 0) return makeTimeRange({
257
+ start: range.start,
258
+ end: range.end
259
+ });
260
+ const span = range.end - range.start;
261
+ const start = range.start + Math.round(span * run.start / length);
262
+ const end = range.start + Math.round(span * run.end / length);
263
+ return makeTimeRange({
264
+ start,
265
+ end: Math.max(start, end)
266
+ });
267
+ };
268
+ /**
269
+ * Falls back to the line's own range when no word carries a time, so a run still gets a range.
270
+ */
271
+ var readWordRange = (words, fallback) => {
272
+ const timed = words.filter((word) => {
273
+ return isNormalWord(word) && word.time !== void 0;
274
+ });
275
+ const first = timed[0]?.time;
276
+ const last = timed.at(-1)?.time;
277
+ if (!first || !last) return fallback;
278
+ return makeTimeRange({
279
+ start: first.start,
280
+ end: last.end
281
+ });
282
+ };
283
+ /**
284
+ * A background may start before its line but not finish after it, so the line grows to the furthest stated end.
285
+ */
286
+ var attach = (line, backgrounds) => {
287
+ const merged = [...line.backgrounds, ...backgrounds];
288
+ merged.sort((left, right) => {
289
+ const leftStart = left.time?.start ?? 0;
290
+ const rightStart = right.time?.start ?? 0;
291
+ return leftStart === rightStart ? (left.time?.end ?? 0) - (right.time?.end ?? 0) : leftStart - rightStart;
292
+ });
293
+ let end = line.time?.end;
294
+ for (const background of merged) {
295
+ const backgroundEnd = background.time?.end;
296
+ if (backgroundEnd !== void 0 && (end === void 0 || backgroundEnd > end)) end = backgroundEnd;
297
+ }
298
+ if (!line.time || end === line.time.end) return {
299
+ ...line,
300
+ backgrounds: merged
301
+ };
302
+ return {
303
+ ...line,
304
+ time: makeTimeRange({
305
+ start: line.time.start,
306
+ end
307
+ }),
308
+ backgrounds: merged
309
+ };
310
+ };
311
+ /**
312
+ * A closed line cannot hold an open background, so it stops where the line does.
313
+ * On the last line the timeline itself ends unknown, so an open background leaves the line open too.
314
+ */
315
+ var boundOpenBackgrounds = (lines) => {
316
+ const last = lines.length - 1;
317
+ return lines.map((line, index) => {
318
+ if (!line.backgrounds.filter((background) => {
319
+ return background.time !== void 0 && background.time.end === void 0;
320
+ }).length) return line;
321
+ if (index === last) return line.time ? {
322
+ ...line,
323
+ time: makeTimeRange({ start: line.time.start })
324
+ } : line;
325
+ if (!line.time || line.time.end === void 0) return line;
326
+ const end = line.time.end;
327
+ return {
328
+ ...line,
329
+ backgrounds: line.backgrounds.map((background) => {
330
+ const time = background.time;
331
+ if (!time || time.end !== void 0) return background;
332
+ return {
333
+ ...background,
334
+ time: makeTimeRange({
335
+ start: time.start,
336
+ end: Math.max(time.start, end)
337
+ })
338
+ };
339
+ })
340
+ };
341
+ });
342
+ };
343
+ /**
344
+ * Words are kept only where the caller says the lyric is word-timed, since the other timings forbid them.
345
+ */
346
+ var makeBackground = (line, words, text, time, wordLevel) => {
347
+ return makeLineBackground({
348
+ time,
349
+ agents: [...line.agents],
350
+ languages: [...line.languages],
351
+ content: wordLevel && words.length ? makeLineContentWords(words) : makeLineContentText(text)
352
+ });
353
+ };
354
+ var readBackgrounds = (line, wordLevel) => {
355
+ const content = line.content;
356
+ if (!content) return [];
357
+ const range = line.time;
358
+ if (wordLevel && content.words.length) return splitWords(content.words).runs.map((words) => {
359
+ const time = range ? readWordRange(words, range) : void 0;
360
+ return makeBackground(line, words, words.map((word) => word.text).join(""), time, wordLevel);
361
+ });
362
+ const text = content.text ?? content.words.map((word) => word.text).join("");
363
+ if (!text) return [];
364
+ const { runs, length } = splitText(text);
365
+ return runs.map((run) => {
366
+ return makeBackground(line, [], run.text, range ? mapSpan(range, length, run) : void 0, false);
367
+ });
368
+ };
369
+ /**
370
+ * An entry without brackets stays on the line, since only the bracketed part describes a background.
371
+ * The line's annotation is rebuilt rather than edited, so the input lyric stays untouched.
372
+ */
373
+ var splitAnnotation = (annotation, backgrounds) => {
374
+ if (!annotation || !backgrounds.length) return annotation;
375
+ const move = (items, rebuild, push) => {
376
+ return items.map((item) => {
377
+ const { main, runs } = splitText(item.text);
378
+ if (!runs.length) return item;
379
+ for (const [index, run] of runs.entries()) {
380
+ const background = backgrounds[index];
381
+ if (background) push(background, run.text, item.language);
382
+ }
383
+ return rebuild(item, main);
384
+ });
385
+ };
386
+ const romans = move(annotation.romans, (item, text) => makeLineAnnotationRoman({
387
+ ...item,
388
+ text
389
+ }), (background, text, language) => {
390
+ background.annotation ??= makeLineAnnotation();
391
+ background.annotation.romans.push(makeLineAnnotationRoman({
392
+ text,
393
+ language
394
+ }));
395
+ });
396
+ const translations = move(annotation.translations, (item, text) => makeLineAnnotationTranslation({
397
+ ...item,
398
+ text
399
+ }), (background, text, language) => {
400
+ background.annotation ??= makeLineAnnotation();
401
+ background.annotation.translations.push(makeLineAnnotationTranslation({
402
+ text,
403
+ language
404
+ }));
405
+ });
406
+ return makeLineAnnotation({
407
+ ...annotation,
408
+ romans,
409
+ translations
410
+ });
411
+ };
412
+ /**
413
+ * Words survive only where the lyric is word-timed, since the other timings forbid them.
414
+ */
415
+ var toBackground = (line, time, wordLevel) => {
416
+ const content = line.content;
417
+ if (!content) return;
418
+ const text = content.text ?? content.words.map((word) => word.text).join("");
419
+ if (!text) return;
420
+ return makeLineBackground({
421
+ time,
422
+ agents: [...line.agents],
423
+ languages: [...line.languages],
424
+ content: wordLevel && content.words.length ? content : makeLineContentText(text),
425
+ annotation: line.annotation
426
+ });
427
+ };
428
+ var isBracketOnlyLine = (line) => {
429
+ if (!line || line.type !== LineType.NORMAL) return false;
430
+ const { main, runs } = splitText(readLineText(line));
431
+ return runs.length > 0 && !main;
432
+ };
433
+ /**
434
+ * A line that held nothing but brackets has no text of its own to sing, so it cannot be the one a background accompanies.
435
+ */
436
+ var findParentIndex = (lines) => {
437
+ for (let index = lines.length - 1; index >= 0; index--) {
438
+ const line = lines[index];
439
+ if (line?.type === LineType.NORMAL && !isBracketOnlyLine(line)) return index;
440
+ }
441
+ return -1;
442
+ };
443
+ /**
444
+ * Both source lines go, since neither holds anything left to sing, and the joined range spans both.
445
+ */
446
+ var joinCrossLine = (lines, wordLevel) => {
447
+ const result = [];
448
+ let changed = false;
449
+ let skip = false;
450
+ for (const [index, line] of lines.entries()) {
451
+ if (skip) {
452
+ skip = false;
453
+ continue;
454
+ }
455
+ const next = lines[index + 1];
456
+ const parentIndex = findParentIndex(result);
457
+ if (line.type === LineType.NORMAL && next?.type === LineType.NORMAL && parentIndex !== -1) {
458
+ const opened = splitText(readLineText(line));
459
+ const closed = splitText(readLineText(next));
460
+ const opens = opened.runs.length === 0 && isOpenBracket(opened.main.charAt(0));
461
+ const closes = closed.runs.length === 0 && isCloseBracket(closed.main.charAt(closed.main.length - 1));
462
+ if (opens && closes) {
463
+ const start = line.time?.start;
464
+ const end = next.time?.end ?? next.time?.start;
465
+ const background = toBackground({
466
+ ...line,
467
+ content: makeLineContentText(opened.main.slice(1) + closed.main.slice(0, -1))
468
+ }, start === void 0 ? void 0 : makeTimeRange({
469
+ start,
470
+ end
471
+ }), wordLevel);
472
+ if (background) {
473
+ result[parentIndex] = attach(result[parentIndex], [background]);
474
+ changed = true;
475
+ skip = true;
476
+ continue;
477
+ }
478
+ }
479
+ }
480
+ result.push(line);
481
+ }
482
+ return {
483
+ lines: result,
484
+ changed
485
+ };
486
+ };
487
+ var stripRuns = (content) => {
488
+ if (content.words.length) {
489
+ const { main } = splitWords(content.words);
490
+ return main.length ? makeLineContentWords(main) : void 0;
491
+ }
492
+ const { main } = splitText(content.text ?? "");
493
+ return main ? makeLineContentText(main) : void 0;
494
+ };
495
+ /**
496
+ * Pulls bracketed background vocals out of the sung lines and attaches them to the line they accompany.
497
+ *
498
+ * A line holding nothing but brackets becomes a background of the line before it and is removed.
499
+ * A bracket opened on one line and closed on a later one joins the line before the opening, and both source lines go.
500
+ * Text outside brackets stays on its line, and a line left with none keeps its place.
501
+ *
502
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
503
+ */
504
+ var extractBackgroundLines = (lyric, options = {}) => {
505
+ const fullLine = options.fullLine ?? true;
506
+ const inLine = options.inLine ?? true;
507
+ const crossLine = options.crossLine ?? true;
508
+ if (!lyric.lines.length) return lyric;
509
+ const wordLevel = lyric.timing === Timing.WORD;
510
+ const joined = crossLine ? joinCrossLine(lyric.lines, wordLevel) : {
511
+ lines: lyric.lines,
512
+ changed: false
513
+ };
514
+ const result = [];
515
+ let changed = joined.changed;
516
+ for (const line of joined.lines) {
517
+ if (line.type !== LineType.NORMAL || !line.content) {
518
+ result.push(line);
519
+ continue;
520
+ }
521
+ const parentIndex = findParentIndex(result);
522
+ if (fullLine && parentIndex !== -1) {
523
+ const { main, runs } = splitText(readLineText(line));
524
+ if (runs.length && !main) {
525
+ const backgrounds = runs.map((run) => {
526
+ return makeBackground(line, [], run.text, readTimeRange(line), false);
527
+ });
528
+ result[parentIndex] = attach(result[parentIndex], backgrounds);
529
+ changed = true;
530
+ continue;
531
+ }
532
+ }
533
+ const backgrounds = inLine ? readBackgrounds(line, wordLevel) : [];
534
+ if (!backgrounds.length) {
535
+ result.push(line);
536
+ continue;
537
+ }
538
+ const nextContent = stripRuns(line.content);
539
+ if (!nextContent) {
540
+ result.push(line);
541
+ continue;
542
+ }
543
+ const extracted = {
544
+ ...line,
545
+ content: nextContent,
546
+ annotation: splitAnnotation(line.annotation, backgrounds)
547
+ };
548
+ result.push(attach(extracted, backgrounds));
549
+ changed = true;
550
+ }
551
+ if (!changed) return lyric;
552
+ const lines = boundOpenBackgrounds(result);
553
+ return {
554
+ ...lyric,
555
+ lines,
556
+ languages: deriveLanguageUsages(lines)
557
+ };
558
+ };
559
+ //#endregion
560
+ //#region src/transforms/emphasis/index.ts
561
+ var deriveContentEmphasis = (content, durationThreshold) => {
562
+ if (content === void 0) return content;
563
+ const words = mapShared(content.words, (word) => {
564
+ if (word.type !== WordType.NORMAL || word.emphasis?.derived === false) return word;
565
+ const time = word.time;
566
+ if (time !== void 0 && time.end !== void 0 && validateTimeRange(time).length === 0 && time.end - time.start > durationThreshold) {
567
+ if (word.emphasis?.value === true) return word;
568
+ return {
569
+ ...word,
570
+ emphasis: makeWordEmphasis({
571
+ value: true,
572
+ derived: true
573
+ })
574
+ };
575
+ }
576
+ if (word.emphasis === void 0) return word;
577
+ return {
578
+ ...word,
579
+ emphasis: void 0
580
+ };
581
+ });
582
+ return words === content.words ? content : {
583
+ ...content,
584
+ words
585
+ };
586
+ };
587
+ /**
588
+ * Derives emphasis for normal words in lead and background vocals using complete, valid word ranges.
589
+ * The stored word duration must strictly exceed durationThreshold; metadata offset and duration have no effect.
590
+ *
591
+ * Source emphasis, including an explicit false, is preserved; derived emphasis is cleared when a word no longer qualifies.
592
+ *
593
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
594
+ *
595
+ * @throws {RangeError} If durationThreshold is not a non-negative safe integer in milliseconds.
596
+ */
597
+ var deriveWordEmphasis = (lyric, options = {}) => {
598
+ const durationThreshold = options.durationThreshold ?? 3e3;
599
+ if (!Number.isSafeInteger(durationThreshold) || durationThreshold < 0) throw new RangeError("durationThreshold must be a non-negative safe integer in milliseconds");
600
+ const lines = mapShared(lyric.lines, (line) => {
601
+ if (line.type !== LineType.NORMAL) return line;
602
+ const content = deriveContentEmphasis(line.content, durationThreshold);
603
+ const backgrounds = mapShared(line.backgrounds, (background) => {
604
+ const content = deriveContentEmphasis(background.content, durationThreshold);
605
+ return content === background.content ? background : {
606
+ ...background,
607
+ content
608
+ };
609
+ });
610
+ return content === line.content && backgrounds === line.backgrounds ? line : {
611
+ ...line,
612
+ content,
613
+ backgrounds
614
+ };
615
+ });
616
+ return lines === lyric.lines ? lyric : {
617
+ ...lyric,
618
+ lines
619
+ };
620
+ };
621
+ //#endregion
622
+ //#region src/transforms/instrumental/index.ts
623
+ var MAX_TIME = 2 ** 31 - 1;
624
+ var readMinimum = (value, fallback, name) => {
625
+ const minimum = value ?? fallback;
626
+ if (!Number.isSafeInteger(minimum) || minimum < 0) throw new RangeError(`${name} must be a non-negative safe integer in milliseconds`);
627
+ return minimum;
628
+ };
629
+ var readEndTime = (value) => {
630
+ if (value !== void 0 && (!Number.isInteger(value) || value < 0 || value > MAX_TIME)) throw new RangeError(`endTime must be a non-negative integer in milliseconds no greater than ${MAX_TIME}`);
631
+ return value;
632
+ };
633
+ var compareTimeRanges = (left, right) => {
634
+ const leftStart = left?.start ?? Infinity;
635
+ const rightStart = right?.start ?? Infinity;
636
+ if (leftStart !== rightStart) return leftStart - rightStart;
637
+ const leftEnd = left?.end ?? Infinity;
638
+ const rightEnd = right?.end ?? Infinity;
639
+ return leftEnd === rightEnd ? 0 : leftEnd - rightEnd;
640
+ };
641
+ var readOccupiedTimeline = (lyric) => {
642
+ const ranges = [];
643
+ let firstSungStart = Infinity;
644
+ const append = (time, sung) => {
645
+ if (time === void 0 || validateTimeRange(time).length > 0) return false;
646
+ if (time.end === time.start) return true;
647
+ ranges.push(time);
648
+ if (sung) firstSungStart = Math.min(firstSungStart, time.start);
649
+ return true;
650
+ };
651
+ for (const line of lyric.lines) {
652
+ if (!append(line.time, line.type === LineType.NORMAL)) return;
653
+ for (const background of line.backgrounds) if (!append(background.time, true)) return;
654
+ }
655
+ ranges.sort(compareTimeRanges);
656
+ return {
657
+ ranges,
658
+ firstSungStart
659
+ };
660
+ };
661
+ var compareLineTimes = (left, right) => {
662
+ return compareTimeRanges(left.time, right.time);
663
+ };
664
+ var mergeLines = (lines, inserted) => {
665
+ let source = lines;
666
+ for (const [index, line] of lines.entries()) {
667
+ const previous = lines[index - 1];
668
+ if (previous !== void 0 && compareLineTimes(previous, line) > 0) {
669
+ source = [...lines].sort(compareLineTimes);
670
+ break;
671
+ }
672
+ }
673
+ const merged = [];
674
+ let nextIndex = 0;
675
+ for (const line of source) {
676
+ let next = inserted[nextIndex];
677
+ while (next !== void 0 && compareLineTimes(next, line) < 0) {
678
+ merged.push(next);
679
+ nextIndex += 1;
680
+ next = inserted[nextIndex];
681
+ }
682
+ merged.push(line);
683
+ }
684
+ merged.push(...inserted.slice(nextIndex));
685
+ return merged;
686
+ };
687
+ /**
688
+ * Inserts derived instrumental lines into uncovered gaps on the stored timeline, starting at zero.
689
+ * Existing lines and background vocals occupy their full ranges; an unknown end blocks later gaps.
690
+ *
691
+ * An outro requires an explicit endTime; metadata duration and offset do not affect insertion.
692
+ *
693
+ * @returns The input when unchanged, or a new lyric and lines array sharing existing nodes.
694
+ */
695
+ var insertInstrumentalLines = (lyric, options = {}) => {
696
+ const minDuration = readMinimum(options.minDuration, 1e4, "minDuration");
697
+ const minIntroDuration = readMinimum(options.minIntroDuration, 5e3, "minIntroDuration");
698
+ const endTime = readEndTime(options.endTime);
699
+ if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD || lyric.lines.length === 0) return lyric;
700
+ const occupied = readOccupiedTimeline(lyric);
701
+ if (!occupied || occupied.firstSungStart === Infinity) return lyric;
702
+ const inserted = [];
703
+ let coveredEnd = 0;
704
+ const appendGap = (end, minimum) => {
705
+ const duration = end - coveredEnd;
706
+ if (duration > 0 && duration >= minimum) inserted.push(makeLineInstrumental({ time: makeTimeRange({
707
+ start: coveredEnd,
708
+ end
709
+ }) }));
710
+ };
711
+ for (const range of occupied.ranges) {
712
+ appendGap(range.start, range.start <= occupied.firstSungStart ? minIntroDuration : minDuration);
713
+ coveredEnd = Math.max(coveredEnd, range.end ?? Infinity);
714
+ if (coveredEnd === Infinity) break;
715
+ }
716
+ if (endTime !== void 0) appendGap(endTime, minDuration);
717
+ if (inserted.length === 0) return lyric;
718
+ return {
719
+ ...lyric,
720
+ lines: mergeLines(lyric.lines, inserted)
721
+ };
722
+ };
723
+ //#endregion
724
+ //#region src/transforms/space/constants.ts
725
+ /**
726
+ * Groups of characters a source may write without a space where one belongs.
727
+ */
728
+ var InsertSpacesRules = {
729
+ /**
730
+ * A CJK character touching a Latin letter, as in `ABC中文`.
731
+ */
732
+ CJK_ENGLISH: "cjkEnglish",
733
+ /**
734
+ * A CJK character touching a digit, as in `2026年`.
735
+ */
736
+ CJK_NUMBER: "cjkNumber",
737
+ /**
738
+ * A comma, semicolon, exclamation mark, question mark or colon touching a character, as in `a,b`; a clock time or thousands separator such as `10:30` stays as written.
739
+ */
740
+ PUNCTUATION: "punctuation",
741
+ /**
742
+ * A quotation mark touching a neighbouring character.
743
+ */
744
+ QUOTE: "quote",
745
+ /**
746
+ * A bracket touching the character outside it.
747
+ */
748
+ BRACKET_OUTSIDE: "bracketOutside",
749
+ /**
750
+ * A plus, equals or ampersand touching a character, as in `a+b`; a compound operator such as `a+=b` stays as written.
751
+ */
752
+ MATH_OPERATOR: "mathOperator",
753
+ /**
754
+ * A hyphen touching a CJK character, as in `中-文`; a compound word such as `well-known` stays as written.
755
+ */
756
+ HYPHEN: "hyphen",
757
+ /**
758
+ * A slash touching a character, as in `and/or`; a fraction such as `1/2` stays as written.
759
+ */
760
+ SLASH: "slash",
761
+ /**
762
+ * Runs of two or more spaces, collapsed to one.
763
+ */
764
+ COMPRESS_SPACES: "compressSpaces",
765
+ /**
766
+ * Spaces just inside a bracket or quotation mark, removed.
767
+ */
768
+ TRIM_INSIDE_SYMBOLS: "trimInsideSymbols"
769
+ };
770
+ var INSERT_SPACES_RULES = Object.values(InsertSpacesRules);
771
+ //#endregion
772
+ //#region src/transforms/space/rules.ts
773
+ var CJK_RANGE = "぀-ヿ㐀-䶿一-鿿豈-﫿";
774
+ var LATIN_RANGE = "A-Za-z";
775
+ var DIGIT_RANGE = "0-9";
776
+ var SYMBOL_RANGE = "!@#$%^&+\\-=/|<>";
777
+ var WORD_RANGE = `${LATIN_RANGE}${DIGIT_RANGE}${CJK_RANGE}`;
778
+ var ALL_RANGE = `${WORD_RANGE}${SYMBOL_RANGE}`;
779
+ var CJK_LATIN = new RegExp(`([${CJK_RANGE}])([${LATIN_RANGE}])`, "gu");
780
+ var LATIN_CJK = new RegExp(`([${LATIN_RANGE}])([${CJK_RANGE}])`, "gu");
781
+ var CJK_DIGIT = new RegExp(`([${CJK_RANGE}])([${DIGIT_RANGE}])`, "gu");
782
+ var DIGIT_CJK = new RegExp(`([${DIGIT_RANGE}])([${CJK_RANGE}])`, "gu");
783
+ var PUNCTUATION = new RegExp(`([${ALL_RANGE}])([!;,\\?:])(?=[${ALL_RANGE}])`, "gu");
784
+ var QUOTE_BEFORE = new RegExp(`([${CJK_RANGE}${LATIN_RANGE}${DIGIT_RANGE}])(["\`“‘])`, "gu");
785
+ var QUOTE_AFTER = new RegExp(`(["”’])([${CJK_RANGE}${LATIN_RANGE}${DIGIT_RANGE}])`, "gu");
786
+ var BRACKET_BEFORE = new RegExp(`([${WORD_RANGE}])([\\[({<])`, "gu");
787
+ var BRACKET_AFTER = new RegExp(`([\\])}>])([${WORD_RANGE}])`, "gu");
788
+ var MATH_OPERATOR = new RegExp(`([${ALL_RANGE}])([+=&])(?=[${ALL_RANGE}])`, "gu");
789
+ var HYPHEN = new RegExp(`(?<=[${WORD_RANGE}])-(?=[${WORD_RANGE}])`, "gu");
790
+ var SLASH = new RegExp(`([${WORD_RANGE}])(/)(?=[${WORD_RANGE}])`, "gu");
791
+ var TRIM_INSIDE_SYMBOLS = /* @__PURE__ */ new RegExp("([<[{\"“‘(])\\s*([^<>[\\]{}()\"“‘”’]*?)\\s*([>\\]}\"”’)])", "gu");
792
+ var REPEATED_SPACES = / {2,}/gu;
793
+ var REPEATED_MARKS = /[!;,\\?:+\-=/&]{2,}/gu;
794
+ var PLACEHOLDER = /\u0001(\d+)\u0001/gu;
795
+ var isDigit = (char) => {
796
+ return char !== void 0 && char >= "0" && char <= "9";
797
+ };
798
+ var isLatin = (char) => {
799
+ return char !== void 0 && (char >= "A" && char <= "Z" || char >= "a" && char <= "z");
800
+ };
801
+ /**
802
+ * A comma or colon between two digits belongs to one number or clock time, so it stays tight.
803
+ */
804
+ var applyPunctuation = (text) => {
805
+ return text.replace(PUNCTUATION, (match, before, mark, offset, source) => {
806
+ if ((mark === "," || mark === ":") && isDigit(before) && isDigit(source[offset + match.length])) return match;
807
+ return `${before}${mark} `;
808
+ });
809
+ };
810
+ /**
811
+ * A hyphen between two digits is a date, range or phone number, and one between two Latin letters joins a compound word.
812
+ *
813
+ * An edge hyphen is left alone, since it reads as a sign or a list marker rather than a dash.
814
+ */
815
+ var applyHyphen = (text) => {
816
+ return text.replace(HYPHEN, (match, offset, source) => {
817
+ const left = source[offset - 1];
818
+ const right = source[offset + 1];
819
+ return isDigit(left) && isDigit(right) || isLatin(left) && isLatin(right) ? match : " - ";
820
+ });
821
+ };
822
+ /**
823
+ * A slash between two digits is a fraction or date, so it stays tight.
824
+ */
825
+ var applySlash = (text) => {
826
+ return text.replace(SLASH, (match, before, slash, offset, source) => {
827
+ return isDigit(before) && isDigit(source[offset + match.length]) ? match : `${before} ${slash} `;
828
+ });
829
+ };
830
+ /**
831
+ * Trims the spaces just inside a bracket or quotation mark, without touching a nested pair inside it.
832
+ */
833
+ var applyTrimInsideSymbols = (text) => {
834
+ return text.replace(TRIM_INSIDE_SYMBOLS, (match, left, inner, right) => {
835
+ return `${left}${inner.trim()}${right}`;
836
+ });
837
+ };
838
+ var APPLIERS = {
839
+ [InsertSpacesRules.CJK_ENGLISH]: (text) => {
840
+ return text.replace(CJK_LATIN, "$1 $2").replace(LATIN_CJK, "$1 $2");
841
+ },
842
+ [InsertSpacesRules.CJK_NUMBER]: (text) => {
843
+ return text.replace(CJK_DIGIT, "$1 $2").replace(DIGIT_CJK, "$1 $2");
844
+ },
845
+ [InsertSpacesRules.PUNCTUATION]: applyPunctuation,
846
+ [InsertSpacesRules.QUOTE]: (text) => {
847
+ return text.replace(QUOTE_BEFORE, "$1 $2").replace(QUOTE_AFTER, "$1 $2");
848
+ },
849
+ [InsertSpacesRules.BRACKET_OUTSIDE]: (text) => {
850
+ return text.replace(BRACKET_BEFORE, "$1 $2").replace(BRACKET_AFTER, "$1 $2");
851
+ },
852
+ [InsertSpacesRules.MATH_OPERATOR]: (text) => {
853
+ return text.replace(MATH_OPERATOR, "$1 $2 ");
854
+ },
855
+ [InsertSpacesRules.HYPHEN]: applyHyphen,
856
+ [InsertSpacesRules.SLASH]: applySlash,
857
+ [InsertSpacesRules.COMPRESS_SPACES]: (text) => {
858
+ return text.replace(REPEATED_SPACES, " ");
859
+ },
860
+ [InsertSpacesRules.TRIM_INSIDE_SYMBOLS]: applyTrimInsideSymbols
861
+ };
862
+ var APPLY_ORDER = [
863
+ InsertSpacesRules.CJK_ENGLISH,
864
+ InsertSpacesRules.CJK_NUMBER,
865
+ InsertSpacesRules.PUNCTUATION,
866
+ InsertSpacesRules.QUOTE,
867
+ InsertSpacesRules.BRACKET_OUTSIDE,
868
+ InsertSpacesRules.MATH_OPERATOR,
869
+ InsertSpacesRules.HYPHEN,
870
+ InsertSpacesRules.SLASH,
871
+ InsertSpacesRules.TRIM_INSIDE_SYMBOLS,
872
+ InsertSpacesRules.COMPRESS_SPACES
873
+ ];
874
+ /**
875
+ * Falls back to every rule only when no list was given, so an explicit empty list means "apply nothing".
876
+ */
877
+ var resolveRules = (rules) => {
878
+ return new Set(rules ?? INSERT_SPACES_RULES);
879
+ };
880
+ /**
881
+ * Applies the selected rules to one text, returning it trimmed.
882
+ *
883
+ * Repeated marks are held aside first, so a rule that widens a single mark never splits a run that belongs together.
884
+ */
885
+ var insertSpaces = (text, rules) => {
886
+ if (!text.trim()) return text;
887
+ const held = [];
888
+ let result = text.replace(REPEATED_MARKS, (match) => {
889
+ held.push(match);
890
+ return `\u0001${held.length - 1}\u0001`;
891
+ });
892
+ for (const rule of APPLY_ORDER) if (rules.has(rule)) result = APPLIERS[rule](result);
893
+ return result.replace(PLACEHOLDER, (match, index) => {
894
+ return held[Number(index)] ?? match;
895
+ }).trim();
896
+ };
897
+ //#endregion
898
+ //#region src/transforms/space/insert.ts
899
+ /**
900
+ * Maps the rewritten text back onto the original words.
901
+ *
902
+ * Every non-space character of the original survives in order, so walking both strings keeps each word's time,
903
+ * language and annotation while the inserted spaces become their own words.
904
+ * A word whose characters stay adjacent in the rewrite is kept whole, so only a word the space actually cuts is split.
905
+ */
906
+ var alignWords = (words, text) => {
907
+ const source = words.filter(isNormalWord);
908
+ const result = [];
909
+ let cursor = 0;
910
+ const takeSpaces = () => {
911
+ let spaces = "";
912
+ while (text[cursor] === " ") {
913
+ spaces += " ";
914
+ cursor += 1;
915
+ }
916
+ return spaces;
917
+ };
918
+ for (const word of source) {
919
+ const spaces = takeSpaces();
920
+ if (spaces) result.push(makeWordSpace({ text: spaces }));
921
+ if (text.startsWith(word.text, cursor)) {
922
+ result.push(word);
923
+ cursor += word.text.length;
924
+ continue;
925
+ }
926
+ let remaining = word.text;
927
+ while (remaining) {
928
+ let taken = 0;
929
+ while (taken < remaining.length && text[cursor + taken] === remaining[taken]) taken += 1;
930
+ if (taken === 0) return;
931
+ result.push({
932
+ ...word,
933
+ text: remaining.slice(0, taken)
934
+ });
935
+ cursor += taken;
936
+ remaining = remaining.slice(taken);
937
+ if (!remaining) break;
938
+ const split = takeSpaces();
939
+ if (!split) return;
940
+ result.push(makeWordSpace({ text: split }));
941
+ }
942
+ }
943
+ const tail = takeSpaces();
944
+ if (tail) result.push(makeWordSpace({ text: tail }));
945
+ if (cursor !== text.length) return;
946
+ return result;
947
+ };
948
+ /**
949
+ * Applies the rules to a word-level content, keeping it unchanged when the alignment fails.
950
+ */
951
+ var applyToWords = (content, rules) => {
952
+ const text = content.words.map((word) => word.text).join("");
953
+ const rewritten = insertSpaces(text, rules);
954
+ if (rewritten === text) return content;
955
+ const words = alignWords(content.words, rewritten);
956
+ if (!words) return content;
957
+ return words.length ? makeLineContentWords(words) : void 0;
958
+ };
959
+ /**
960
+ * Applies the rules to a plain-text content, dropping a line the rules would leave empty.
961
+ */
962
+ var applyToText = (content, rules) => {
963
+ const text = content.text ?? "";
964
+ const rewritten = insertSpaces(text, rules);
965
+ if (rewritten === text) return content;
966
+ return rewritten ? makeLineContentText(rewritten) : void 0;
967
+ };
968
+ /**
969
+ * Applies the rules to one content by the shape it carries.
970
+ */
971
+ var applyToContent = (content, rules) => {
972
+ if (!content) return content;
973
+ return content.words.length ? applyToWords(content, rules) : applyToText(content, rules);
974
+ };
975
+ /**
976
+ * Applies the rules to the line annotation, keeping the entries they leave untouched.
977
+ */
978
+ var applyToAnnotation = (annotation, rules) => {
979
+ if (!annotation) return annotation;
980
+ let changed = false;
981
+ const romans = annotation.romans.map((item) => {
982
+ const text = insertSpaces(item.text, rules);
983
+ if (text === item.text) return item;
984
+ changed = true;
985
+ return makeLineAnnotationRoman({
986
+ ...item,
987
+ text
988
+ });
989
+ });
990
+ const translations = annotation.translations.map((item) => {
991
+ const text = insertSpaces(item.text, rules);
992
+ if (text === item.text) return item;
993
+ changed = true;
994
+ return makeLineAnnotationTranslation({
995
+ ...item,
996
+ text
997
+ });
998
+ });
999
+ return changed ? {
1000
+ ...annotation,
1001
+ romans,
1002
+ translations
1003
+ } : annotation;
1004
+ };
1005
+ /**
1006
+ * Inserts the spaces a source left out, so CJK text set against Latin letters, digits or punctuation reads evenly.
1007
+ *
1008
+ * A word-level line keeps every word's time and annotation: an inserted space becomes its own space word,
1009
+ * and a word the rules split yields one word per character, all sharing the original range.
1010
+ * A plain-text line and the annotation tracks are rewritten as whole strings.
1011
+ *
1012
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
1013
+ */
1014
+ var insertSpacesInLines = (lyric, options = {}) => {
1015
+ const rules = resolveRules(options.rules);
1016
+ const annotations = options.annotations ?? true;
1017
+ const applyToBackground = (background) => {
1018
+ const content = applyToContent(background.content, rules);
1019
+ const annotation = annotations ? applyToAnnotation(background.annotation, rules) : background.annotation;
1020
+ if (content === background.content && annotation === background.annotation) return background;
1021
+ return {
1022
+ ...background,
1023
+ content,
1024
+ annotation
1025
+ };
1026
+ };
1027
+ const lines = mapShared(lyric.lines, (line) => {
1028
+ if (line.type !== LineType.NORMAL) return line;
1029
+ const content = applyToContent(line.content, rules);
1030
+ const annotation = annotations ? applyToAnnotation(line.annotation, rules) : line.annotation;
1031
+ const backgrounds = mapShared(line.backgrounds, applyToBackground);
1032
+ if (content === line.content && annotation === line.annotation && backgrounds === line.backgrounds) return line;
1033
+ return {
1034
+ ...line,
1035
+ content,
1036
+ annotation,
1037
+ backgrounds
1038
+ };
1039
+ });
1040
+ return lines === lyric.lines ? lyric : {
1041
+ ...lyric,
1042
+ lines
1043
+ };
1044
+ };
1045
+ //#endregion
1046
+ export { INSERT_SPACES_RULES, InsertSpacesRules, deriveWordEmphasis, extractBackgroundLines, insertInstrumentalLines, insertSpacesInLines, stripBackgroundBrackets };
1047
+
1048
+ //# sourceMappingURL=transforms.js.map