@gondola-lyrics/kit 0.4.0 → 0.5.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.
@@ -3,15 +3,18 @@ import { runtime } from '@gondola-lyrics/model';
3
3
  /**
4
4
  * Generates text in the requested format, which need not match `lyric.format`.
5
5
  *
6
- * The writer map pairs each format's options with its result and requires a writer for every supported format.
6
+ * The writer map pairs options with results only for formats that have an implemented writer.
7
7
  * An unknown target returns `null`.
8
8
  */
9
- export declare const generate: <Target extends LyricFormat>(lyric: runtime.Lyric, target: Target, options?: GenerateOptions[Target]) => GenerateResults[Target] | null;
9
+ export declare const generate: <Target extends GenerateFormat>(lyric: runtime.Lyric, target: Target, options?: GenerateOptions[Target]) => GenerateResults[Target] | null;
10
+
11
+ export declare type GenerateFormat = keyof GenerateOptions;
10
12
 
11
13
  /**
12
14
  * Generates the LRC text plus the annotations a single file cannot hold.
13
15
  *
14
16
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
17
+ * Word romanizations retain token boundaries; explicit line romanizations take precedence for the same language.
15
18
  */
16
19
  export declare const generateLrc: (lyric: runtime.Lyric, options?: LrcGenerateOptions) => LrcGenerateResult;
17
20
 
@@ -30,6 +33,7 @@ declare interface GenerateResults {
30
33
  *
31
34
  * Offsets are baked into timestamps; a negative result or a missing required time throws.
32
35
  * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
36
+ * Word romanizations are emitted as line text without their token timing.
33
37
  */
34
38
  export declare const generateTtml: (lyric: runtime.Lyric, options?: TtmlGenerateOptions) => TtmlGenerateResult;
35
39
 
@@ -63,7 +67,7 @@ export declare type LrcGenerateResult = LrcContent;
63
67
  /**
64
68
  * Values match the lowercase format names stored in `Lyric.format`.
65
69
  */
66
- export declare type LyricFormat = 'lrc' | 'ttml';
70
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
67
71
 
68
72
  export declare interface TtmlGenerateOptions {
69
73
  /**
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Diagnostic } from '@gondola-lyrics/model/runtime';
2
2
  import { runtime } from '@gondola-lyrics/model';
3
3
 
4
+ export declare const DEFAULT_CREDIT_NAME_SEPARATORS: readonly ["+", ":", "/"];
5
+
4
6
  /**
5
7
  * Derives emphasis for normal words in lead and background vocals using complete, valid word ranges.
6
8
  * The stored word duration must strictly exceed durationThreshold; metadata offset and duration have no effect.
@@ -29,6 +31,32 @@ export declare interface DeriveWordEmphasisOptions {
29
31
  */
30
32
  export declare const detectFormat: (input: unknown) => LyricFormat | undefined;
31
33
 
34
+ /**
35
+ * Extracts colon-prefixed performers and carries them across nonempty normal lines, replacing their existing agent lists.
36
+ * Missing bindings enables legacy name inference; supplied bindings accept exact prefixes and reset propagation on unmatched prefixes.
37
+ * Removing prefixes preserves retained line and word times, but drops prefix-only lines with their attached content.
38
+ *
39
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
40
+ *
41
+ * @throws {TypeError} If bindings reference an agent id absent from lyric.agents.
42
+ */
43
+ export declare const extractAgents: (lyric: runtime.Lyric, options?: ExtractAgentsOptions) => runtime.Lyric;
44
+
45
+ export declare interface ExtractAgentsOptions {
46
+ /**
47
+ * Maps complete, trimmed prefixes to existing agent ids, preserving array order for joint performers.
48
+ * Matching is case-sensitive and preserves internal whitespace; an empty object disables automatic inference.
49
+ * Omitting this option enables legacy name inference; explicit bindings override its clock exclusion.
50
+ */
51
+ bindings?: Readonly<Record<string, string | readonly string[]>>;
52
+ /**
53
+ * Removes matched prefixes and drops lines left with no sung text, including their attached content.
54
+ *
55
+ * @default true
56
+ */
57
+ removePrefixes?: boolean;
58
+ }
59
+
32
60
  /**
33
61
  * Pulls bracketed background vocals out of the sung lines and attaches them to the line they accompany.
34
62
  *
@@ -61,18 +89,48 @@ export declare interface ExtractBackgroundLinesOptions {
61
89
  crossLine?: boolean;
62
90
  }
63
91
 
92
+ /**
93
+ * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
94
+ * Merges names by role and original label without inferring their language.
95
+ *
96
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
97
+ */
98
+ export declare const extractCredits: (lyric: runtime.Lyric, options?: ExtractCreditsOptions) => runtime.Lyric;
99
+
100
+ export declare interface ExtractCreditsOptions {
101
+ /**
102
+ * Removes extracted source lines unless they carry background vocals, line annotations, or word translations or romanizations.
103
+ * Ruby annotations alone do not prevent removal.
104
+ *
105
+ * Refreshes language usages and falls back to line timing when no word-level content remains.
106
+ *
107
+ * @default false
108
+ */
109
+ removeSourceLines?: boolean;
110
+ /**
111
+ * Splits names at literal separators and ignores empty strings; an empty list preserves the complete source name.
112
+ * Longer separators take precedence so overlapping delimiters remain predictable.
113
+ *
114
+ * @default DEFAULT_CREDIT_NAME_SEPARATORS
115
+ */
116
+ nameSeparators?: readonly string[];
117
+ }
118
+
64
119
  /**
65
120
  * Generates text in the requested format, which need not match `lyric.format`.
66
121
  *
67
- * The writer map pairs each format's options with its result and requires a writer for every supported format.
122
+ * The writer map pairs options with results only for formats that have an implemented writer.
68
123
  * An unknown target returns `null`.
69
124
  */
70
- export declare const generate: <Target extends LyricFormat>(lyric: runtime.Lyric, target: Target, options?: GenerateOptions[Target]) => GenerateResults[Target] | null;
125
+ export declare const generate: <Target extends GenerateFormat>(lyric: runtime.Lyric, target: Target, options?: GenerateOptions[Target]) => GenerateResults[Target] | null;
126
+
127
+ export declare type GenerateFormat = keyof GenerateOptions;
71
128
 
72
129
  /**
73
130
  * Generates the LRC text plus the annotations a single file cannot hold.
74
131
  *
75
132
  * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
133
+ * Word romanizations retain token boundaries; explicit line romanizations take precedence for the same language.
76
134
  */
77
135
  export declare const generateLrc: (lyric: runtime.Lyric, options?: LrcGenerateOptions) => LrcGenerateResult;
78
136
 
@@ -91,6 +149,7 @@ declare interface GenerateResults {
91
149
  *
92
150
  * Offsets are baked into timestamps; a negative result or a missing required time throws.
93
151
  * Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
152
+ * Word romanizations are emitted as line text without their token timing.
94
153
  */
95
154
  export declare const generateTtml: (lyric: runtime.Lyric, options?: TtmlGenerateOptions) => TtmlGenerateResult;
96
155
 
@@ -231,28 +290,60 @@ export declare interface LrcParseOptions {
231
290
  * Defaults to `0`, which requires an exact match.
232
291
  */
233
292
  annotationTolerance?: number;
293
+ /**
294
+ * Maximum roman token drift outside a matched word, in milliseconds; matched boundaries are adjusted to that word.
295
+ * Original word timing is preserved, and incomplete word matches retain the source line romanization.
296
+ *
297
+ * @default 0
298
+ */
299
+ wordAnnotationTolerance?: number;
300
+ /**
301
+ * Recovers unmatched roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
302
+ * Only a base kana followed by its small kana combines; doubled consonants and long vowels retain the whole line romanization.
303
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
304
+ *
305
+ * @default false
306
+ */
307
+ wordAnnotationFallback?: boolean;
234
308
  }
235
309
 
236
310
  /**
237
311
  * Values match the lowercase format names stored in `Lyric.format`.
238
312
  */
239
- export declare type LyricFormat = 'lrc' | 'ttml';
313
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
314
+
315
+ /**
316
+ * Categories the bundled filter rules identify, in rule order.
317
+ */
318
+ export declare const NON_LYRIC_CATEGORIES: readonly string[];
240
319
 
241
320
  /**
242
321
  * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
243
322
  * Annotation tracks travel with the source and are attached by the format's parser.
244
323
  */
245
- export declare const parse: (input: unknown, options?: LrcParseOptions & TtmlParseOptions) => ParseResult | null;
324
+ export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions) => ParseResult | null;
246
325
 
247
326
  /**
248
327
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
249
328
  *
250
329
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
330
+ * Complete roman tracks attach to words by timing and optional local recovery; other tracks retain their source line text.
251
331
  *
252
332
  * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
253
333
  */
254
334
  export declare const parseLrc: (input: string | LrcParseInput, options?: LrcParseOptions) => ParseResult;
255
335
 
336
+ /**
337
+ * Parses plaintext or XML QRC, retaining explicit timing, word romanization, and ruby from kana metadata.
338
+ * Original-track kana supplies ruby and is removed from the returned metadata.
339
+ *
340
+ * @param input Original lyrics with optional translation and romanization tracks.
341
+ * @param options Independent line and word tolerances, with optional local roman recovery.
342
+ *
343
+ * @returns Canonical lyrics and source diagnostics; an empty result has INVALID status.
344
+ */
345
+ export declare const parseQrc: (input: string | QrcParseInput, options?: QrcParseOptions) => ParseResult;
346
+
256
347
  /**
257
348
  * A parsed lyric in canonical form, with every rule it breaks.
258
349
  */
@@ -269,6 +360,65 @@ export declare interface ParseResult {
269
360
  */
270
361
  export declare const parseTtml: (input: TtmlParseInput, options?: TtmlParseOptions) => ParseResult;
271
362
 
363
+ declare interface QrcContent {
364
+ original: string;
365
+ translations?: string;
366
+ romans?: string;
367
+ }
368
+
369
+ export declare type QrcParseInput = QrcContent;
370
+
371
+ export declare interface QrcParseOptions {
372
+ /**
373
+ * Extra tolerance in milliseconds for line annotations left after exact time and row order matching.
374
+ *
375
+ * @default 0
376
+ */
377
+ annotationTolerance?: number;
378
+ /**
379
+ * Extra word-boundary tolerance in milliseconds; native QRC tracks allow at least 1ms for rounding.
380
+ * LRC auxiliary tracks use exact word boundaries by default, and incomplete matches retain the whole line romanization.
381
+ *
382
+ * @default 0
383
+ */
384
+ wordAnnotationTolerance?: number;
385
+ /**
386
+ * Recovers unmatched roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
387
+ * Only a base kana followed by its small kana combines; doubled consonants and long vowels retain the whole line romanization.
388
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
389
+ *
390
+ * @default false
391
+ */
392
+ wordAnnotationFallback?: boolean;
393
+ }
394
+
395
+ /**
396
+ * Removes normal lead lines matched by the shared notice rules and, when musicInfo is provided, first-line song information.
397
+ * The first non-empty normal line must have one literal " - " separator and a known title and artist on opposite sides; either order and extra text such as bracketed aliases are accepted.
398
+ * Drops attached annotations and backgrounds, refreshes language usages, and falls back to line timing when no word-level content remains.
399
+ *
400
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
401
+ */
402
+ export declare const removeNonLyricLines: (lyric: runtime.Lyric, options?: RemoveNonLyricLinesOptions) => runtime.Lyric;
403
+
404
+ export declare interface RemoveNonLyricLinesOptions {
405
+ /**
406
+ * Removes lines identified under these categories; an empty list skips the shared rules.
407
+ * First-line music information is selected independently.
408
+ *
409
+ * @default NON_LYRIC_CATEGORIES
410
+ */
411
+ categories?: readonly string[];
412
+ /**
413
+ * Enables first-line music-information matching using these known titles and artists.
414
+ * Omitting this option, or leaving either list empty, skips the check; lyric metadata is not inferred.
415
+ */
416
+ musicInfo?: {
417
+ titles?: readonly string[];
418
+ artists?: readonly string[];
419
+ };
420
+ }
421
+
272
422
  /**
273
423
  * Removes the brackets some sources wrap around background vocals.
274
424
  *
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { detectFormat, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml } from "./formats.js";
2
- import { InsertSpacesRules, deriveWordEmphasis, extractBackgroundLines, insertInstrumentalLines, insertSpacesInLines, stripBackgroundBrackets } from "./transforms.js";
1
+ import { detectFormat, generate, generateLrc, generateTtml, parse, parseLrc, parseQrc, parseTtml } from "./formats.js";
2
+ import { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, removeNonLyricLines, stripBackgroundBrackets } from "./transforms.js";
3
3
  import "./parser.js";
4
4
  import "./generator.js";
5
- export { InsertSpacesRules, deriveWordEmphasis, detectFormat, extractBackgroundLines, generate, generateLrc, generateTtml, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseTtml, stripBackgroundBrackets };
5
+ export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, generate, generateLrc, generateTtml, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseQrc, parseTtml, removeNonLyricLines, stripBackgroundBrackets };
@@ -0,0 +1,117 @@
1
+ //#region src/utils/array.ts
2
+ /**
3
+ * Preserves the input array when every element keeps its identity, so unchanged parent nodes can also be reused.
4
+ *
5
+ * The callback must leave inputs untouched and return the original element when unchanged.
6
+ */
7
+ var mapShared = (items, transform) => {
8
+ let result = items;
9
+ for (const [index, item] of items.entries()) {
10
+ const transformed = transform(item);
11
+ if (transformed !== item) {
12
+ if (result === items) result = [...items];
13
+ result[index] = transformed;
14
+ }
15
+ }
16
+ return result;
17
+ };
18
+ /**
19
+ * Finds the first value meeting a lower bound so range queries can skip earlier candidates.
20
+ *
21
+ * @param values Values sorted in ascending order.
22
+ * @param target Inclusive lower bound.
23
+ *
24
+ * @returns The first matching index, or the array length when every value is smaller.
25
+ */
26
+ var lowerBound = (values, target) => {
27
+ let left = 0;
28
+ let right = values.length;
29
+ while (left < right) {
30
+ const middle = Math.floor((left + right) / 2);
31
+ if (values[middle] < target) left = middle + 1;
32
+ else right = middle;
33
+ }
34
+ return left;
35
+ };
36
+ //#endregion
37
+ //#region src/utils/language.ts
38
+ var SCRIPT_PATTERNS = [
39
+ [/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u, 1],
40
+ [/\p{Script=Latin}/u, 2],
41
+ [/\p{Script=Hangul}/u, 3],
42
+ [/\p{Script=Cyrillic}/u, 4],
43
+ [/\p{Script=Greek}/u, 5],
44
+ [/\p{Script=Arabic}/u, 6],
45
+ [/\p{Script=Hebrew}/u, 7],
46
+ [/\p{Script=Devanagari}/u, 8],
47
+ [/\p{Script=Thai}/u, 9]
48
+ ];
49
+ var getOtherScript = (text, start, width) => {
50
+ const unit = text.charCodeAt(start);
51
+ if (unit === 12539 || unit === 12540) return 0;
52
+ if (unit >= 1024 && unit <= 1153 || unit >= 1162 && unit <= 1327) return 4;
53
+ if (unit >= 931 && unit <= 993) return 5;
54
+ if (unit >= 1568 && unit <= 1599 || unit >= 1601 && unit <= 1610) return 6;
55
+ if (unit >= 1488 && unit <= 1514) return 7;
56
+ if (unit >= 2304 && unit <= 2384 || unit >= 2389 && unit <= 2403 || unit >= 2406 && unit <= 2431) return 8;
57
+ if (unit >= 3585 && unit <= 3642 || unit >= 3648 && unit <= 3675) return 9;
58
+ const character = text.slice(start, start + width);
59
+ for (const [pattern, script] of SCRIPT_PATTERNS) if (pattern.test(character)) return script;
60
+ return 0;
61
+ };
62
+ /**
63
+ * Finds a boundary between supported scripts without allocating a segment list.
64
+ * Han and kana share a group so Japanese labels stay intact; Hangul, Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari and Thai remain separate.
65
+ * Punctuation, digits, inherited marks and unsupported scripts stay in the surrounding run.
66
+ *
67
+ * @param start A UTF-16 offset at a code-point boundary, usually the end returned by the previous call.
68
+ *
69
+ * @returns The exclusive UTF-16 end of the run, or text.length when no later boundary exists.
70
+ */
71
+ var findScriptRunEnd = (text, start = 0) => {
72
+ let script = 0;
73
+ let index = start;
74
+ while (index < text.length) {
75
+ const unit = text.charCodeAt(index);
76
+ let nextScript = 0;
77
+ let width = 1;
78
+ if (unit <= 127) {
79
+ const letter = unit | 32;
80
+ if (letter >= 97 && letter <= 122) {
81
+ if (script !== 0 && script !== 2) return index;
82
+ script = 2;
83
+ do {
84
+ index += 1;
85
+ const nextLetter = text.charCodeAt(index) | 32;
86
+ if (nextLetter < 97 || nextLetter > 122) break;
87
+ } while (index < text.length);
88
+ continue;
89
+ }
90
+ } else if (unit >= 19968 && unit <= 40959) nextScript = 1;
91
+ else if (unit >= 12353 && unit <= 12438 || unit >= 12449 && unit <= 12538) nextScript = 1;
92
+ else if (unit >= 44032 && unit <= 55203) nextScript = 3;
93
+ else {
94
+ width = unit >= 55296 && unit <= 56319 && text.codePointAt(index) > 65535 ? 2 : 1;
95
+ nextScript = getOtherScript(text, index, width);
96
+ }
97
+ if (nextScript !== 0) {
98
+ if (script !== 0 && script !== nextScript) return index;
99
+ script = nextScript;
100
+ }
101
+ index += width;
102
+ }
103
+ return text.length;
104
+ };
105
+ /**
106
+ * Returns a language tag in the canonical form the model stores, which is lower case.
107
+ *
108
+ * This mirrors the model's own helper, which it does not export.
109
+ * Comparing tags stays with the consumer, since BCP 47 matches by subtag prefix rather than by string equality.
110
+ */
111
+ var lowerTag = (tag) => {
112
+ return tag ? tag.toLowerCase() : void 0;
113
+ };
114
+ //#endregion
115
+ export { mapShared as i, lowerTag as n, lowerBound as r, findScriptRunEnd as t };
116
+
117
+ //# sourceMappingURL=language.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language.js","names":[],"sources":["../src/utils/array.ts","../src/utils/language.ts"],"sourcesContent":["/**\n * Preserves the input array when every element keeps its identity, so unchanged parent nodes can also be reused.\n *\n * The callback must leave inputs untouched and return the original element when unchanged.\n */\nexport const mapShared = <T>(items: T[], transform: (item: T) => T): T[] => {\n let result = items\n for (const [index, item] of items.entries()) {\n const transformed = transform(item)\n if (transformed !== item) {\n if (result === items) {\n result = [...items]\n }\n result[index] = transformed\n }\n }\n return result\n}\n\n/**\n * Finds the first value meeting a lower bound so range queries can skip earlier candidates.\n *\n * @param values Values sorted in ascending order.\n * @param target Inclusive lower bound.\n *\n * @returns The first matching index, or the array length when every value is smaller.\n */\nexport const lowerBound = (values: readonly number[], target: number): number => {\n let left = 0\n let right = values.length\n while (left < right) {\n const middle = Math.floor((left + right) / 2)\n if (values[middle]! < target) {\n left = middle + 1\n } else {\n right = middle\n }\n }\n return left\n}\n","const enum ScriptGroup {\n NEUTRAL,\n EAST_ASIAN,\n LATIN,\n HANGUL,\n CYRILLIC,\n GREEK,\n ARABIC,\n HEBREW,\n DEVANAGARI,\n THAI,\n}\n\nconst SCRIPT_PATTERNS: readonly (readonly [RegExp, ScriptGroup])[] = [\n [/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}]/u, ScriptGroup.EAST_ASIAN],\n [/\\p{Script=Latin}/u, ScriptGroup.LATIN],\n [/\\p{Script=Hangul}/u, ScriptGroup.HANGUL],\n [/\\p{Script=Cyrillic}/u, ScriptGroup.CYRILLIC],\n [/\\p{Script=Greek}/u, ScriptGroup.GREEK],\n [/\\p{Script=Arabic}/u, ScriptGroup.ARABIC],\n [/\\p{Script=Hebrew}/u, ScriptGroup.HEBREW],\n [/\\p{Script=Devanagari}/u, ScriptGroup.DEVANAGARI],\n [/\\p{Script=Thai}/u, ScriptGroup.THAI],\n]\n\nconst getOtherScript = (text: string, start: number, width: number): ScriptGroup => {\n const unit = text.charCodeAt(start)\n if (unit === 0x30fb || unit === 0x30fc) {\n return ScriptGroup.NEUTRAL\n }\n if ((unit >= 0x0400 && unit <= 0x0481) || (unit >= 0x048a && unit <= 0x052f)) {\n return ScriptGroup.CYRILLIC\n }\n if (unit >= 0x03a3 && unit <= 0x03e1) {\n return ScriptGroup.GREEK\n }\n if ((unit >= 0x0620 && unit <= 0x063f) || (unit >= 0x0641 && unit <= 0x064a)) {\n return ScriptGroup.ARABIC\n }\n if (unit >= 0x05d0 && unit <= 0x05ea) {\n return ScriptGroup.HEBREW\n }\n if ((unit >= 0x0900 && unit <= 0x0950) || (unit >= 0x0955 && unit <= 0x0963) || (unit >= 0x0966 && unit <= 0x097f)) {\n return ScriptGroup.DEVANAGARI\n }\n if ((unit >= 0x0e01 && unit <= 0x0e3a) || (unit >= 0x0e40 && unit <= 0x0e5b)) {\n return ScriptGroup.THAI\n }\n\n const character = text.slice(start, start + width)\n for (const [pattern, script] of SCRIPT_PATTERNS) {\n if (pattern.test(character)) {\n return script\n }\n }\n return ScriptGroup.NEUTRAL\n}\n\n/**\n * Finds a boundary between supported scripts without allocating a segment list.\n * Han and kana share a group so Japanese labels stay intact; Hangul, Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari and Thai remain separate.\n * Punctuation, digits, inherited marks and unsupported scripts stay in the surrounding run.\n *\n * @param start A UTF-16 offset at a code-point boundary, usually the end returned by the previous call.\n *\n * @returns The exclusive UTF-16 end of the run, or text.length when no later boundary exists.\n */\nexport const findScriptRunEnd = (text: string, start = 0): number => {\n let script = ScriptGroup.NEUTRAL\n let index = start\n\n while (index < text.length) {\n const unit = text.charCodeAt(index)\n let nextScript = ScriptGroup.NEUTRAL\n let width = 1\n\n if (unit <= 0x7f) {\n const letter = unit | 0x20\n if (letter >= 0x61 && letter <= 0x7a) {\n if (script !== ScriptGroup.NEUTRAL && script !== ScriptGroup.LATIN) {\n return index\n }\n script = ScriptGroup.LATIN\n do {\n index += 1\n const nextLetter = text.charCodeAt(index) | 0x20\n if (nextLetter < 0x61 || nextLetter > 0x7a) {\n break\n }\n } while (index < text.length)\n continue\n }\n } else if (unit >= 0x4e00 && unit <= 0x9fff) {\n nextScript = ScriptGroup.EAST_ASIAN\n } else if ((unit >= 0x3041 && unit <= 0x3096) || (unit >= 0x30a1 && unit <= 0x30fa)) {\n nextScript = ScriptGroup.EAST_ASIAN\n } else if (unit >= 0xac00 && unit <= 0xd7a3) {\n nextScript = ScriptGroup.HANGUL\n } else {\n width = unit >= 0xd800 && unit <= 0xdbff && text.codePointAt(index)! > 0xffff ? 2 : 1\n nextScript = getOtherScript(text, index, width)\n }\n\n if (nextScript !== ScriptGroup.NEUTRAL) {\n if (script !== ScriptGroup.NEUTRAL && script !== nextScript) {\n return index\n }\n script = nextScript\n }\n index += width\n }\n return text.length\n}\n\n/**\n * Returns a language tag in the canonical form the model stores, which is lower case.\n *\n * This mirrors the model's own helper, which it does not export.\n * Comparing tags stays with the consumer, since BCP 47 matches by subtag prefix rather than by string equality.\n */\nexport const lowerTag = (tag: string | undefined): string | undefined => {\n return tag ? tag.toLowerCase() : undefined\n}\n"],"mappings":"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"}
package/dist/parser.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Diagnostic } from '@gondola-lyrics/model/runtime';
2
2
  import { runtime } from '@gondola-lyrics/model';
3
3
 
4
+ export declare const DEFAULT_CREDIT_NAME_SEPARATORS: readonly ["+", ":", "/"];
5
+
4
6
  /**
5
7
  * Derives emphasis for normal words in lead and background vocals using complete, valid word ranges.
6
8
  * The stored word duration must strictly exceed durationThreshold; metadata offset and duration have no effect.
@@ -29,6 +31,32 @@ export declare interface DeriveWordEmphasisOptions {
29
31
  */
30
32
  export declare const detectFormat: (input: unknown) => LyricFormat | undefined;
31
33
 
34
+ /**
35
+ * Extracts colon-prefixed performers and carries them across nonempty normal lines, replacing their existing agent lists.
36
+ * Missing bindings enables legacy name inference; supplied bindings accept exact prefixes and reset propagation on unmatched prefixes.
37
+ * Removing prefixes preserves retained line and word times, but drops prefix-only lines with their attached content.
38
+ *
39
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
40
+ *
41
+ * @throws {TypeError} If bindings reference an agent id absent from lyric.agents.
42
+ */
43
+ export declare const extractAgents: (lyric: runtime.Lyric, options?: ExtractAgentsOptions) => runtime.Lyric;
44
+
45
+ export declare interface ExtractAgentsOptions {
46
+ /**
47
+ * Maps complete, trimmed prefixes to existing agent ids, preserving array order for joint performers.
48
+ * Matching is case-sensitive and preserves internal whitespace; an empty object disables automatic inference.
49
+ * Omitting this option enables legacy name inference; explicit bindings override its clock exclusion.
50
+ */
51
+ bindings?: Readonly<Record<string, string | readonly string[]>>;
52
+ /**
53
+ * Removes matched prefixes and drops lines left with no sung text, including their attached content.
54
+ *
55
+ * @default true
56
+ */
57
+ removePrefixes?: boolean;
58
+ }
59
+
32
60
  /**
33
61
  * Pulls bracketed background vocals out of the sung lines and attaches them to the line they accompany.
34
62
  *
@@ -61,6 +89,33 @@ export declare interface ExtractBackgroundLinesOptions {
61
89
  crossLine?: boolean;
62
90
  }
63
91
 
92
+ /**
93
+ * Preserves source labels and name spelling while matching normalized credit labels in normal lead lines.
94
+ * Merges names by role and original label without inferring their language.
95
+ *
96
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
97
+ */
98
+ export declare const extractCredits: (lyric: runtime.Lyric, options?: ExtractCreditsOptions) => runtime.Lyric;
99
+
100
+ export declare interface ExtractCreditsOptions {
101
+ /**
102
+ * Removes extracted source lines unless they carry background vocals, line annotations, or word translations or romanizations.
103
+ * Ruby annotations alone do not prevent removal.
104
+ *
105
+ * Refreshes language usages and falls back to line timing when no word-level content remains.
106
+ *
107
+ * @default false
108
+ */
109
+ removeSourceLines?: boolean;
110
+ /**
111
+ * Splits names at literal separators and ignores empty strings; an empty list preserves the complete source name.
112
+ * Longer separators take precedence so overlapping delimiters remain predictable.
113
+ *
114
+ * @default DEFAULT_CREDIT_NAME_SEPARATORS
115
+ */
116
+ nameSeparators?: readonly string[];
117
+ }
118
+
64
119
  /**
65
120
  * Inserts derived instrumental lines into uncovered gaps on the stored timeline, starting at zero.
66
121
  * Existing lines and background vocals occupy their full ranges; an unknown end blocks later gaps.
@@ -177,28 +232,60 @@ export declare interface LrcParseOptions {
177
232
  * Defaults to `0`, which requires an exact match.
178
233
  */
179
234
  annotationTolerance?: number;
235
+ /**
236
+ * Maximum roman token drift outside a matched word, in milliseconds; matched boundaries are adjusted to that word.
237
+ * Original word timing is preserved, and incomplete word matches retain the source line romanization.
238
+ *
239
+ * @default 0
240
+ */
241
+ wordAnnotationTolerance?: number;
242
+ /**
243
+ * Recovers unmatched roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
244
+ * Only a base kana followed by its small kana combines; doubled consonants and long vowels retain the whole line romanization.
245
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
246
+ *
247
+ * @default false
248
+ */
249
+ wordAnnotationFallback?: boolean;
180
250
  }
181
251
 
182
252
  /**
183
253
  * Values match the lowercase format names stored in `Lyric.format`.
184
254
  */
185
- export declare type LyricFormat = 'lrc' | 'ttml';
255
+ export declare type LyricFormat = 'lrc' | 'qrc' | 'ttml';
256
+
257
+ /**
258
+ * Categories the bundled filter rules identify, in rule order.
259
+ */
260
+ export declare const NON_LYRIC_CATEGORIES: readonly string[];
186
261
 
187
262
  /**
188
263
  * Unrecognized text returns `null`; a recognized format returns its canonical lyric and diagnostics.
189
264
  * Annotation tracks travel with the source and are attached by the format's parser.
190
265
  */
191
- export declare const parse: (input: unknown, options?: LrcParseOptions & TtmlParseOptions) => ParseResult | null;
266
+ export declare const parse: (input: unknown, options?: LrcParseOptions & QrcParseOptions & TtmlParseOptions) => ParseResult | null;
192
267
 
193
268
  /**
194
269
  * Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
195
270
  *
196
271
  * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
272
+ * Complete roman tracks attach to words by timing and optional local recovery; other tracks retain their source line text.
197
273
  *
198
274
  * `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
199
275
  */
200
276
  export declare const parseLrc: (input: string | LrcParseInput, options?: LrcParseOptions) => ParseResult;
201
277
 
278
+ /**
279
+ * Parses plaintext or XML QRC, retaining explicit timing, word romanization, and ruby from kana metadata.
280
+ * Original-track kana supplies ruby and is removed from the returned metadata.
281
+ *
282
+ * @param input Original lyrics with optional translation and romanization tracks.
283
+ * @param options Independent line and word tolerances, with optional local roman recovery.
284
+ *
285
+ * @returns Canonical lyrics and source diagnostics; an empty result has INVALID status.
286
+ */
287
+ export declare const parseQrc: (input: string | QrcParseInput, options?: QrcParseOptions) => ParseResult;
288
+
202
289
  /**
203
290
  * A parsed lyric in canonical form, with every rule it breaks.
204
291
  */
@@ -215,6 +302,65 @@ export declare interface ParseResult {
215
302
  */
216
303
  export declare const parseTtml: (input: TtmlParseInput, options?: TtmlParseOptions) => ParseResult;
217
304
 
305
+ declare interface QrcContent {
306
+ original: string;
307
+ translations?: string;
308
+ romans?: string;
309
+ }
310
+
311
+ export declare type QrcParseInput = QrcContent;
312
+
313
+ export declare interface QrcParseOptions {
314
+ /**
315
+ * Extra tolerance in milliseconds for line annotations left after exact time and row order matching.
316
+ *
317
+ * @default 0
318
+ */
319
+ annotationTolerance?: number;
320
+ /**
321
+ * Extra word-boundary tolerance in milliseconds; native QRC tracks allow at least 1ms for rounding.
322
+ * LRC auxiliary tracks use exact word boundaries by default, and incomplete matches retain the whole line romanization.
323
+ *
324
+ * @default 0
325
+ */
326
+ wordAnnotationTolerance?: number;
327
+ /**
328
+ * Recovers unmatched roman fragments from unique two-kana combination starts or equal-sized gaps between timing anchors.
329
+ * Only a base kana followed by its small kana combines; doubled consonants and long vowels retain the whole line romanization.
330
+ * Recovered token times are clipped to their original word; untimed fragments inherit its range.
331
+ *
332
+ * @default false
333
+ */
334
+ wordAnnotationFallback?: boolean;
335
+ }
336
+
337
+ /**
338
+ * Removes normal lead lines matched by the shared notice rules and, when musicInfo is provided, first-line song information.
339
+ * The first non-empty normal line must have one literal " - " separator and a known title and artist on opposite sides; either order and extra text such as bracketed aliases are accepted.
340
+ * Drops attached annotations and backgrounds, refreshes language usages, and falls back to line timing when no word-level content remains.
341
+ *
342
+ * @returns The input when unchanged, or a new lyric sharing all unchanged nodes.
343
+ */
344
+ export declare const removeNonLyricLines: (lyric: runtime.Lyric, options?: RemoveNonLyricLinesOptions) => runtime.Lyric;
345
+
346
+ export declare interface RemoveNonLyricLinesOptions {
347
+ /**
348
+ * Removes lines identified under these categories; an empty list skips the shared rules.
349
+ * First-line music information is selected independently.
350
+ *
351
+ * @default NON_LYRIC_CATEGORIES
352
+ */
353
+ categories?: readonly string[];
354
+ /**
355
+ * Enables first-line music-information matching using these known titles and artists.
356
+ * Omitting this option, or leaving either list empty, skips the check; lyric metadata is not inferred.
357
+ */
358
+ musicInfo?: {
359
+ titles?: readonly string[];
360
+ artists?: readonly string[];
361
+ };
362
+ }
363
+
218
364
  /**
219
365
  * Removes the brackets some sources wrap around background vocals.
220
366
  *
package/dist/parser.js CHANGED
@@ -1,3 +1,3 @@
1
- import { detectFormat, parse, parseLrc, parseTtml } from "./formats.js";
2
- import { InsertSpacesRules, deriveWordEmphasis, extractBackgroundLines, insertInstrumentalLines, insertSpacesInLines, stripBackgroundBrackets } from "./transforms.js";
3
- export { InsertSpacesRules, deriveWordEmphasis, detectFormat, extractBackgroundLines, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseTtml, stripBackgroundBrackets };
1
+ import { detectFormat, parse, parseLrc, parseQrc, parseTtml } from "./formats.js";
2
+ import { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, removeNonLyricLines, stripBackgroundBrackets } from "./transforms.js";
3
+ export { DEFAULT_CREDIT_NAME_SEPARATORS, InsertSpacesRules, NON_LYRIC_CATEGORIES, deriveWordEmphasis, detectFormat, extractAgents, extractBackgroundLines, extractCredits, insertInstrumentalLines, insertSpacesInLines, parse, parseLrc, parseQrc, parseTtml, removeNonLyricLines, stripBackgroundBrackets };