@gondola-lyrics/kit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/formats.d.ts +327 -4
- package/dist/formats.js +1991 -292
- package/dist/formats.js.map +1 -1
- package/dist/generator.d.ts +7 -3
- package/dist/index.d.ts +242 -4
- package/dist/index.js +3 -3
- package/dist/parser.d.ts +236 -2
- package/dist/parser.js +3 -3
- package/dist/rules.js +166 -0
- package/dist/rules.js.map +1 -0
- package/dist/transforms.d.ts +95 -0
- package/dist/transforms.js +2251 -32
- package/dist/transforms.js.map +1 -1
- package/package.json +1 -1
package/dist/formats.js
CHANGED
|
@@ -1,109 +1,593 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { a as lowerBound, i as lowerTag, o as mapShared, r as trimRuleWhitespace } from "./rules.js";
|
|
2
|
+
import { AgentType, CreditRole, LineType, LyricStatus, Timing, WordType, canonicalizeLyric, deriveLanguageUsages, deriveLineRomans, getAnnotationItemText, getContentText, getWordsText, isWordLevelLine, makeAgent, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineBackground, makeLineContentText, makeLineContentWords, makeLineInstrumental, makeLineNormal, makeLyric, makeMeta, makeMetaCredit, makeText, makeTimeRange, makeUnknownEntry, makeWordAnnotation, makeWordAnnotationRoman, makeWordAnnotationRuby, makeWordAnnotationToken, makeWordNormal, makeWordSpace, removeOffset, resolveAnnotationItemTime, resolveAnnotationTokenTime, validateLyric } from "@gondola-lyrics/model/runtime";
|
|
3
|
+
//#region src/formats/krc/common.ts
|
|
4
|
+
var LINE_PATTERN$3 = /^\[(\d+),(\d+)\]/;
|
|
5
|
+
var WORD_PATTERN$3 = /<(\d+,\d+,\d+)>/g;
|
|
3
6
|
/**
|
|
4
|
-
*
|
|
7
|
+
* Reads decrypted plaintext without unwrapping XML or accepting auxiliary tracks.
|
|
8
|
+
*
|
|
9
|
+
* @returns Original content, or null when the input cannot be read as plaintext KRC.
|
|
5
10
|
*/
|
|
6
|
-
var
|
|
7
|
-
|
|
11
|
+
var readKrcContent = (input) => {
|
|
12
|
+
const source = typeof input === "string" ? { original: input } : input;
|
|
13
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return null;
|
|
14
|
+
const original = source.original;
|
|
15
|
+
if (typeof original !== "string" || /^\s*</.test(original)) return null;
|
|
16
|
+
return { original };
|
|
8
17
|
};
|
|
9
|
-
|
|
10
|
-
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/formats/krc/detect.ts
|
|
20
|
+
var LANGUAGE_PATTERN = /\[language:\s*ey[A-Za-z0-9+/=]*\]/;
|
|
21
|
+
/**
|
|
22
|
+
* Recognizes native KRC from a duration line carrying a complete three-field word tag, or from the Kugou language container.
|
|
23
|
+
*
|
|
24
|
+
* Both signals live in the original plaintext, so an auxiliary track still cannot decide the format.
|
|
25
|
+
*
|
|
26
|
+
* @returns Whether the original plaintext contains a strong KRC syntax signal.
|
|
27
|
+
*/
|
|
28
|
+
var detectKrc = (input) => {
|
|
29
|
+
const source = readKrcContent(input);
|
|
30
|
+
if (!source) return false;
|
|
31
|
+
if (LANGUAGE_PATTERN.test(source.original)) return true;
|
|
32
|
+
return source.original.split(/\r\n?|\n/).some((raw) => {
|
|
33
|
+
const line = raw.trimStart();
|
|
34
|
+
const header = LINE_PATTERN$3.exec(line);
|
|
35
|
+
return !!header && /<\d+,\d+,\d+>/.test(line.slice(header[0].length));
|
|
36
|
+
});
|
|
11
37
|
};
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/formats/result.ts
|
|
12
40
|
/**
|
|
13
|
-
*
|
|
41
|
+
* Canonicalizes and validates one parser's output, so every format hands back the same shape.
|
|
42
|
+
*
|
|
43
|
+
* Canonicalization runs first because validation reports paths against the canonical tree.
|
|
44
|
+
*/
|
|
45
|
+
var finalizeLyric = (input) => {
|
|
46
|
+
const lyric = canonicalizeLyric(input);
|
|
47
|
+
return {
|
|
48
|
+
lyric,
|
|
49
|
+
diagnostics: validateLyric(lyric)
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/utils/kana.ts
|
|
54
|
+
var KANA_COMBINATIONS = /* @__PURE__ */ new Map([
|
|
55
|
+
[12353, "うくぐつふゔ"],
|
|
56
|
+
[12355, "うくぐすずつてでふゔ"],
|
|
57
|
+
[12357, "とど"],
|
|
58
|
+
[12359, "いうくぐしじちぢつにひびぴみりふゔ"],
|
|
59
|
+
[12361, "うくぐつふゔ"],
|
|
60
|
+
[12419, "きぎしじちぢにひびぴみり"],
|
|
61
|
+
[12421, "きぎしじちぢにひびぴみりてでふゔ"],
|
|
62
|
+
[12423, "きぎしじちぢにひびぴみり"],
|
|
63
|
+
[12430, "くぐ"]
|
|
64
|
+
]);
|
|
65
|
+
var readKana = (text) => {
|
|
66
|
+
const kana = text.normalize("NFKC");
|
|
67
|
+
if (kana.length !== 1) return "";
|
|
68
|
+
const code = kana.charCodeAt(0);
|
|
69
|
+
return code >= 12449 && code <= 12534 ? String.fromCharCode(code - 96) : kana;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Reports whether the second kana joins the first into one mora, so a single roman fragment may cover both.
|
|
14
73
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
74
|
+
* Katakana and compatibility forms normalize to hiragana first.
|
|
75
|
+
* Doubled consonants, long-vowel marks and three-kana runs are not combinations.
|
|
17
76
|
*/
|
|
18
|
-
var
|
|
77
|
+
var combinesKana = (first, second) => {
|
|
78
|
+
const base = readKana(first);
|
|
79
|
+
const small = readKana(second);
|
|
80
|
+
return !!base && !!small && !!KANA_COMBINATIONS.get(small.charCodeAt(0))?.includes(base);
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/utils/annotation.ts
|
|
84
|
+
var readText$3 = (line) => {
|
|
85
|
+
return getContentText(line.content).trim();
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Combines explicit roman tracks with display-only word aggregations, preferring explicit entries for the same language.
|
|
89
|
+
*
|
|
90
|
+
* Derived entries must not be stored back on the line.
|
|
91
|
+
*/
|
|
92
|
+
var getLineRomans = (line) => {
|
|
93
|
+
const explicit = line.annotation?.romans ?? [];
|
|
94
|
+
const languages = new Set(explicit.map((item) => lowerTag(item.language)));
|
|
95
|
+
const derived = deriveLineRomans(line.content).filter((item) => !languages.has(lowerTag(item.language)));
|
|
96
|
+
return derived.length ? [...explicit, ...derived] : explicit;
|
|
97
|
+
};
|
|
98
|
+
var readWordRange = (word) => {
|
|
99
|
+
const time = word.time;
|
|
100
|
+
if (!time || !Number.isSafeInteger(time.start) || time.start < 0 || time.end !== void 0 && (!Number.isSafeInteger(time.end) || time.end < time.start)) return;
|
|
101
|
+
return time;
|
|
102
|
+
};
|
|
103
|
+
var containsRomanTime = (source, target, tolerance) => {
|
|
104
|
+
const end = target.end ?? Number.POSITIVE_INFINITY;
|
|
105
|
+
if (source.start < target.start - tolerance || source.end !== void 0 && source.end > end + tolerance) return false;
|
|
106
|
+
if (source.end === void 0 || source.start === source.end) return target.start === target.end ? source.start <= end + tolerance : source.start < end + tolerance;
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
var matchRomanTarget = (time, targets, tolerance) => {
|
|
110
|
+
let matched;
|
|
111
|
+
let priority = -1;
|
|
112
|
+
for (const target of targets) {
|
|
113
|
+
if (!containsRomanTime(time, target.time, tolerance)) continue;
|
|
114
|
+
const exact = time.end !== void 0 && time.start === target.time.start && time.end === target.time.end;
|
|
115
|
+
const point = time.start === time.end && target.time.start === target.time.end;
|
|
116
|
+
const rank = exact ? 3 : point ? 2 : containsRomanTime(time, target.time, 0) ? 1 : 0;
|
|
117
|
+
if (rank > priority) {
|
|
118
|
+
matched = target;
|
|
119
|
+
priority = rank;
|
|
120
|
+
} else if (rank === priority) matched = void 0;
|
|
121
|
+
}
|
|
122
|
+
return matched ?? (priority === -1 ? void 0 : null);
|
|
123
|
+
};
|
|
124
|
+
var overlapLength = (time, target) => {
|
|
125
|
+
const finish = time.end ?? time.start;
|
|
126
|
+
return Math.min(finish, target.time.end ?? Number.POSITIVE_INFINITY) - Math.max(time.start, target.time.start);
|
|
127
|
+
};
|
|
128
|
+
var sharesLanguage = (targets) => {
|
|
129
|
+
return new Set(targets.flatMap((target) => target ? [lowerTag(target.word.language)] : []).filter(Boolean)).size <= 1;
|
|
130
|
+
};
|
|
131
|
+
var isOrderedByTime = (targets) => {
|
|
132
|
+
return targets.every((target, index) => {
|
|
133
|
+
return target.time.start >= (targets[index - 1]?.time.start ?? 0);
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
var canExtendToFollowing = (target, following) => {
|
|
137
|
+
const first = lowerTag(target.word.language);
|
|
138
|
+
const second = lowerTag(following.word.language);
|
|
139
|
+
return (!first || !second || first === second) && combinesKana(target.word.text, following.word.text);
|
|
140
|
+
};
|
|
141
|
+
var recoverRomanTargets = (sources, targets, tolerance, rounding, language) => {
|
|
142
|
+
const slack = Math.max(tolerance, rounding);
|
|
143
|
+
const used = new Set(sources.flatMap((source) => source.target ? [source.target] : []));
|
|
144
|
+
const reserved = /* @__PURE__ */ new Set();
|
|
145
|
+
const conflict = (target) => {
|
|
146
|
+
return target.word.annotation?.romans.some((item) => lowerTag(item.language) === language);
|
|
147
|
+
};
|
|
148
|
+
for (const [index, source] of sources.entries()) {
|
|
149
|
+
const time = source.time;
|
|
150
|
+
if (source.target || !time || time.end === void 0) continue;
|
|
151
|
+
const exact = targets.filter((target) => target.time.start === time.start);
|
|
152
|
+
const candidates = exact.length ? exact : targets.filter((target) => Math.abs(target.time.start - time.start) <= rounding);
|
|
153
|
+
if (candidates.length !== 1) continue;
|
|
154
|
+
const target = candidates[0];
|
|
155
|
+
const following = targets[targets.indexOf(target) + 1];
|
|
156
|
+
if (!following || following.index !== target.index + 1 || !canExtendToFollowing(target, following)) continue;
|
|
157
|
+
if (used.has(target) || used.has(following) || reserved.has(target) || reserved.has(following)) continue;
|
|
158
|
+
if (conflict(target) || following.word.annotation?.romans.length) continue;
|
|
159
|
+
const previous = sources.slice(0, index).findLast((item) => item.target)?.target;
|
|
160
|
+
const next = sources.slice(index + 1).find((item) => item.target)?.target;
|
|
161
|
+
if (target.index <= (previous?.index ?? -1) || following.index >= (next?.index ?? Number.POSITIVE_INFINITY)) continue;
|
|
162
|
+
if (target.time.start < (previous?.time.start ?? 0) || following.time.start > (next?.time.start ?? Number.POSITIVE_INFINITY)) continue;
|
|
163
|
+
if (!sharesLanguage([
|
|
164
|
+
previous,
|
|
165
|
+
target,
|
|
166
|
+
following,
|
|
167
|
+
next
|
|
168
|
+
])) continue;
|
|
169
|
+
if (target.time.end === void 0 || following.time.end === void 0 || Math.abs(following.time.start - target.time.end) > rounding) continue;
|
|
170
|
+
if (time.end <= target.time.end || time.end > following.time.end + slack) continue;
|
|
171
|
+
if (targets.some((candidate) => candidate !== target && candidate !== following && overlapLength(time, candidate) > slack)) continue;
|
|
172
|
+
source.target = target;
|
|
173
|
+
used.add(target);
|
|
174
|
+
reserved.add(following);
|
|
175
|
+
}
|
|
176
|
+
for (let start = 0; start < sources.length; start += 1) {
|
|
177
|
+
if (sources[start].target) continue;
|
|
178
|
+
let end = start + 1;
|
|
179
|
+
while (end < sources.length && !sources[end].target) end += 1;
|
|
180
|
+
const previous = sources[start - 1]?.target;
|
|
181
|
+
const next = sources[end]?.target;
|
|
182
|
+
const pending = targets.filter((target) => {
|
|
183
|
+
return target.index > (previous?.index ?? -1) && target.index < (next?.index ?? Number.POSITIVE_INFINITY) && !reserved.has(target);
|
|
184
|
+
});
|
|
185
|
+
if (pending.length !== end - start) return false;
|
|
186
|
+
if (sources.slice(start, end).some((source) => !source.time) && pending.length > 1 && (!previous || !next)) return false;
|
|
187
|
+
const boundaries = [
|
|
188
|
+
previous,
|
|
189
|
+
...pending,
|
|
190
|
+
next
|
|
191
|
+
].filter((target) => target !== void 0);
|
|
192
|
+
if (!sharesLanguage(boundaries) || !isOrderedByTime(boundaries)) return false;
|
|
193
|
+
for (const [offset, target] of pending.entries()) {
|
|
194
|
+
const source = sources[start + offset];
|
|
195
|
+
if (used.has(target) || conflict(target)) return false;
|
|
196
|
+
const time = source.time;
|
|
197
|
+
if (time && (overlapLength(time, target) <= 0 || targets.some((candidate) => candidate !== target && overlapLength(time, candidate) > slack))) return false;
|
|
198
|
+
source.target = target;
|
|
199
|
+
used.add(target);
|
|
200
|
+
}
|
|
201
|
+
start = end - 1;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
var readRomanLanguage = (source) => {
|
|
206
|
+
const languages = new Set(source.languages.map(lowerTag).filter(Boolean));
|
|
207
|
+
for (const word of source.content?.words ?? []) {
|
|
208
|
+
const language = word.type === WordType.NORMAL ? lowerTag(word.language) : void 0;
|
|
209
|
+
if (language) languages.add(language);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
language: languages.size === 1 ? languages.values().next().value : lowerTag(source.languages[0]),
|
|
213
|
+
consistent: languages.size <= 1
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
var attachWordRomans = (line, source, tolerance, language, fallback) => {
|
|
217
|
+
const content = line.content;
|
|
218
|
+
const sourceContent = source.line.content;
|
|
219
|
+
if (!content?.words.length || content.text !== void 0 || !sourceContent?.words.length || sourceContent.text !== void 0) return;
|
|
220
|
+
const targets = [];
|
|
221
|
+
let spaces = 0;
|
|
222
|
+
for (const [index, word] of content.words.entries()) {
|
|
223
|
+
if (word.type === WordType.SPACE) {
|
|
224
|
+
spaces += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const time = readWordRange(word);
|
|
228
|
+
if (word.type !== WordType.NORMAL || !time) return;
|
|
229
|
+
targets.push({
|
|
230
|
+
word,
|
|
231
|
+
index,
|
|
232
|
+
time,
|
|
233
|
+
spaces,
|
|
234
|
+
tokens: []
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const sources = [];
|
|
238
|
+
let separator = "";
|
|
239
|
+
for (const word of sourceContent.words) {
|
|
240
|
+
if (word.type === WordType.SPACE) {
|
|
241
|
+
separator += word.text;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const time = readWordRange(word);
|
|
245
|
+
if (word.type !== WordType.NORMAL || !word.text.trim() || word.time && !time || !time && !fallback) return;
|
|
246
|
+
const target = time ? matchRomanTarget(time, targets, tolerance) : void 0;
|
|
247
|
+
if (target === null || !target && !fallback) return;
|
|
248
|
+
sources.push({
|
|
249
|
+
word,
|
|
250
|
+
time,
|
|
251
|
+
separator,
|
|
252
|
+
target
|
|
253
|
+
});
|
|
254
|
+
separator = "";
|
|
255
|
+
}
|
|
256
|
+
const anchors = sources.flatMap((source) => source.target ? [source.target] : []);
|
|
257
|
+
if (anchors.some((target, index) => target.index < (anchors[index - 1]?.index ?? -1))) return;
|
|
258
|
+
const recovered = fallback && sources.some((item) => !item.target);
|
|
259
|
+
if (recovered && !recoverRomanTargets(sources, targets, tolerance, source.rounding, language)) return;
|
|
260
|
+
let previous;
|
|
261
|
+
let previousStart = 0;
|
|
262
|
+
for (const { word, time, separator, target } of sources) {
|
|
263
|
+
if (!target || target.index < (previous?.index ?? -1) || target.word.annotation?.romans.some((item) => lowerTag(item.language) === language)) return;
|
|
264
|
+
const bound = target.time.end ?? Number.POSITIVE_INFINITY;
|
|
265
|
+
const start = time ? Math.min(bound, Math.max(target.time.start, time.start)) : target.time.start;
|
|
266
|
+
const end = time?.end === void 0 ? target.time.end : Math.min(bound, Math.max(target.time.start, time.end));
|
|
267
|
+
if (recovered && start < previousStart) return;
|
|
268
|
+
previousStart = start;
|
|
269
|
+
const token = makeWordAnnotationToken({
|
|
270
|
+
text: previous ? word.text : word.text.trimStart(),
|
|
271
|
+
time: {
|
|
272
|
+
start,
|
|
273
|
+
end
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
if (previous) {
|
|
277
|
+
const last = previous.tokens.at(-1);
|
|
278
|
+
if (target.spaces > previous.spaces) {
|
|
279
|
+
const space = separator.indexOf(" ");
|
|
280
|
+
if (space === -1) return;
|
|
281
|
+
last.text += separator.slice(0, space);
|
|
282
|
+
token.text = separator.slice(space + 1) + token.text;
|
|
283
|
+
} else last.text += separator;
|
|
284
|
+
}
|
|
285
|
+
target.tokens.push(token);
|
|
286
|
+
previous = target;
|
|
287
|
+
}
|
|
288
|
+
if (!previous) return;
|
|
289
|
+
const last = previous.tokens.at(-1);
|
|
290
|
+
last.text = last.text.trimEnd();
|
|
291
|
+
const words = content.words.slice();
|
|
292
|
+
for (const { word, index, tokens } of targets) {
|
|
293
|
+
if (!tokens.length) continue;
|
|
294
|
+
const roman = makeWordAnnotationRoman({
|
|
295
|
+
language,
|
|
296
|
+
tokens
|
|
297
|
+
});
|
|
298
|
+
words[index] = {
|
|
299
|
+
...word,
|
|
300
|
+
annotation: makeWordAnnotation({
|
|
301
|
+
...word.annotation,
|
|
302
|
+
romans: [...word.annotation?.romans ?? [], roman]
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const result = {
|
|
307
|
+
...line,
|
|
308
|
+
content: {
|
|
309
|
+
...content,
|
|
310
|
+
words
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
return deriveLineRomans(result.content).find((item) => lowerTag(item.language) === language)?.text === source.text ? result : void 0;
|
|
314
|
+
};
|
|
315
|
+
var readSources = (lines, options) => {
|
|
316
|
+
const result = [];
|
|
317
|
+
const preserve = options.preservePlaceholders;
|
|
318
|
+
for (const [index, line] of lines.entries()) {
|
|
319
|
+
const start = line.time?.start;
|
|
320
|
+
const text = readText$3(line);
|
|
321
|
+
const timing = options.getTiming?.(line);
|
|
322
|
+
const precision = timing?.precision;
|
|
323
|
+
if (start !== void 0 && (preserve || text) || options.matchByOrder && text) result.push({
|
|
324
|
+
line,
|
|
325
|
+
index,
|
|
326
|
+
text,
|
|
327
|
+
start,
|
|
328
|
+
precision: precision && Number.isFinite(precision) && precision > 0 ? precision : 1,
|
|
329
|
+
rounding: timing?.rounding === 1 ? 1 : 0
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
};
|
|
334
|
+
var createTimeIndex = (lines, options) => {
|
|
335
|
+
const starts = [];
|
|
336
|
+
const lyricRows = [];
|
|
337
|
+
const targets = /* @__PURE__ */ new Map();
|
|
338
|
+
for (const [index, line] of lines.entries()) {
|
|
339
|
+
const start = line.time?.start;
|
|
340
|
+
const valid = start !== void 0 && (!options.preservePlaceholders || Number.isFinite(start));
|
|
341
|
+
starts.push(valid ? start : void 0);
|
|
342
|
+
if (options.matchByOrder && line.type === LineType.NORMAL && readText$3(line)) lyricRows.push(index);
|
|
343
|
+
if (!valid) continue;
|
|
344
|
+
const queue = targets.get(start) ?? [];
|
|
345
|
+
queue.push(index);
|
|
346
|
+
targets.set(start, queue);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
starts,
|
|
350
|
+
lyricRows,
|
|
351
|
+
targets,
|
|
352
|
+
buckets: /* @__PURE__ */ new Map()
|
|
353
|
+
};
|
|
354
|
+
};
|
|
355
|
+
var getTimeBuckets = (index, precision) => {
|
|
356
|
+
const cached = index.buckets.get(precision);
|
|
357
|
+
if (cached) return cached;
|
|
358
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const [target, start] of index.starts.entries()) {
|
|
360
|
+
if (start === void 0 || !Number.isFinite(start)) continue;
|
|
361
|
+
const key = Math.floor(start / precision);
|
|
362
|
+
let bucket = buckets.get(key);
|
|
363
|
+
if (!bucket) {
|
|
364
|
+
bucket = {
|
|
365
|
+
start,
|
|
366
|
+
indices: [],
|
|
367
|
+
mixed: false
|
|
368
|
+
};
|
|
369
|
+
buckets.set(key, bucket);
|
|
370
|
+
}
|
|
371
|
+
bucket.indices.push(target);
|
|
372
|
+
bucket.mixed ||= bucket.start !== start;
|
|
373
|
+
}
|
|
374
|
+
index.buckets.set(precision, buckets);
|
|
375
|
+
return buckets;
|
|
376
|
+
};
|
|
377
|
+
var matchDuplicates = (start, count, index, groups) => {
|
|
378
|
+
const result = [];
|
|
379
|
+
const collect = (precision, key, roundingOnly) => {
|
|
380
|
+
const queue = groups.get(precision)?.get(key);
|
|
381
|
+
if (!queue) return true;
|
|
382
|
+
const bucket = getTimeBuckets(index, precision).get(key);
|
|
383
|
+
if (bucket && (bucket.mixed || bucket.start !== start)) return roundingOnly && queue.every((source) => source.rounding !== 1);
|
|
384
|
+
for (const source of queue) {
|
|
385
|
+
const sourceStart = source.start;
|
|
386
|
+
const rounded = source.rounding === 1;
|
|
387
|
+
if (roundingOnly && !rounded) continue;
|
|
388
|
+
if (rounded && (sourceStart - 1 !== start && index.targets.has(sourceStart - 1) || sourceStart + 1 !== start && index.targets.has(sourceStart + 1))) return false;
|
|
389
|
+
result.push(source);
|
|
390
|
+
if (result.length > count) return false;
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
};
|
|
394
|
+
for (const precision of groups.keys()) if (!collect(precision, Math.floor(start / precision), false)) return;
|
|
395
|
+
for (const neighbor of [start - 1, start + 1]) if (neighbor !== start && !collect(1, Math.floor(neighbor), true)) return;
|
|
396
|
+
return result;
|
|
397
|
+
};
|
|
398
|
+
var matchAnnotations = (index, sources, options) => {
|
|
19
399
|
const matched = /* @__PURE__ */ new Map();
|
|
20
|
-
if (
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
for (const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
400
|
+
if (!sources.length) return matched;
|
|
401
|
+
const preserve = options.preservePlaceholders;
|
|
402
|
+
const byStart = /* @__PURE__ */ new Map();
|
|
403
|
+
for (const source of sources) {
|
|
404
|
+
const start = source.start;
|
|
405
|
+
if (start === void 0 || preserve && !Number.isFinite(start)) continue;
|
|
406
|
+
const queue = byStart.get(start) ?? [];
|
|
407
|
+
queue.push(source);
|
|
408
|
+
byStart.set(start, queue);
|
|
28
409
|
}
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
410
|
+
const usedSources = /* @__PURE__ */ new Set();
|
|
411
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
412
|
+
const assign = (target, source) => {
|
|
413
|
+
matched.set(target, source);
|
|
414
|
+
usedSources.add(source);
|
|
415
|
+
};
|
|
416
|
+
for (const [start, queue] of index.targets) {
|
|
417
|
+
const candidates = byStart.get(start);
|
|
418
|
+
if (!candidates) continue;
|
|
419
|
+
if (queue.length > 1) {
|
|
420
|
+
if (!preserve || candidates.length === queue.length) {
|
|
421
|
+
const count = Math.min(queue.length, candidates.length);
|
|
422
|
+
for (let position = 0; position < count; position += 1) assign(queue[position], candidates[position]);
|
|
423
|
+
}
|
|
37
424
|
continue;
|
|
38
425
|
}
|
|
39
|
-
|
|
40
|
-
|
|
426
|
+
let source = candidates[0];
|
|
427
|
+
for (const candidate of candidates) if (candidate.precision < source.precision) source = candidate;
|
|
428
|
+
assign(queue[0], source);
|
|
41
429
|
}
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
430
|
+
if (usedSources.size === sources.length) return matched;
|
|
431
|
+
const readable = options.matchByOrder ? sources.filter((source) => source.text) : [];
|
|
432
|
+
if (readable.length && readable.length === index.lyricRows.length) {
|
|
433
|
+
if (readable.every((source, position) => {
|
|
434
|
+
const target = index.lyricRows[position];
|
|
435
|
+
return !matched.has(target) && !usedSources.has(source) || matched.get(target) === source;
|
|
436
|
+
})) {
|
|
437
|
+
for (const [position, source] of readable.entries()) if (!usedSources.has(source)) assign(index.lyricRows[position], source);
|
|
438
|
+
return matched;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (preserve || options.getTiming) {
|
|
442
|
+
const groups = /* @__PURE__ */ new Map();
|
|
443
|
+
for (const source of sources) {
|
|
444
|
+
const start = source.start;
|
|
445
|
+
if (usedSources.has(source) || start === void 0 || !Number.isFinite(start)) continue;
|
|
446
|
+
const precision = source.precision;
|
|
447
|
+
let group = groups.get(precision);
|
|
448
|
+
if (!group) {
|
|
449
|
+
group = /* @__PURE__ */ new Map();
|
|
450
|
+
groups.set(precision, group);
|
|
451
|
+
}
|
|
452
|
+
const key = Math.floor(start / precision);
|
|
453
|
+
const queue = group.get(key) ?? [];
|
|
454
|
+
queue.push(source);
|
|
455
|
+
group.set(key, queue);
|
|
456
|
+
}
|
|
457
|
+
for (const [start, queue] of index.targets) {
|
|
458
|
+
if (queue.length < 2 || matched.has(queue[0])) continue;
|
|
459
|
+
const candidates = matchDuplicates(start, queue.length, index, groups);
|
|
460
|
+
if (!candidates || candidates.length !== queue.length) continue;
|
|
461
|
+
candidates.sort((first, second) => first.index - second.index);
|
|
462
|
+
for (const [position, source] of candidates.entries()) assign(queue[position], source);
|
|
463
|
+
}
|
|
464
|
+
for (const precision of [...groups.keys()].sort((first, second) => first - second)) {
|
|
465
|
+
const candidates = getTimeBuckets(index, precision);
|
|
466
|
+
for (const [key, entries] of groups.get(precision)) {
|
|
467
|
+
const bucket = candidates.get(key);
|
|
468
|
+
if (!bucket) continue;
|
|
469
|
+
const sourceQueue = entries.filter((source) => !usedSources.has(source));
|
|
470
|
+
const targetQueue = bucket.indices.filter((target) => !matched.has(target));
|
|
471
|
+
if (!sourceQueue.length || !targetQueue.length) continue;
|
|
472
|
+
if ((precision > 1 || targetQueue.length > 1) && sourceQueue.length !== targetQueue.length) {
|
|
473
|
+
for (const source of sourceQueue) ambiguous.add(source);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const count = Math.min(sourceQueue.length, targetQueue.length);
|
|
477
|
+
for (let position = 0; position < count; position += 1) assign(targetQueue[position], sourceQueue[position]);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const rounding = /* @__PURE__ */ new Map();
|
|
481
|
+
const claims = /* @__PURE__ */ new Map();
|
|
482
|
+
for (const source of sources) {
|
|
483
|
+
const start = source.start;
|
|
484
|
+
if (source.rounding !== 1 || usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
|
|
485
|
+
let target = -1;
|
|
486
|
+
let count = 0;
|
|
487
|
+
for (const neighbor of [start - 1, start + 1]) for (const candidate of index.targets.get(neighbor) ?? []) {
|
|
488
|
+
if (matched.has(candidate)) continue;
|
|
489
|
+
target = candidate;
|
|
490
|
+
count += 1;
|
|
491
|
+
claims.set(candidate, (claims.get(candidate) ?? 0) + 1);
|
|
492
|
+
}
|
|
493
|
+
if (count === 1) rounding.set(source, target);
|
|
494
|
+
else if (count > 1) ambiguous.add(source);
|
|
495
|
+
}
|
|
496
|
+
for (const [source, target] of rounding) if (claims.get(target) === 1) assign(target, source);
|
|
497
|
+
else ambiguous.add(source);
|
|
498
|
+
}
|
|
499
|
+
const tolerance = options.tolerance ?? 0;
|
|
500
|
+
if (!Number.isFinite(tolerance) || tolerance <= 0) return matched;
|
|
501
|
+
const ordered = index.ordered ??= [...index.targets.keys()].filter(Number.isFinite).sort((first, second) => first - second);
|
|
502
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
503
|
+
for (const source of sources) {
|
|
504
|
+
const start = source.start;
|
|
505
|
+
if (usedSources.has(source) || ambiguous.has(source) || start === void 0 || !Number.isFinite(start)) continue;
|
|
46
506
|
let target = -1;
|
|
47
507
|
let distance = Number.POSITIVE_INFINITY;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
508
|
+
const end = start + tolerance;
|
|
509
|
+
for (let position = lowerBound(ordered, start - tolerance); position < ordered.length && ordered[position] <= end; position += 1) {
|
|
510
|
+
const targetStart = ordered[position];
|
|
511
|
+
const current = Math.abs(targetStart - start);
|
|
512
|
+
if (current > tolerance || current > distance) continue;
|
|
513
|
+
const queue = index.targets.get(targetStart);
|
|
514
|
+
let cursor = cursors.get(targetStart) ?? 0;
|
|
515
|
+
while (cursor < queue.length && matched.has(queue[cursor])) cursor += 1;
|
|
516
|
+
cursors.set(targetStart, cursor);
|
|
517
|
+
const candidate = queue[cursor];
|
|
518
|
+
if (candidate !== void 0 && (current < distance || current === distance && candidate < target)) {
|
|
519
|
+
target = candidate;
|
|
53
520
|
distance = current;
|
|
54
521
|
}
|
|
55
522
|
}
|
|
56
|
-
if (target !== -1)
|
|
57
|
-
matched.set(target, annotation);
|
|
58
|
-
used.add(target);
|
|
59
|
-
}
|
|
523
|
+
if (target !== -1) assign(target, source);
|
|
60
524
|
}
|
|
61
525
|
return matched;
|
|
62
526
|
};
|
|
527
|
+
var attachTracks = (lines, sources, options, wordTolerance) => {
|
|
528
|
+
if (!lines.length || !sources.translations?.length && !sources.romans?.length) return lines;
|
|
529
|
+
const index = createTimeIndex(lines, options);
|
|
530
|
+
let result = lines;
|
|
531
|
+
for (const kind of ["translations", "romans"]) {
|
|
532
|
+
const matches = matchAnnotations(index, readSources(sources[kind] ?? [], options), options);
|
|
533
|
+
for (const [target, source] of matches) {
|
|
534
|
+
const line = result[target];
|
|
535
|
+
if (line.type === LineType.INSTRUMENTAL || !source.text) continue;
|
|
536
|
+
const romanLanguage = kind === "romans" && wordTolerance !== void 0 ? readRomanLanguage(source.line) : void 0;
|
|
537
|
+
let annotated = romanLanguage?.consistent && wordTolerance !== void 0 ? attachWordRomans(line, source, wordTolerance, romanLanguage.language, options.wordFallback === true) : void 0;
|
|
538
|
+
if (!annotated) {
|
|
539
|
+
const language = romanLanguage ? romanLanguage.language : source.line.languages[0];
|
|
540
|
+
const create = kind === "romans" ? makeLineAnnotationRoman : makeLineAnnotationTranslation;
|
|
541
|
+
const annotation = makeLineAnnotation({
|
|
542
|
+
...line.annotation,
|
|
543
|
+
[kind]: [...line.annotation?.[kind] ?? [], create({
|
|
544
|
+
text: source.text,
|
|
545
|
+
language
|
|
546
|
+
})]
|
|
547
|
+
});
|
|
548
|
+
annotated = {
|
|
549
|
+
...line,
|
|
550
|
+
annotation
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (result === lines) result = lines.slice();
|
|
554
|
+
result[target] = annotated;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
558
|
+
};
|
|
63
559
|
/**
|
|
64
|
-
* Attaches
|
|
560
|
+
* Attaches complete roman tracks to words using timing and optional local recovery, retaining the source line when matching is incomplete.
|
|
561
|
+
* Translations remain line-level, and successful word tracks are not duplicated on the line.
|
|
65
562
|
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const tolerance = Number.isFinite(options.tolerance) ? Math.max(0, options.tolerance ?? 0) : 0;
|
|
71
|
-
const translations = matchEntries(lines, (sources.translations ?? []).filter(isReadable), tolerance);
|
|
72
|
-
const romans = matchEntries(lines, (sources.romans ?? []).filter(isReadable), tolerance);
|
|
73
|
-
if (translations.size === 0 && romans.size === 0) return lines;
|
|
74
|
-
return lines.map((line, index) => {
|
|
75
|
-
const translation = translations.get(index);
|
|
76
|
-
const roman = romans.get(index);
|
|
77
|
-
if (line.type === LineType.INSTRUMENTAL || translation === void 0 && roman === void 0) return line;
|
|
78
|
-
return {
|
|
79
|
-
...line,
|
|
80
|
-
annotation: makeLineAnnotation({
|
|
81
|
-
romans: [...line.annotation?.romans ?? [], ...roman === void 0 ? [] : [makeLineAnnotationRoman({
|
|
82
|
-
text: readText$1(roman),
|
|
83
|
-
language: roman.languages[0]
|
|
84
|
-
})]],
|
|
85
|
-
translations: [...line.annotation?.translations ?? [], ...translation === void 0 ? [] : [makeLineAnnotationTranslation({
|
|
86
|
-
text: readText$1(translation),
|
|
87
|
-
language: translation.languages[0]
|
|
88
|
-
})]]
|
|
89
|
-
})
|
|
90
|
-
};
|
|
91
|
-
});
|
|
563
|
+
* Match before final normalization; original text and word timing are preserved.
|
|
564
|
+
*/
|
|
565
|
+
var attachAnnotations = (lines, sources, options = {}) => {
|
|
566
|
+
return attachTracks(lines, sources, options, Number.isFinite(options.wordTolerance) ? Math.max(0, options.wordTolerance ?? 0) : 0);
|
|
92
567
|
};
|
|
93
568
|
//#endregion
|
|
94
|
-
//#region src/utils/
|
|
569
|
+
//#region src/utils/encoding.ts
|
|
95
570
|
/**
|
|
96
|
-
*
|
|
571
|
+
* Decodes canonical standard base64 into bytes, identically in browsers and Node.
|
|
572
|
+
* atob decides what is decodable; the round-trip re-encode keeps the result canonical, so slacker forms stay undecoded.
|
|
97
573
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
574
|
+
* @param value Base64 text; whitespace, URL-safe alphabets, and misplaced padding are rejected.
|
|
575
|
+
*
|
|
576
|
+
* @returns The decoded bytes, or null when the input is not canonical base64.
|
|
577
|
+
*/
|
|
578
|
+
var decodeBase64 = (value) => {
|
|
579
|
+
if (!value) return null;
|
|
580
|
+
try {
|
|
581
|
+
const binary = atob(value);
|
|
582
|
+
if (btoa(binary).replace(/=+$/, "") !== value.replace(/=+$/, "")) return null;
|
|
583
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
103
587
|
};
|
|
104
588
|
//#endregion
|
|
105
589
|
//#region src/utils/time.ts
|
|
106
|
-
var CLOCK_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
|
|
590
|
+
var CLOCK_PATTERN$1 = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
|
|
107
591
|
var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
|
|
108
592
|
/**
|
|
109
593
|
* Reads a clock value, where the fraction is padded or truncated to millisecond precision.
|
|
@@ -112,7 +596,7 @@ var OFFSET_PATTERN = /^(\d+(?:\.\d+)?)(ms|h|m|s)$/iu;
|
|
|
112
596
|
*/
|
|
113
597
|
var parseClock = (value) => {
|
|
114
598
|
if (!value.includes(":") && !value.includes(".")) return;
|
|
115
|
-
const match = CLOCK_PATTERN.exec(value);
|
|
599
|
+
const match = CLOCK_PATTERN$1.exec(value);
|
|
116
600
|
if (!match) return;
|
|
117
601
|
const hour = Number(match[1] ?? 0);
|
|
118
602
|
const minute = Number(match[2] ?? 0);
|
|
@@ -194,7 +678,7 @@ var prefixedNames = /* @__PURE__ */ new Map();
|
|
|
194
678
|
*/
|
|
195
679
|
var splitXmlName = (name) => {
|
|
196
680
|
const cached = prefixedNames.get(name);
|
|
197
|
-
if (cached
|
|
681
|
+
if (cached) return cached;
|
|
198
682
|
const colon = name.indexOf(":");
|
|
199
683
|
const split = colon === -1 ? {
|
|
200
684
|
namespacePrefix: "",
|
|
@@ -203,7 +687,7 @@ var splitXmlName = (name) => {
|
|
|
203
687
|
namespacePrefix: name.slice(0, colon),
|
|
204
688
|
localName: name.slice(colon + 1)
|
|
205
689
|
};
|
|
206
|
-
if (split.namespacePrefix
|
|
690
|
+
if (split.namespacePrefix && prefixedNames.size < 1024) prefixedNames.set(name, split);
|
|
207
691
|
return split;
|
|
208
692
|
};
|
|
209
693
|
/**
|
|
@@ -265,7 +749,7 @@ var getXmlAttribute = (element, localName) => {
|
|
|
265
749
|
const attributes = element.attributes;
|
|
266
750
|
for (let index = 0; index < attributes.length; index += 1) {
|
|
267
751
|
const attribute = attributes[index];
|
|
268
|
-
if (attribute
|
|
752
|
+
if (attribute && attribute.localName === localName) return attribute.value;
|
|
269
753
|
}
|
|
270
754
|
};
|
|
271
755
|
/**
|
|
@@ -275,7 +759,7 @@ var getXmlQualifiedAttribute = (element, qualifiedName) => {
|
|
|
275
759
|
const attributes = element.attributes;
|
|
276
760
|
for (let index = 0; index < attributes.length; index += 1) {
|
|
277
761
|
const attribute = attributes[index];
|
|
278
|
-
if (attribute
|
|
762
|
+
if (attribute && attribute.qualifiedName === qualifiedName) return attribute.value;
|
|
279
763
|
}
|
|
280
764
|
};
|
|
281
765
|
/**
|
|
@@ -286,7 +770,7 @@ var getXmlChildElements = (element, localName) => {
|
|
|
286
770
|
const children = element.children;
|
|
287
771
|
for (let index = 0; index < children.length; index += 1) {
|
|
288
772
|
const child = children[index];
|
|
289
|
-
if (child
|
|
773
|
+
if (child && child.nodeType === XmlNodeType.element && child.localName === localName) result.push(child);
|
|
290
774
|
}
|
|
291
775
|
return result;
|
|
292
776
|
};
|
|
@@ -297,7 +781,7 @@ var hasXmlChildElement = (element, localName) => {
|
|
|
297
781
|
const children = element.children;
|
|
298
782
|
for (let index = 0; index < children.length; index += 1) {
|
|
299
783
|
const child = children[index];
|
|
300
|
-
if (child
|
|
784
|
+
if (child && child.nodeType === XmlNodeType.element && child.localName === localName) return true;
|
|
301
785
|
}
|
|
302
786
|
return false;
|
|
303
787
|
};
|
|
@@ -306,13 +790,13 @@ var hasXmlChildElement = (element, localName) => {
|
|
|
306
790
|
*/
|
|
307
791
|
var findXmlElement = (element, localName) => {
|
|
308
792
|
const stack = [element];
|
|
309
|
-
while (stack.length
|
|
793
|
+
while (stack.length) {
|
|
310
794
|
const node = stack.pop();
|
|
311
|
-
if (node
|
|
795
|
+
if (!node || node.nodeType !== XmlNodeType.element) continue;
|
|
312
796
|
if (node.localName === localName) return node;
|
|
313
797
|
for (let index = node.children.length - 1; index >= 0; index -= 1) {
|
|
314
798
|
const child = node.children[index];
|
|
315
|
-
if (child
|
|
799
|
+
if (child) stack.push(child);
|
|
316
800
|
}
|
|
317
801
|
}
|
|
318
802
|
};
|
|
@@ -321,18 +805,18 @@ var findXmlElement = (element, localName) => {
|
|
|
321
805
|
*/
|
|
322
806
|
var getXmlTextContent = (node) => {
|
|
323
807
|
if (node.nodeType !== XmlNodeType.element) return node.text;
|
|
324
|
-
if (node.children.length
|
|
808
|
+
if (!node.children.length) return "";
|
|
325
809
|
const child = node.children[0];
|
|
326
|
-
if (node.children.length === 1 && child
|
|
810
|
+
if (node.children.length === 1 && child && child.nodeType !== XmlNodeType.element) return child.text;
|
|
327
811
|
const parts = [];
|
|
328
812
|
const stack = [node];
|
|
329
|
-
while (stack.length
|
|
813
|
+
while (stack.length) {
|
|
330
814
|
const current = stack.pop();
|
|
331
|
-
if (current
|
|
815
|
+
if (!current) continue;
|
|
332
816
|
if (current.nodeType === XmlNodeType.element) {
|
|
333
817
|
for (let index = current.children.length - 1; index >= 0; index -= 1) {
|
|
334
818
|
const child = current.children[index];
|
|
335
|
-
if (child
|
|
819
|
+
if (child) stack.push(child);
|
|
336
820
|
}
|
|
337
821
|
continue;
|
|
338
822
|
}
|
|
@@ -400,7 +884,7 @@ var decodeText = (text) => {
|
|
|
400
884
|
cursor = end + 1;
|
|
401
885
|
start = cursor;
|
|
402
886
|
}
|
|
403
|
-
if (parts.length
|
|
887
|
+
if (!parts.length) return text;
|
|
404
888
|
parts.push(text.slice(start));
|
|
405
889
|
return parts.join("");
|
|
406
890
|
};
|
|
@@ -409,7 +893,7 @@ var decodeText = (text) => {
|
|
|
409
893
|
*/
|
|
410
894
|
var parseXml = (source) => {
|
|
411
895
|
const length = source.length;
|
|
412
|
-
let position = length
|
|
896
|
+
let position = length && source.charCodeAt(0) === BYTE_ORDER_MARK ? 1 : 0;
|
|
413
897
|
let valid = true;
|
|
414
898
|
const stack = [];
|
|
415
899
|
const root = {
|
|
@@ -429,7 +913,7 @@ var parseXml = (source) => {
|
|
|
429
913
|
position = end;
|
|
430
914
|
if (start === end) return;
|
|
431
915
|
const parent = stack[stack.length - 1];
|
|
432
|
-
if (parent
|
|
916
|
+
if (!parent) {
|
|
433
917
|
valid = false;
|
|
434
918
|
return;
|
|
435
919
|
}
|
|
@@ -544,7 +1028,7 @@ var parseXml = (source) => {
|
|
|
544
1028
|
}
|
|
545
1029
|
position = cursor;
|
|
546
1030
|
const parent = stack[stack.length - 1];
|
|
547
|
-
if (parent
|
|
1031
|
+
if (!parent) {
|
|
548
1032
|
valid = false;
|
|
549
1033
|
return;
|
|
550
1034
|
}
|
|
@@ -574,7 +1058,7 @@ var parseXml = (source) => {
|
|
|
574
1058
|
return;
|
|
575
1059
|
}
|
|
576
1060
|
const top = stack[stack.length - 1];
|
|
577
|
-
if (top
|
|
1061
|
+
if (!top || top.qualifiedName !== name) {
|
|
578
1062
|
valid = false;
|
|
579
1063
|
return;
|
|
580
1064
|
}
|
|
@@ -601,7 +1085,7 @@ var parseXml = (source) => {
|
|
|
601
1085
|
return;
|
|
602
1086
|
}
|
|
603
1087
|
const parent = stack[stack.length - 1];
|
|
604
|
-
if (parent
|
|
1088
|
+
if (!parent) {
|
|
605
1089
|
valid = false;
|
|
606
1090
|
return;
|
|
607
1091
|
}
|
|
@@ -706,7 +1190,7 @@ var escapeXmlText = (text, chunks) => {
|
|
|
706
1190
|
}
|
|
707
1191
|
} else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
|
|
708
1192
|
else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
|
|
709
|
-
if (escaped
|
|
1193
|
+
if (!escaped) continue;
|
|
710
1194
|
if (start < index) chunks.push(text.slice(start, index));
|
|
711
1195
|
chunks.push(escaped);
|
|
712
1196
|
start = index + 1;
|
|
@@ -742,7 +1226,7 @@ var escapeXmlAttribute = (value, chunks) => {
|
|
|
742
1226
|
}
|
|
743
1227
|
} else if (code >= 56320 && code <= 57343) escaped = REPLACEMENT_CHARACTER;
|
|
744
1228
|
else if (!isAllowedCodePoint(code)) escaped = REPLACEMENT_CHARACTER;
|
|
745
|
-
if (escaped
|
|
1229
|
+
if (!escaped) continue;
|
|
746
1230
|
if (start < index) chunks.push(value.slice(start, index));
|
|
747
1231
|
chunks.push(escaped);
|
|
748
1232
|
start = index + 1;
|
|
@@ -762,7 +1246,7 @@ var hasOnlyElements = (element) => {
|
|
|
762
1246
|
const children = element.children;
|
|
763
1247
|
for (let index = 0; index < children.length; index += 1) {
|
|
764
1248
|
const child = children[index];
|
|
765
|
-
if (child
|
|
1249
|
+
if (child && child.nodeType !== XmlNodeType.element) return false;
|
|
766
1250
|
}
|
|
767
1251
|
return true;
|
|
768
1252
|
};
|
|
@@ -782,13 +1266,13 @@ var writeXmlNode = (node, chunks, depth, indent) => {
|
|
|
782
1266
|
chunks.push("<", node.qualifiedName);
|
|
783
1267
|
for (let index = 0; index < node.attributes.length; index += 1) {
|
|
784
1268
|
const attribute = node.attributes[index];
|
|
785
|
-
if (attribute
|
|
1269
|
+
if (!attribute) continue;
|
|
786
1270
|
chunks.push(" ", attribute.qualifiedName, "=\"");
|
|
787
1271
|
escapeXmlAttribute(attribute.value, chunks);
|
|
788
1272
|
chunks.push("\"");
|
|
789
1273
|
}
|
|
790
1274
|
const children = node.children;
|
|
791
|
-
if (children.length
|
|
1275
|
+
if (!children.length) {
|
|
792
1276
|
chunks.push("/>");
|
|
793
1277
|
return;
|
|
794
1278
|
}
|
|
@@ -796,7 +1280,7 @@ var writeXmlNode = (node, chunks, depth, indent) => {
|
|
|
796
1280
|
const childIndent = indent !== void 0 && hasOnlyElements(node) ? indent : void 0;
|
|
797
1281
|
for (let index = 0; index < children.length; index += 1) {
|
|
798
1282
|
const child = children[index];
|
|
799
|
-
if (child
|
|
1283
|
+
if (child) writeXmlNode(child, chunks, depth + 1, childIndent);
|
|
800
1284
|
}
|
|
801
1285
|
if (childIndent !== void 0) chunks.push("\n", childIndent.repeat(depth));
|
|
802
1286
|
chunks.push("</", node.qualifiedName, ">");
|
|
@@ -809,20 +1293,94 @@ var writeXmlNode = (node, chunks, depth, indent) => {
|
|
|
809
1293
|
var serializeXml = (root, options = {}) => {
|
|
810
1294
|
const chunks = [];
|
|
811
1295
|
if (options.declaration ?? true) chunks.push(XML_DECLARATION);
|
|
812
|
-
const indent = options.indent
|
|
1296
|
+
const indent = options.indent ? options.indentText ?? DEFAULT_INDENT : void 0;
|
|
813
1297
|
if (indent !== void 0) chunks.push("\n");
|
|
814
1298
|
if (root.qualifiedName === ROOT_NAME) for (let index = 0; index < root.children.length; index += 1) {
|
|
815
1299
|
const child = root.children[index];
|
|
816
|
-
if (child
|
|
1300
|
+
if (child) writeXmlNode(child, chunks, 0, indent);
|
|
817
1301
|
}
|
|
818
1302
|
else writeXmlNode(root, chunks, 0, indent);
|
|
819
1303
|
return chunks.join("");
|
|
820
1304
|
};
|
|
821
1305
|
//#endregion
|
|
1306
|
+
//#region src/formats/qrc/common.ts
|
|
1307
|
+
var LINE_PATTERN$2 = /^\[(\d+),(\d+)\]/;
|
|
1308
|
+
var XML_ROOT_NAMES = [
|
|
1309
|
+
"QrcInfos",
|
|
1310
|
+
"LyricInfo",
|
|
1311
|
+
"Lyric_1"
|
|
1312
|
+
];
|
|
1313
|
+
var XML_PATTERN = /^\s*</;
|
|
1314
|
+
var TIMED_LINE_PATTERN = /(?:^|[\r\n])[^\S\r\n]*\[\d+,\d+\]/;
|
|
1315
|
+
/**
|
|
1316
|
+
* Unwraps supported QRC XML while keeping plaintext literal.
|
|
1317
|
+
*
|
|
1318
|
+
* XML entities are decoded once by the XML reader.
|
|
1319
|
+
*
|
|
1320
|
+
* @returns The payload, or null when XML is malformed or has no supported lyric content.
|
|
1321
|
+
*/
|
|
1322
|
+
var readText$2 = (source) => {
|
|
1323
|
+
if (!XML_PATTERN.test(source)) return source;
|
|
1324
|
+
if (!XML_ROOT_NAMES.some((name) => source.includes(name))) return null;
|
|
1325
|
+
const document = parseXml(source);
|
|
1326
|
+
if (!document) return null;
|
|
1327
|
+
const root = document.children.find((node) => node.nodeType === XmlNodeType.element);
|
|
1328
|
+
if (!root || !XML_ROOT_NAMES.includes(root.localName)) return null;
|
|
1329
|
+
const pending = [root];
|
|
1330
|
+
while (pending.length) {
|
|
1331
|
+
const element = pending.pop();
|
|
1332
|
+
const content = getXmlAttribute(element, "LyricContent");
|
|
1333
|
+
if (content !== void 0) return content;
|
|
1334
|
+
for (let index = element.children.length - 1; index >= 0; index -= 1) {
|
|
1335
|
+
const child = element.children[index];
|
|
1336
|
+
if (child.nodeType === XmlNodeType.element) pending.push(child);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
};
|
|
1341
|
+
/**
|
|
1342
|
+
* Validates the original payload and keeps auxiliary tracks separate so each can use its own format.
|
|
1343
|
+
*
|
|
1344
|
+
* @returns Normalized input, or null when the original content cannot be read.
|
|
1345
|
+
*/
|
|
1346
|
+
var readContent = (input) => {
|
|
1347
|
+
const value = typeof input === "string" ? { original: input } : input;
|
|
1348
|
+
if (!value || typeof value !== "object") return null;
|
|
1349
|
+
const source = value;
|
|
1350
|
+
if (typeof source.original !== "string") return null;
|
|
1351
|
+
const original = readText$2(source.original);
|
|
1352
|
+
if (original === null) return null;
|
|
1353
|
+
return {
|
|
1354
|
+
original,
|
|
1355
|
+
translations: typeof source.translations === "string" ? source.translations : void 0,
|
|
1356
|
+
romans: typeof source.romans === "string" ? source.romans : void 0
|
|
1357
|
+
};
|
|
1358
|
+
};
|
|
1359
|
+
/**
|
|
1360
|
+
* Requires a timed QRC row so metadata alone cannot select the format.
|
|
1361
|
+
*/
|
|
1362
|
+
var hasTimedLines = (text) => {
|
|
1363
|
+
return TIMED_LINE_PATTERN.test(text);
|
|
1364
|
+
};
|
|
1365
|
+
//#endregion
|
|
1366
|
+
//#region src/formats/qrc/detect.ts
|
|
1367
|
+
/**
|
|
1368
|
+
* Detects QRC from the original track so annotations cannot change format selection.
|
|
1369
|
+
*
|
|
1370
|
+
* @returns Whether plaintext or a supported XML payload contains a timed QRC row.
|
|
1371
|
+
*/
|
|
1372
|
+
var detectQrc = (input) => {
|
|
1373
|
+
const content = readContent(input);
|
|
1374
|
+
return !!content && hasTimedLines(content.original);
|
|
1375
|
+
};
|
|
1376
|
+
//#endregion
|
|
822
1377
|
//#region src/formats/lrc/common.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* Accepts plain lyrics or separate tracks while rejecting an unreadable original payload.
|
|
1380
|
+
*/
|
|
823
1381
|
var readLrcContent = (input) => {
|
|
824
1382
|
if (typeof input === "string") return { original: input };
|
|
825
|
-
if (typeof input !== "object"
|
|
1383
|
+
if (!input || typeof input !== "object") return null;
|
|
826
1384
|
const source = input;
|
|
827
1385
|
if (typeof source.original !== "string") return null;
|
|
828
1386
|
return {
|
|
@@ -871,34 +1429,42 @@ var parseWordTime = (body, lineStart) => {
|
|
|
871
1429
|
}
|
|
872
1430
|
};
|
|
873
1431
|
/**
|
|
874
|
-
*
|
|
875
|
-
*
|
|
1432
|
+
* Splits recognized tags from their following text while preserving literal content and empty tagged segments.
|
|
1433
|
+
*
|
|
1434
|
+
* @param pattern A global pattern whose first capture is the tag body.
|
|
1435
|
+
* @param accepts Optional format-specific tag validation.
|
|
1436
|
+
*
|
|
1437
|
+
* @returns Source pieces in their original order, including an untagged prefix when present.
|
|
876
1438
|
*/
|
|
877
|
-
var
|
|
1439
|
+
var scanWordTags = (content, pattern, accepts) => {
|
|
878
1440
|
const pieces = [];
|
|
879
1441
|
let tag;
|
|
880
1442
|
let cursor = 0;
|
|
881
|
-
for (const match of content.matchAll(
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
1443
|
+
for (const match of content.matchAll(pattern)) {
|
|
1444
|
+
const nextTag = match[1] ?? "";
|
|
1445
|
+
if (accepts && !accepts(nextTag)) continue;
|
|
1446
|
+
const before = content.slice(cursor, match.index);
|
|
1447
|
+
if (before || tag !== void 0) pieces.push({
|
|
885
1448
|
text: before,
|
|
886
1449
|
tag
|
|
887
1450
|
});
|
|
888
|
-
tag =
|
|
889
|
-
cursor =
|
|
1451
|
+
tag = nextTag;
|
|
1452
|
+
cursor = match.index + match[0].length;
|
|
890
1453
|
}
|
|
891
1454
|
const tail = content.slice(cursor);
|
|
892
|
-
if (tail) pieces.push({
|
|
1455
|
+
if (tail || tag !== void 0) pieces.push({
|
|
893
1456
|
text: tail,
|
|
894
1457
|
tag
|
|
895
1458
|
});
|
|
896
|
-
else if (tag !== void 0) pieces.push({
|
|
897
|
-
text: "",
|
|
898
|
-
tag
|
|
899
|
-
});
|
|
900
1459
|
return pieces;
|
|
901
1460
|
};
|
|
1461
|
+
/**
|
|
1462
|
+
* Untagged leading text is preserved; empty timed pieces retain explicit segment boundaries.
|
|
1463
|
+
* Only recognized time tags split content; other angle-bracket text remains literal.
|
|
1464
|
+
*/
|
|
1465
|
+
var splitWordTags = (content) => {
|
|
1466
|
+
return scanWordTags(content, WORD_TAG_PATTERN, (tag) => !!parseWordTime(tag, 0));
|
|
1467
|
+
};
|
|
902
1468
|
//#endregion
|
|
903
1469
|
//#region src/formats/lrc/detect.ts
|
|
904
1470
|
/**
|
|
@@ -932,25 +1498,48 @@ var renderWordTag = (start, end, options) => {
|
|
|
932
1498
|
return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`;
|
|
933
1499
|
};
|
|
934
1500
|
/**
|
|
935
|
-
* Plain text joins words as written, including the separators stored as space words.
|
|
936
|
-
*/
|
|
937
|
-
var renderPlainText = (line) => {
|
|
938
|
-
const content = line.content;
|
|
939
|
-
if (!content) return "";
|
|
940
|
-
if (content.text !== void 0) return content.text;
|
|
941
|
-
return content.words.map((word) => word.text).join("");
|
|
942
|
-
};
|
|
943
|
-
/**
|
|
944
1501
|
* Word-level lines interleave tags with their text; untimed words stay untagged.
|
|
945
1502
|
*/
|
|
946
1503
|
var renderSyllableText = (line, options) => {
|
|
947
1504
|
const content = line.content;
|
|
948
|
-
if (!content?.words.length) return
|
|
1505
|
+
if (!content?.words.length) return getContentText(content);
|
|
949
1506
|
return content.words.map((word) => {
|
|
950
1507
|
if (word.type === WordType.SPACE) return word.text;
|
|
951
1508
|
const start = word.time?.start;
|
|
952
1509
|
if (start === void 0) return word.text;
|
|
953
|
-
|
|
1510
|
+
const end = word.time?.end;
|
|
1511
|
+
const closing = options.wordTiming === "angle" && options.includeWordEnd && end === start ? renderWordTag(end, void 0, options) : "";
|
|
1512
|
+
return `${renderWordTag(start, end, options)}${word.text}${closing}`;
|
|
1513
|
+
}).join("");
|
|
1514
|
+
};
|
|
1515
|
+
var renderWordRomans = (line, language, options) => {
|
|
1516
|
+
const pieces = [];
|
|
1517
|
+
let spaced = false;
|
|
1518
|
+
for (const word of line.content?.words ?? []) {
|
|
1519
|
+
if (word.type === WordType.SPACE) {
|
|
1520
|
+
spaced = true;
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
const roman = word.annotation?.romans.find((item) => lowerTag(item.language) === language);
|
|
1524
|
+
if (!roman || !getAnnotationItemText(roman)) continue;
|
|
1525
|
+
let prefix = pieces.length && spaced ? " " : "";
|
|
1526
|
+
spaced = false;
|
|
1527
|
+
for (const token of roman.tokens) {
|
|
1528
|
+
if (!token.text) continue;
|
|
1529
|
+
pieces.push({
|
|
1530
|
+
text: prefix + token.text,
|
|
1531
|
+
time: resolveAnnotationTokenTime(token, roman, word)
|
|
1532
|
+
});
|
|
1533
|
+
prefix = "";
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return pieces.map((piece, index) => {
|
|
1537
|
+
const start = piece.time?.start;
|
|
1538
|
+
if (start === void 0) return piece.text;
|
|
1539
|
+
const end = piece.time?.end;
|
|
1540
|
+
const nextStart = pieces[index + 1]?.time?.start;
|
|
1541
|
+
const closing = end !== void 0 && end !== nextStart && (!options.includeWordEnd || end === start) ? renderWordTag(end, void 0, options) : "";
|
|
1542
|
+
return `${renderWordTag(start, end, options)}${piece.text}${closing}`;
|
|
954
1543
|
}).join("");
|
|
955
1544
|
};
|
|
956
1545
|
var renderMetaRows = (meta, options) => {
|
|
@@ -972,7 +1561,7 @@ var renderLineRow = (line, options, wordLevel) => {
|
|
|
972
1561
|
if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) return;
|
|
973
1562
|
const time = renderTimeTag(line.time?.start ?? 0, options);
|
|
974
1563
|
if (line.type === LineType.INSTRUMENTAL) return time;
|
|
975
|
-
return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) :
|
|
1564
|
+
return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : getContentText(line.content)}`;
|
|
976
1565
|
};
|
|
977
1566
|
var renderAnnotationRows = (lines, options, pick) => {
|
|
978
1567
|
const rows = [];
|
|
@@ -983,6 +1572,20 @@ var renderAnnotationRows = (lines, options, pick) => {
|
|
|
983
1572
|
}
|
|
984
1573
|
return rows;
|
|
985
1574
|
};
|
|
1575
|
+
var renderRomanRows = (lines, options) => {
|
|
1576
|
+
const rows = [];
|
|
1577
|
+
for (const line of lines) {
|
|
1578
|
+
const start = line.time?.start;
|
|
1579
|
+
if (start === void 0) continue;
|
|
1580
|
+
const explicit = new Set((line.annotation?.romans ?? []).map((item) => lowerTag(item.language)));
|
|
1581
|
+
for (const item of getLineRomans(line)) {
|
|
1582
|
+
const language = lowerTag(item.language);
|
|
1583
|
+
const text = options.wordTiming === "none" || explicit.has(language) ? item.text : renderWordRomans(line, language, options);
|
|
1584
|
+
rows.push(`${renderTimeTag(start, options)}${text}`);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return rows;
|
|
1588
|
+
};
|
|
986
1589
|
var joinBlocks = (header, body) => {
|
|
987
1590
|
const blocks = [];
|
|
988
1591
|
if (header.length) blocks.push(header.join("\n"));
|
|
@@ -993,47 +1596,29 @@ var joinBlocks = (header, body) => {
|
|
|
993
1596
|
* Generates the LRC text plus the annotations a single file cannot hold.
|
|
994
1597
|
*
|
|
995
1598
|
* Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
|
|
1599
|
+
* Word romanizations retain token boundaries; explicit line romanizations take precedence for the same language.
|
|
996
1600
|
*/
|
|
997
1601
|
var generateLrc = (lyric, options = {}) => {
|
|
998
1602
|
const resolved = resolveOptions$1(options);
|
|
999
1603
|
const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
|
|
1000
|
-
const wordLevel = lyric.lines.some((line) =>
|
|
1604
|
+
const wordLevel = lyric.lines.some((line) => !!line.content?.words.length) && resolved.wordTiming !== "none";
|
|
1001
1605
|
const result = { original: joinBlocks(header, lyric.lines.map((line) => {
|
|
1002
1606
|
return renderLineRow(line, resolved, wordLevel);
|
|
1003
1607
|
}).filter((row) => row !== void 0)) };
|
|
1004
1608
|
const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? []);
|
|
1005
1609
|
if (translations.length) result.translations = joinBlocks(header, translations);
|
|
1006
|
-
const romans =
|
|
1610
|
+
const romans = renderRomanRows(lyric.lines, resolved);
|
|
1007
1611
|
if (romans.length) result.romans = joinBlocks(header, romans);
|
|
1008
1612
|
return result;
|
|
1009
1613
|
};
|
|
1010
1614
|
//#endregion
|
|
1011
|
-
//#region src/formats/result.ts
|
|
1012
|
-
/**
|
|
1013
|
-
* Canonicalizes and validates one parser's output, so every format hands back the same shape.
|
|
1014
|
-
*
|
|
1015
|
-
* Canonicalization runs first because validation reports paths against the canonical tree.
|
|
1016
|
-
*/
|
|
1017
|
-
var finalizeLyric = (input) => {
|
|
1018
|
-
const lyric = canonicalizeLyric(input);
|
|
1019
|
-
return {
|
|
1020
|
-
lyric,
|
|
1021
|
-
diagnostics: validateLyric(lyric)
|
|
1022
|
-
};
|
|
1023
|
-
};
|
|
1024
|
-
//#endregion
|
|
1025
1615
|
//#region src/formats/lrc/parser.ts
|
|
1026
|
-
var
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
* A trailing tag alone still makes the line word-level, so it can close the last word.
|
|
1031
|
-
* Unreadable angle-bracket text stays plain lyric content.
|
|
1032
|
-
*/
|
|
1033
|
-
var isWordTimed = (text) => {
|
|
1034
|
-
return splitWordTags(text).some((piece) => piece.tag !== void 0 && parseWordTime(piece.tag, 0) !== void 0);
|
|
1616
|
+
var CLOCK_PATTERN = /^(?:\d+:){0,2}\d+(?:\.(\d+))?$/;
|
|
1617
|
+
var readLineTiming = (tag) => {
|
|
1618
|
+
const clock = CLOCK_PATTERN.exec(tag.trim());
|
|
1619
|
+
return { precision: clock ? 10 ** Math.max(0, 3 - (clock[1]?.length ?? 0)) : 1 };
|
|
1035
1620
|
};
|
|
1036
|
-
var readMeta = (body) => {
|
|
1621
|
+
var readMeta$1 = (body) => {
|
|
1037
1622
|
const match = META_PATTERN.exec(body);
|
|
1038
1623
|
if (!match) return;
|
|
1039
1624
|
return {
|
|
@@ -1041,28 +1626,33 @@ var readMeta = (body) => {
|
|
|
1041
1626
|
value: (match[2] ?? "").trim()
|
|
1042
1627
|
};
|
|
1043
1628
|
};
|
|
1044
|
-
var readGroup = (tags, text, timed, meta) => {
|
|
1629
|
+
var readGroup = (tags, text, row, timed, meta, options) => {
|
|
1045
1630
|
for (const tag of tags) {
|
|
1046
1631
|
const start = isTime(tag) ? parseTime(tag) : void 0;
|
|
1047
1632
|
if (start !== void 0) {
|
|
1048
1633
|
timed.push({
|
|
1049
1634
|
text,
|
|
1050
|
-
start
|
|
1635
|
+
start,
|
|
1636
|
+
tag,
|
|
1637
|
+
row
|
|
1051
1638
|
});
|
|
1052
1639
|
continue;
|
|
1053
1640
|
}
|
|
1054
|
-
const pair = readMeta(tag);
|
|
1055
|
-
if (pair)
|
|
1641
|
+
const pair = readMeta$1(tag);
|
|
1642
|
+
if (pair) {
|
|
1643
|
+
meta.push(pair);
|
|
1644
|
+
options.onMeta?.(pair, row);
|
|
1645
|
+
}
|
|
1056
1646
|
}
|
|
1057
1647
|
};
|
|
1058
1648
|
/**
|
|
1059
1649
|
* Stacked timestamps share the following text, as in `[00:10][00:20]twice`.
|
|
1060
1650
|
* Text before the first tag is discarded because it has no timing association.
|
|
1061
1651
|
*/
|
|
1062
|
-
var readTimedLines = (text) => {
|
|
1652
|
+
var readTimedLines = (text, options) => {
|
|
1063
1653
|
const timed = [];
|
|
1064
1654
|
const meta = [];
|
|
1065
|
-
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
1655
|
+
for (const [row, raw] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
|
|
1066
1656
|
const line = raw.trim();
|
|
1067
1657
|
if (!line) continue;
|
|
1068
1658
|
const matches = [...line.matchAll(TAG_PATTERN)];
|
|
@@ -1073,10 +1663,10 @@ var readTimedLines = (text) => {
|
|
|
1073
1663
|
const shared = line.slice(at, next);
|
|
1074
1664
|
group.push(match[1] ?? "");
|
|
1075
1665
|
if (!shared.trim()) continue;
|
|
1076
|
-
readGroup(group, shared, timed, meta);
|
|
1666
|
+
readGroup(group, shared, row, timed, meta, options);
|
|
1077
1667
|
group = [];
|
|
1078
1668
|
}
|
|
1079
|
-
readGroup(group, "", timed, meta);
|
|
1669
|
+
readGroup(group, "", row, timed, meta, options);
|
|
1080
1670
|
}
|
|
1081
1671
|
return {
|
|
1082
1672
|
timed,
|
|
@@ -1084,57 +1674,11 @@ var readTimedLines = (text) => {
|
|
|
1084
1674
|
};
|
|
1085
1675
|
};
|
|
1086
1676
|
/**
|
|
1087
|
-
* Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.
|
|
1088
|
-
* Keys are lowercased and values trimmed, so original tag spelling is not preserved.
|
|
1089
|
-
*/
|
|
1090
|
-
var readMetaTags = (tags) => {
|
|
1091
|
-
const meta = makeMeta();
|
|
1092
|
-
for (const tag of tags) switch (tag.key) {
|
|
1093
|
-
case "length":
|
|
1094
|
-
case "duration": {
|
|
1095
|
-
const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
|
|
1096
|
-
if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
|
|
1097
|
-
else meta.unknowns.push(makeUnknownEntry({
|
|
1098
|
-
key: tag.key,
|
|
1099
|
-
value: tag.value
|
|
1100
|
-
}));
|
|
1101
|
-
break;
|
|
1102
|
-
}
|
|
1103
|
-
case "offset":
|
|
1104
|
-
meta.offset = Math.trunc(Number(tag.value)) || 0;
|
|
1105
|
-
break;
|
|
1106
|
-
case "ti":
|
|
1107
|
-
case "title":
|
|
1108
|
-
meta.titles.push(makeText({ text: tag.value }));
|
|
1109
|
-
break;
|
|
1110
|
-
case "ar":
|
|
1111
|
-
case "artist":
|
|
1112
|
-
meta.artists.push(makeText({ text: tag.value }));
|
|
1113
|
-
break;
|
|
1114
|
-
case "al":
|
|
1115
|
-
case "album":
|
|
1116
|
-
meta.albums.push(makeText({ text: tag.value }));
|
|
1117
|
-
break;
|
|
1118
|
-
case "by":
|
|
1119
|
-
case "author":
|
|
1120
|
-
meta.authors.push(makeText({ text: tag.value }));
|
|
1121
|
-
break;
|
|
1122
|
-
case "isrc":
|
|
1123
|
-
meta.isrcs.push(tag.value);
|
|
1124
|
-
break;
|
|
1125
|
-
default: meta.unknowns.push(makeUnknownEntry({
|
|
1126
|
-
key: tag.key,
|
|
1127
|
-
value: tag.value
|
|
1128
|
-
}));
|
|
1129
|
-
}
|
|
1130
|
-
return meta;
|
|
1131
|
-
};
|
|
1132
|
-
/**
|
|
1133
1677
|
* Whitespace becomes a space word preserving the source run, as in `lo World`.
|
|
1134
1678
|
* A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
|
|
1135
1679
|
* Explicit word ends are kept as written, so duration tags survive unchanged.
|
|
1136
1680
|
*/
|
|
1137
|
-
var readWords$
|
|
1681
|
+
var readWords$2 = (pieces, lineStart) => {
|
|
1138
1682
|
const drafts = [];
|
|
1139
1683
|
const open = /* @__PURE__ */ new Set();
|
|
1140
1684
|
const closeSegment = (boundary) => {
|
|
@@ -1144,9 +1688,9 @@ var readWords$1 = (text, lineStart) => {
|
|
|
1144
1688
|
};
|
|
1145
1689
|
let segmentStart = lineStart;
|
|
1146
1690
|
let cursor = lineStart;
|
|
1147
|
-
for (const piece of
|
|
1148
|
-
const time = piece.tag
|
|
1149
|
-
if (time
|
|
1691
|
+
for (const piece of pieces) {
|
|
1692
|
+
const time = piece.tag ? parseWordTime(piece.tag, lineStart) : void 0;
|
|
1693
|
+
if (time) {
|
|
1150
1694
|
closeSegment(time.start);
|
|
1151
1695
|
segmentStart = time.start;
|
|
1152
1696
|
cursor = time.start;
|
|
@@ -1162,7 +1706,7 @@ var readWords$1 = (text, lineStart) => {
|
|
|
1162
1706
|
});
|
|
1163
1707
|
continue;
|
|
1164
1708
|
}
|
|
1165
|
-
const tagged = !applied && time
|
|
1709
|
+
const tagged = !applied && !!time;
|
|
1166
1710
|
applied = true;
|
|
1167
1711
|
const start = tagged ? segmentStart : cursor;
|
|
1168
1712
|
const end = tagged ? time?.end : void 0;
|
|
@@ -1182,23 +1726,26 @@ var readWords$1 = (text, lineStart) => {
|
|
|
1182
1726
|
};
|
|
1183
1727
|
};
|
|
1184
1728
|
/**
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
* Empty timed rows represent instrumental time rather than sung content.
|
|
1729
|
+
* Explicit word ranges can extend past the next line's start, so the inferred end covers both.
|
|
1730
|
+
* A trailing open segment uses that end when it is known.
|
|
1188
1731
|
*/
|
|
1189
|
-
var readLine$
|
|
1732
|
+
var readLine$4 = (text, start, nextStart) => {
|
|
1190
1733
|
const trimmed = text.trim();
|
|
1191
1734
|
let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
|
|
1192
|
-
if (trimmed
|
|
1735
|
+
if (!trimmed) return makeLineInstrumental({ time: makeTimeRange({
|
|
1193
1736
|
start,
|
|
1194
1737
|
end
|
|
1195
1738
|
}) });
|
|
1196
|
-
const
|
|
1197
|
-
const
|
|
1739
|
+
const pieces = splitWordTags(text);
|
|
1740
|
+
const wordTimed = pieces.some((piece) => !!piece.tag);
|
|
1741
|
+
const { drafts, closeSegment } = wordTimed ? readWords$2(pieces, start) : {
|
|
1198
1742
|
drafts: [],
|
|
1199
1743
|
closeSegment: () => {}
|
|
1200
1744
|
};
|
|
1201
1745
|
if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
|
|
1746
|
+
if (end !== void 0) {
|
|
1747
|
+
for (const draft of drafts) if (draft.end !== void 0) end = Math.max(end, draft.end);
|
|
1748
|
+
}
|
|
1202
1749
|
closeSegment(end);
|
|
1203
1750
|
const words = drafts.map((draft) => {
|
|
1204
1751
|
if (draft.space) return makeWordSpace({ text: draft.text });
|
|
@@ -1225,29 +1772,97 @@ var readLine$1 = (text, start, nextStart) => {
|
|
|
1225
1772
|
* Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
|
|
1226
1773
|
* Empty timed rows become instrumental lines bounded by the next timestamp when one is usable.
|
|
1227
1774
|
*/
|
|
1228
|
-
var readLines = (timed) => {
|
|
1775
|
+
var readLines$1 = (timed, options) => {
|
|
1229
1776
|
const lines = [];
|
|
1230
1777
|
for (const [index, item] of timed.entries()) {
|
|
1231
1778
|
const next = timed[index + 1]?.start;
|
|
1232
|
-
|
|
1779
|
+
const line = readLine$4(item.text, item.start, next);
|
|
1780
|
+
lines.push(line);
|
|
1781
|
+
options.onLine?.(line, readLineTiming(item.tag), item.row);
|
|
1233
1782
|
}
|
|
1234
1783
|
return lines;
|
|
1235
1784
|
};
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1785
|
+
/**
|
|
1786
|
+
* Reads one LRC track in source order and performs no other operation: no metadata mapping, annotation attachment, sorting, or normalization.
|
|
1787
|
+
*
|
|
1788
|
+
* @param text Original lyrics or one auxiliary track in LRC syntax.
|
|
1789
|
+
* @param options Optional observer for source timestamp information.
|
|
1790
|
+
*
|
|
1791
|
+
* @returns Model lines in input order and metadata tags with lowercase keys and trimmed values.
|
|
1792
|
+
*/
|
|
1793
|
+
var readLrc = (text, options = {}) => {
|
|
1794
|
+
const { timed, meta } = readTimedLines(text, options);
|
|
1795
|
+
return {
|
|
1796
|
+
lines: readLines$1(timed, options),
|
|
1797
|
+
metaTags: meta
|
|
1798
|
+
};
|
|
1799
|
+
};
|
|
1800
|
+
var isBareNumber = (value) => {
|
|
1801
|
+
return /^\d+$/.test(value.trim());
|
|
1802
|
+
};
|
|
1803
|
+
/**
|
|
1804
|
+
* Maps standard lyric metadata while preserving unmapped tags and unparseable durations in unknowns.
|
|
1805
|
+
*
|
|
1806
|
+
* @param tags Reader tags with lowercase keys and trimmed values.
|
|
1807
|
+
*
|
|
1808
|
+
* @returns Model metadata without interpreting platform-specific embedded annotations.
|
|
1809
|
+
*/
|
|
1810
|
+
var readLrcMetaTags = (tags) => {
|
|
1811
|
+
const meta = makeMeta();
|
|
1812
|
+
for (const tag of tags) switch (tag.key) {
|
|
1813
|
+
case "length":
|
|
1814
|
+
case "duration": {
|
|
1815
|
+
const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
|
|
1816
|
+
if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
|
|
1817
|
+
else meta.unknowns.push(makeUnknownEntry({
|
|
1818
|
+
key: tag.key,
|
|
1819
|
+
value: tag.value
|
|
1820
|
+
}));
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
case "offset":
|
|
1824
|
+
meta.offset = Math.trunc(Number(tag.value)) || 0;
|
|
1825
|
+
break;
|
|
1826
|
+
case "ti":
|
|
1827
|
+
case "title":
|
|
1828
|
+
meta.titles.push(makeText({ text: tag.value }));
|
|
1829
|
+
break;
|
|
1830
|
+
case "ar":
|
|
1831
|
+
case "artist":
|
|
1832
|
+
meta.artists.push(makeText({ text: tag.value }));
|
|
1833
|
+
break;
|
|
1834
|
+
case "al":
|
|
1835
|
+
case "album":
|
|
1836
|
+
meta.albums.push(makeText({ text: tag.value }));
|
|
1837
|
+
break;
|
|
1838
|
+
case "by":
|
|
1839
|
+
case "author":
|
|
1840
|
+
meta.authors.push(makeText({ text: tag.value }));
|
|
1841
|
+
break;
|
|
1842
|
+
case "isrc":
|
|
1843
|
+
meta.isrcs.push(tag.value);
|
|
1844
|
+
break;
|
|
1845
|
+
default: meta.unknowns.push(makeUnknownEntry({
|
|
1846
|
+
key: tag.key,
|
|
1847
|
+
value: tag.value
|
|
1848
|
+
}));
|
|
1849
|
+
}
|
|
1850
|
+
return meta;
|
|
1851
|
+
};
|
|
1852
|
+
var parseLrcText = (text) => {
|
|
1853
|
+
const { lines, metaTags } = readLrc(text);
|
|
1854
|
+
const meta = readLrcMetaTags(metaTags);
|
|
1855
|
+
if (!lines.length) return makeLyric({
|
|
1856
|
+
format: "lrc",
|
|
1857
|
+
status: LyricStatus.INVALID,
|
|
1858
|
+
meta
|
|
1859
|
+
});
|
|
1245
1860
|
const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
|
|
1246
1861
|
return makeLyric({
|
|
1247
1862
|
format: "lrc",
|
|
1248
1863
|
status: LyricStatus.VALID,
|
|
1249
1864
|
timing: wordTimed ? Timing.WORD : Timing.LINE,
|
|
1250
|
-
meta
|
|
1865
|
+
meta,
|
|
1251
1866
|
languages: deriveLanguageUsages(lines),
|
|
1252
1867
|
lines
|
|
1253
1868
|
});
|
|
@@ -1256,6 +1871,7 @@ var parseLrcText = (text) => {
|
|
|
1256
1871
|
* Empty timed rows become instrumental lines; source order and missing word times remain for model validation.
|
|
1257
1872
|
*
|
|
1258
1873
|
* Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
|
|
1874
|
+
* Complete roman tracks attach to words by timing and optional local recovery; other tracks retain their source line text.
|
|
1259
1875
|
*
|
|
1260
1876
|
* `INVALID` means no readable timed lines; the returned diagnostics carry whatever the canonical tree breaks.
|
|
1261
1877
|
*/
|
|
@@ -1263,19 +1879,836 @@ var parseLrc = (input, options = {}) => {
|
|
|
1263
1879
|
const source = readLrcContent(input) ?? { original: "" };
|
|
1264
1880
|
const lyric = parseLrcText(source.original);
|
|
1265
1881
|
if (lyric.status === LyricStatus.INVALID) return finalizeLyric(lyric);
|
|
1266
|
-
const translations = source.translations
|
|
1267
|
-
const romans = source.romans
|
|
1268
|
-
if (translations.length
|
|
1269
|
-
const lines =
|
|
1882
|
+
const translations = source.translations ? readLrc(source.translations).lines : [];
|
|
1883
|
+
const romans = source.romans ? readLrc(source.romans).lines : [];
|
|
1884
|
+
if (!translations.length && !romans.length) return finalizeLyric(lyric);
|
|
1885
|
+
const lines = attachAnnotations(lyric.lines, {
|
|
1270
1886
|
translations,
|
|
1271
1887
|
romans
|
|
1272
|
-
}, {
|
|
1888
|
+
}, {
|
|
1889
|
+
tolerance: options.annotationTolerance,
|
|
1890
|
+
wordTolerance: options.wordAnnotationTolerance,
|
|
1891
|
+
wordFallback: options.wordAnnotationFallback
|
|
1892
|
+
});
|
|
1273
1893
|
return finalizeLyric({
|
|
1274
1894
|
...lyric,
|
|
1275
1895
|
lines
|
|
1276
1896
|
});
|
|
1277
1897
|
};
|
|
1278
1898
|
//#endregion
|
|
1899
|
+
//#region src/formats/qrc/parser.ts
|
|
1900
|
+
var WORD_PATTERN$2 = /\((\d+),(\d+)\)/g;
|
|
1901
|
+
var META_PATTERN$1 = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
|
|
1902
|
+
var META_TAG_PATTERN = /\[([^\]]*)\]/g;
|
|
1903
|
+
var RUBY_PATTERN = /\((\d+),(\d+)\)|(\d+)|([^0-9()]+)/gy;
|
|
1904
|
+
var RUBY_WORD_PATTERN = /[\p{Script=Han}\p{Number}]/u;
|
|
1905
|
+
var SEPARATORS$3 = /^\p{White_Space}+$/u;
|
|
1906
|
+
var NATIVE_ANNOTATION_TIMING = {
|
|
1907
|
+
precision: 1,
|
|
1908
|
+
rounding: 1
|
|
1909
|
+
};
|
|
1910
|
+
/**
|
|
1911
|
+
* Reads coverage markers and optional absolute token times without interpreting timing digits as coverage.
|
|
1912
|
+
*
|
|
1913
|
+
* @returns Complete ruby sources, or undefined when any source cannot be read.
|
|
1914
|
+
*/
|
|
1915
|
+
var readRubyEntries = (value) => {
|
|
1916
|
+
const entries = [];
|
|
1917
|
+
let length = 0;
|
|
1918
|
+
let tokens = [];
|
|
1919
|
+
let text = "";
|
|
1920
|
+
let cursor = 0;
|
|
1921
|
+
const finish = () => {
|
|
1922
|
+
if (text.trim()) tokens.push(makeWordAnnotationToken({ text }));
|
|
1923
|
+
if (!length || !tokens.length) return false;
|
|
1924
|
+
entries.push({
|
|
1925
|
+
length,
|
|
1926
|
+
ruby: makeWordAnnotationRuby({ tokens })
|
|
1927
|
+
});
|
|
1928
|
+
tokens = [];
|
|
1929
|
+
text = "";
|
|
1930
|
+
return true;
|
|
1931
|
+
};
|
|
1932
|
+
for (const match of value.matchAll(RUBY_PATTERN)) {
|
|
1933
|
+
cursor = match.index + match[0].length;
|
|
1934
|
+
if (match[3]) {
|
|
1935
|
+
if (length && !finish()) return;
|
|
1936
|
+
length = Number(match[3]);
|
|
1937
|
+
if (!Number.isSafeInteger(length) || length <= 0) return;
|
|
1938
|
+
} else if (match[1]) {
|
|
1939
|
+
const start = Number(match[1]);
|
|
1940
|
+
const end = start + Number(match[2]);
|
|
1941
|
+
if (!length || !text.trim() || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return;
|
|
1942
|
+
tokens.push(makeWordAnnotationToken({
|
|
1943
|
+
text,
|
|
1944
|
+
time: {
|
|
1945
|
+
start,
|
|
1946
|
+
end
|
|
1947
|
+
}
|
|
1948
|
+
}));
|
|
1949
|
+
text = "";
|
|
1950
|
+
} else {
|
|
1951
|
+
if (!length) return;
|
|
1952
|
+
text += match[4];
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
return cursor === value.length && finish() ? entries : void 0;
|
|
1956
|
+
};
|
|
1957
|
+
var readRubies = (meta) => {
|
|
1958
|
+
let entries;
|
|
1959
|
+
const unknowns = [];
|
|
1960
|
+
for (const entry of meta.unknowns) if (entry.key === "kana") entries ??= readRubyEntries(entry.value);
|
|
1961
|
+
else unknowns.push(entry);
|
|
1962
|
+
meta.unknowns = unknowns;
|
|
1963
|
+
return entries;
|
|
1964
|
+
};
|
|
1965
|
+
var matchRubies = (words, sources) => {
|
|
1966
|
+
const starts = /* @__PURE__ */ new Map();
|
|
1967
|
+
for (const [index, candidate] of words.entries()) {
|
|
1968
|
+
const start = candidate?.word.time?.start;
|
|
1969
|
+
if (start !== void 0 && Number.isSafeInteger(start) && start >= 0) {
|
|
1970
|
+
const queue = starts.get(start) ?? [];
|
|
1971
|
+
queue.push(index);
|
|
1972
|
+
starts.set(start, queue);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
const timed = /* @__PURE__ */ new Set();
|
|
1976
|
+
const claims = /* @__PURE__ */ new Map();
|
|
1977
|
+
for (const [index, source] of sources.entries()) {
|
|
1978
|
+
const times = source.ruby.tokens.flatMap((token) => token.time ? [token.time] : []);
|
|
1979
|
+
if (!times.length) continue;
|
|
1980
|
+
timed.add(index);
|
|
1981
|
+
const start = times[0].start;
|
|
1982
|
+
const end = times.at(-1).end;
|
|
1983
|
+
if (start === void 0) continue;
|
|
1984
|
+
let candidates = (starts.get(start) ?? []).filter((target) => {
|
|
1985
|
+
const candidate = words[target];
|
|
1986
|
+
const finish = candidate.word.time?.end;
|
|
1987
|
+
return candidate.length === source.length && finish !== void 0 && Number.isSafeInteger(finish) && times.every((time) => {
|
|
1988
|
+
return time.start !== void 0 && time.end !== void 0 && time.start >= start && time.end >= time.start && time.end <= finish;
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
if (times.length === source.ruby.tokens.length) {
|
|
1992
|
+
const exact = candidates.filter((target) => words[target].word.time.end === end);
|
|
1993
|
+
if (exact.length) candidates = exact;
|
|
1994
|
+
}
|
|
1995
|
+
if (candidates.length !== 1) continue;
|
|
1996
|
+
const target = candidates[0];
|
|
1997
|
+
const queue = claims.get(target) ?? [];
|
|
1998
|
+
queue.push(index);
|
|
1999
|
+
claims.set(target, queue);
|
|
2000
|
+
}
|
|
2001
|
+
const matched = /* @__PURE__ */ new Map();
|
|
2002
|
+
for (const [target, queue] of claims) if (queue.length === 1) matched.set(queue[0], target);
|
|
2003
|
+
const anchors = [
|
|
2004
|
+
[-1, -1],
|
|
2005
|
+
...[...matched].sort((first, second) => first[0] - second[0]),
|
|
2006
|
+
[sources.length, words.length]
|
|
2007
|
+
];
|
|
2008
|
+
for (let index = 1; index < anchors.length; index += 1) if (anchors[index][1] <= anchors[index - 1][1]) return matched;
|
|
2009
|
+
for (let index = 1; index < anchors.length; index += 1) {
|
|
2010
|
+
const [firstSource, firstWord] = anchors[index - 1];
|
|
2011
|
+
const [lastSource, lastWord] = anchors[index];
|
|
2012
|
+
const pending = [];
|
|
2013
|
+
let compatible = true;
|
|
2014
|
+
for (let source = firstSource + 1; source < lastSource; source += 1) {
|
|
2015
|
+
if (timed.has(source)) {
|
|
2016
|
+
compatible = false;
|
|
2017
|
+
break;
|
|
2018
|
+
}
|
|
2019
|
+
pending.push(source);
|
|
2020
|
+
}
|
|
2021
|
+
if (!compatible || !pending.length) continue;
|
|
2022
|
+
const candidates = [];
|
|
2023
|
+
for (let target = firstWord + 1; target < lastWord; target += 1) {
|
|
2024
|
+
const candidate = words[target];
|
|
2025
|
+
if (!candidate) {
|
|
2026
|
+
compatible = false;
|
|
2027
|
+
break;
|
|
2028
|
+
}
|
|
2029
|
+
if (candidate.byOrder) candidates.push(target);
|
|
2030
|
+
}
|
|
2031
|
+
if (!compatible || candidates.length !== pending.length || pending.some((source, position) => sources[source].length !== words[candidates[position]].length)) continue;
|
|
2032
|
+
for (const [position, source] of pending.entries()) matched.set(source, candidates[position]);
|
|
2033
|
+
}
|
|
2034
|
+
return matched;
|
|
2035
|
+
};
|
|
2036
|
+
/**
|
|
2037
|
+
* Attaches whole-word ruby without changing the source text, segmentation, or timing.
|
|
2038
|
+
*
|
|
2039
|
+
* @param lines Original timed rows before filtering or sorting.
|
|
2040
|
+
* @param sources Readings with source coverage lengths and optional token times.
|
|
2041
|
+
*
|
|
2042
|
+
* @returns Annotated rows and matched source indices, preserving unchanged nodes.
|
|
2043
|
+
*/
|
|
2044
|
+
var attachRubies = (lines, sources) => {
|
|
2045
|
+
if (!sources.length) return {
|
|
2046
|
+
lines,
|
|
2047
|
+
matched: /* @__PURE__ */ new Set()
|
|
2048
|
+
};
|
|
2049
|
+
const words = [];
|
|
2050
|
+
for (const line of lines) {
|
|
2051
|
+
if (line.type !== LineType.NORMAL) continue;
|
|
2052
|
+
if (line.content?.text && RUBY_WORD_PATTERN.test(line.content.text)) {
|
|
2053
|
+
words.push(void 0);
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
for (const word of line.content?.words ?? []) if (word.type === WordType.NORMAL) words.push({
|
|
2057
|
+
word,
|
|
2058
|
+
length: [...word.text].length,
|
|
2059
|
+
byOrder: RUBY_WORD_PATTERN.test(word.text)
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
const matched = matchRubies(words, sources);
|
|
2063
|
+
if (!matched.size) return {
|
|
2064
|
+
lines,
|
|
2065
|
+
matched: /* @__PURE__ */ new Set()
|
|
2066
|
+
};
|
|
2067
|
+
const annotations = new Map([...matched].map(([source, target]) => [words[target].word, sources[source].ruby]));
|
|
2068
|
+
return {
|
|
2069
|
+
lines: mapShared(lines, (line) => {
|
|
2070
|
+
const content = line.content;
|
|
2071
|
+
if (!content) return line;
|
|
2072
|
+
const words = mapShared(content.words, (word) => {
|
|
2073
|
+
const ruby = annotations.get(word);
|
|
2074
|
+
if (!ruby) return word;
|
|
2075
|
+
return {
|
|
2076
|
+
...word,
|
|
2077
|
+
annotation: makeWordAnnotation({
|
|
2078
|
+
...word.annotation,
|
|
2079
|
+
rubies: [...word.annotation?.rubies ?? [], ruby]
|
|
2080
|
+
})
|
|
2081
|
+
};
|
|
2082
|
+
});
|
|
2083
|
+
return words === content.words ? line : {
|
|
2084
|
+
...line,
|
|
2085
|
+
content: {
|
|
2086
|
+
...content,
|
|
2087
|
+
words
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
}),
|
|
2091
|
+
matched: new Set(matched.keys())
|
|
2092
|
+
};
|
|
2093
|
+
};
|
|
2094
|
+
var readMeta = (body, meta) => {
|
|
2095
|
+
const match = META_PATTERN$1.exec(body);
|
|
2096
|
+
if (!match) return;
|
|
2097
|
+
const key = match[1].toLowerCase();
|
|
2098
|
+
const value = match[2].trim();
|
|
2099
|
+
switch (key) {
|
|
2100
|
+
case "length":
|
|
2101
|
+
case "duration": {
|
|
2102
|
+
const duration = parseTime(value) ?? (/^\d+$/.test(value) ? Number(value) : NaN);
|
|
2103
|
+
if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
|
|
2104
|
+
else meta.unknowns.push(makeUnknownEntry({
|
|
2105
|
+
key,
|
|
2106
|
+
value
|
|
2107
|
+
}));
|
|
2108
|
+
break;
|
|
2109
|
+
}
|
|
2110
|
+
case "offset":
|
|
2111
|
+
meta.offset = Math.trunc(Number(value)) || 0;
|
|
2112
|
+
break;
|
|
2113
|
+
case "ti":
|
|
2114
|
+
case "title":
|
|
2115
|
+
meta.titles.push(makeText({ text: value }));
|
|
2116
|
+
break;
|
|
2117
|
+
case "ar":
|
|
2118
|
+
case "artist":
|
|
2119
|
+
meta.artists.push(makeText({ text: value }));
|
|
2120
|
+
break;
|
|
2121
|
+
case "al":
|
|
2122
|
+
case "album":
|
|
2123
|
+
meta.albums.push(makeText({ text: value }));
|
|
2124
|
+
break;
|
|
2125
|
+
case "by":
|
|
2126
|
+
case "author":
|
|
2127
|
+
meta.authors.push(makeText({ text: value }));
|
|
2128
|
+
break;
|
|
2129
|
+
case "isrc":
|
|
2130
|
+
meta.isrcs.push(value);
|
|
2131
|
+
break;
|
|
2132
|
+
default: meta.unknowns.push(makeUnknownEntry({
|
|
2133
|
+
key,
|
|
2134
|
+
value
|
|
2135
|
+
}));
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
var readTimeRange = (startText, durationText) => {
|
|
2139
|
+
const start = Number(startText);
|
|
2140
|
+
return makeTimeRange({
|
|
2141
|
+
start,
|
|
2142
|
+
end: start + Number(durationText)
|
|
2143
|
+
});
|
|
2144
|
+
};
|
|
2145
|
+
var appendWords$2 = (words, text, time) => {
|
|
2146
|
+
for (const fragment of text.split(/(\p{White_Space}+)/u)) {
|
|
2147
|
+
if (!fragment) continue;
|
|
2148
|
+
if (SEPARATORS$3.test(fragment)) words.push(makeWordSpace({ text: fragment }));
|
|
2149
|
+
else words.push(makeWordNormal({
|
|
2150
|
+
text: fragment,
|
|
2151
|
+
time
|
|
2152
|
+
}));
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
var readWords$1 = (text) => {
|
|
2156
|
+
const words = [];
|
|
2157
|
+
let cursor = 0;
|
|
2158
|
+
let tagged = false;
|
|
2159
|
+
for (const match of text.matchAll(WORD_PATTERN$2)) {
|
|
2160
|
+
tagged = true;
|
|
2161
|
+
appendWords$2(words, text.slice(cursor, match.index), readTimeRange(match[1], match[2]));
|
|
2162
|
+
cursor = match.index + match[0].length;
|
|
2163
|
+
}
|
|
2164
|
+
if (!tagged) return;
|
|
2165
|
+
appendWords$2(words, text.slice(cursor));
|
|
2166
|
+
return words;
|
|
2167
|
+
};
|
|
2168
|
+
/**
|
|
2169
|
+
* Kugou writes `//` where it holds no translation, in QRC source rows and in its language payload alike.
|
|
2170
|
+
*
|
|
2171
|
+
* @returns Whether the trimmed text is that sentinel instead of real content.
|
|
2172
|
+
*/
|
|
2173
|
+
var isPlaceholderText = (text) => {
|
|
2174
|
+
return text.trim() === "//";
|
|
2175
|
+
};
|
|
2176
|
+
var readLines = (text) => {
|
|
2177
|
+
const lines = [];
|
|
2178
|
+
const meta = makeMeta();
|
|
2179
|
+
const placeholders = /* @__PURE__ */ new Set();
|
|
2180
|
+
for (const raw of text.split(/\r\n?|\n/)) {
|
|
2181
|
+
const line = raw.trimStart();
|
|
2182
|
+
const match = LINE_PATTERN$2.exec(line);
|
|
2183
|
+
if (match) {
|
|
2184
|
+
const time = readTimeRange(match[1], match[2]);
|
|
2185
|
+
const text = line.slice(match[0].length);
|
|
2186
|
+
const words = readWords$1(text);
|
|
2187
|
+
const contentText = (words ? getWordsText(words) : text).trim();
|
|
2188
|
+
const placeholder = isPlaceholderText(contentText);
|
|
2189
|
+
if (placeholder) placeholders.add(lines.length);
|
|
2190
|
+
if (!contentText || placeholder) lines.push(makeLineInstrumental({ time }));
|
|
2191
|
+
else {
|
|
2192
|
+
const content = words ? makeLineContentWords(words) : makeLineContentText(text);
|
|
2193
|
+
lines.push(makeLineNormal({
|
|
2194
|
+
time,
|
|
2195
|
+
content
|
|
2196
|
+
}));
|
|
2197
|
+
}
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
if (!line.startsWith("[")) continue;
|
|
2201
|
+
for (const tag of line.matchAll(META_TAG_PATTERN)) {
|
|
2202
|
+
const body = tag[1];
|
|
2203
|
+
if (isTime(body)) continue;
|
|
2204
|
+
readMeta(body, meta);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
return {
|
|
2208
|
+
lines,
|
|
2209
|
+
meta,
|
|
2210
|
+
placeholders
|
|
2211
|
+
};
|
|
2212
|
+
};
|
|
2213
|
+
var readAnnotations$1 = (text, timings) => {
|
|
2214
|
+
if (!text) return [];
|
|
2215
|
+
const { lines } = readLines(text);
|
|
2216
|
+
if (lines.length) {
|
|
2217
|
+
for (const line of lines) timings.set(line, NATIVE_ANNOTATION_TIMING);
|
|
2218
|
+
return lines;
|
|
2219
|
+
}
|
|
2220
|
+
readLrc(text, { onLine: (line, timing) => {
|
|
2221
|
+
const annotation = isPlaceholderText(getContentText(line.content)) ? makeLineInstrumental({ time: line.time }) : line;
|
|
2222
|
+
timings.set(annotation, timing);
|
|
2223
|
+
lines.push(annotation);
|
|
2224
|
+
} });
|
|
2225
|
+
return lines;
|
|
2226
|
+
};
|
|
2227
|
+
/**
|
|
2228
|
+
* Parses plaintext or XML QRC, retaining explicit timing, word romanization, and ruby from kana metadata.
|
|
2229
|
+
* Original-track kana supplies ruby and is removed from the returned metadata.
|
|
2230
|
+
*
|
|
2231
|
+
* @param input Original lyrics with optional translation and romanization tracks.
|
|
2232
|
+
* @param options Independent line and word tolerances, with optional local roman recovery.
|
|
2233
|
+
*
|
|
2234
|
+
* @returns Canonical lyrics and source diagnostics; an empty result has INVALID status.
|
|
2235
|
+
*/
|
|
2236
|
+
var parseQrc = (input, options = {}) => {
|
|
2237
|
+
const source = readContent(input);
|
|
2238
|
+
if (!source) return finalizeLyric(makeLyric({
|
|
2239
|
+
format: "qrc",
|
|
2240
|
+
status: LyricStatus.INVALID
|
|
2241
|
+
}));
|
|
2242
|
+
const parsed = readLines(source.original);
|
|
2243
|
+
const meta = parsed.meta;
|
|
2244
|
+
const rubies = readRubies(meta);
|
|
2245
|
+
if (!parsed.lines.length) return finalizeLyric(makeLyric({
|
|
2246
|
+
format: "qrc",
|
|
2247
|
+
status: LyricStatus.INVALID,
|
|
2248
|
+
meta
|
|
2249
|
+
}));
|
|
2250
|
+
const timings = /* @__PURE__ */ new WeakMap();
|
|
2251
|
+
const translationLines = readAnnotations$1(source.translations && readText$2(source.translations), timings);
|
|
2252
|
+
const romanLines = readAnnotations$1(source.romans && readText$2(source.romans), timings);
|
|
2253
|
+
const rounding = romanLines.some((line) => timings.get(line)?.rounding === 1) ? 1 : 0;
|
|
2254
|
+
const wordTolerance = Number.isFinite(options.wordAnnotationTolerance) ? options.wordAnnotationTolerance ?? 0 : 0;
|
|
2255
|
+
const annotated = attachAnnotations(!rubies ? parsed.lines : attachRubies(parsed.lines, rubies).lines, {
|
|
2256
|
+
translations: translationLines,
|
|
2257
|
+
romans: romanLines
|
|
2258
|
+
}, {
|
|
2259
|
+
matchByOrder: true,
|
|
2260
|
+
preservePlaceholders: true,
|
|
2261
|
+
tolerance: options.annotationTolerance,
|
|
2262
|
+
wordTolerance: Math.max(wordTolerance, rounding),
|
|
2263
|
+
wordFallback: options.wordAnnotationFallback,
|
|
2264
|
+
getTiming: (line) => timings.get(line)
|
|
2265
|
+
});
|
|
2266
|
+
const lines = parsed.placeholders.size ? annotated.filter((_, index) => !parsed.placeholders.has(index)) : annotated;
|
|
2267
|
+
return finalizeLyric(makeLyric({
|
|
2268
|
+
format: "qrc",
|
|
2269
|
+
status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
|
|
2270
|
+
timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
|
|
2271
|
+
meta,
|
|
2272
|
+
lines,
|
|
2273
|
+
languages: deriveLanguageUsages(lines)
|
|
2274
|
+
}));
|
|
2275
|
+
};
|
|
2276
|
+
//#endregion
|
|
2277
|
+
//#region src/formats/krc/parser.ts
|
|
2278
|
+
var SEPARATORS$2 = /^\p{White_Space}+$/u;
|
|
2279
|
+
var appendWords$1 = (words, text, time) => {
|
|
2280
|
+
for (const fragment of text.split(/(\p{White_Space}+)/u)) {
|
|
2281
|
+
if (!fragment) continue;
|
|
2282
|
+
words.push(SEPARATORS$2.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
|
|
2283
|
+
text: fragment,
|
|
2284
|
+
time
|
|
2285
|
+
}));
|
|
2286
|
+
}
|
|
2287
|
+
};
|
|
2288
|
+
var readLine$3 = (body, start, duration) => {
|
|
2289
|
+
const time = makeTimeRange({
|
|
2290
|
+
start,
|
|
2291
|
+
end: start + duration
|
|
2292
|
+
});
|
|
2293
|
+
const pieces = scanWordTags(body, WORD_PATTERN$3);
|
|
2294
|
+
if (!pieces.some((piece) => piece.tag !== void 0)) return body.trim() ? makeLineNormal({
|
|
2295
|
+
time,
|
|
2296
|
+
content: makeLineContentText(body)
|
|
2297
|
+
}) : makeLineInstrumental({ time });
|
|
2298
|
+
const words = [];
|
|
2299
|
+
for (const piece of pieces) {
|
|
2300
|
+
if (piece.tag === void 0) {
|
|
2301
|
+
appendWords$1(words, piece.text);
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
const [offset, wordDuration] = piece.tag.split(",").map(Number);
|
|
2305
|
+
const wordStart = start + offset;
|
|
2306
|
+
appendWords$1(words, piece.text, makeTimeRange({
|
|
2307
|
+
start: wordStart,
|
|
2308
|
+
end: wordStart + wordDuration
|
|
2309
|
+
}));
|
|
2310
|
+
}
|
|
2311
|
+
return getWordsText(words).trim() ? makeLineNormal({
|
|
2312
|
+
time,
|
|
2313
|
+
content: makeLineContentWords(words)
|
|
2314
|
+
}) : makeLineInstrumental({ time });
|
|
2315
|
+
};
|
|
2316
|
+
/**
|
|
2317
|
+
* Reads native KRC rows in source order and performs no other operation: no metadata mapping, annotation attachment, sorting, or normalization.
|
|
2318
|
+
*
|
|
2319
|
+
* Explicit line and word durations survive unchanged; whitespace splitting never reallocates segment time.
|
|
2320
|
+
*
|
|
2321
|
+
* @returns Original-order model lines and metadata tags awaiting interpretation.
|
|
2322
|
+
*/
|
|
2323
|
+
var readKrc = (text) => {
|
|
2324
|
+
const lines = [];
|
|
2325
|
+
const metaTags = [];
|
|
2326
|
+
for (const raw of text.replace(/^\uFEFF/, "").split(/\r\n?|\n/)) {
|
|
2327
|
+
const line = raw.trimStart();
|
|
2328
|
+
const header = LINE_PATTERN$3.exec(line);
|
|
2329
|
+
if (header) {
|
|
2330
|
+
lines.push(readLine$3(line.slice(header[0].length), Number(header[1]), Number(header[2])));
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
if (!line.startsWith("[")) continue;
|
|
2334
|
+
for (const tag of line.matchAll(TAG_PATTERN)) {
|
|
2335
|
+
const meta = META_PATTERN.exec(tag[1]);
|
|
2336
|
+
if (meta) metaTags.push({
|
|
2337
|
+
key: meta[1].toLowerCase(),
|
|
2338
|
+
value: meta[2].trim()
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return {
|
|
2343
|
+
lines,
|
|
2344
|
+
metaTags
|
|
2345
|
+
};
|
|
2346
|
+
};
|
|
2347
|
+
var attachKana = (lines, meta) => {
|
|
2348
|
+
for (const entry of meta.unknowns) {
|
|
2349
|
+
if (entry.key !== "kana") continue;
|
|
2350
|
+
const sources = readRubyEntries(entry.value);
|
|
2351
|
+
if (!sources) continue;
|
|
2352
|
+
const attached = attachRubies(lines, sources);
|
|
2353
|
+
if (attached.matched.size === sources.length) meta.unknowns = meta.unknowns.filter((item) => item !== entry);
|
|
2354
|
+
return attached.lines;
|
|
2355
|
+
}
|
|
2356
|
+
return lines;
|
|
2357
|
+
};
|
|
2358
|
+
var isObject$1 = (value) => {
|
|
2359
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2360
|
+
};
|
|
2361
|
+
var readPayload = (value) => {
|
|
2362
|
+
const bytes = decodeBase64(value);
|
|
2363
|
+
if (!bytes) return;
|
|
2364
|
+
try {
|
|
2365
|
+
const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
2366
|
+
return isObject$1(payload) && payload.version === 1 && Array.isArray(payload.content) ? payload : void 0;
|
|
2367
|
+
} catch {
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
var readTrack$1 = (value) => {
|
|
2372
|
+
if (!isObject$1(value) || value.type !== 0 && value.type !== 1 || typeof value.language !== "number" || !Number.isSafeInteger(value.language) || value.language < 0 || !Array.isArray(value.lyricContent) || !value.lyricContent.every((row) => Array.isArray(row) && row.every((cell) => typeof cell === "string"))) return;
|
|
2373
|
+
return value;
|
|
2374
|
+
};
|
|
2375
|
+
var attachTrack = (lines, track) => {
|
|
2376
|
+
let result = lines;
|
|
2377
|
+
let complete = true;
|
|
2378
|
+
for (const [index, cells] of track.lyricContent.entries()) {
|
|
2379
|
+
const text = cells.join("");
|
|
2380
|
+
if (cells.length > 1) complete = false;
|
|
2381
|
+
if (!text.trim() || isPlaceholderText(text)) continue;
|
|
2382
|
+
const line = result[index];
|
|
2383
|
+
if (line.type === LineType.INSTRUMENTAL) {
|
|
2384
|
+
complete = false;
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
const kind = track.type === 1 ? "translations" : "romans";
|
|
2388
|
+
const existing = line.annotation?.[kind] ?? [];
|
|
2389
|
+
const occupied = existing.find((item) => !item.language);
|
|
2390
|
+
if (occupied) {
|
|
2391
|
+
complete = complete && occupied.text === text;
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
const create = track.type === 1 ? makeLineAnnotationTranslation : makeLineAnnotationRoman;
|
|
2395
|
+
const annotated = {
|
|
2396
|
+
...line,
|
|
2397
|
+
annotation: makeLineAnnotation({
|
|
2398
|
+
...line.annotation,
|
|
2399
|
+
[kind]: [...existing, create({ text })]
|
|
2400
|
+
})
|
|
2401
|
+
};
|
|
2402
|
+
if (result === lines) result = lines.slice();
|
|
2403
|
+
result[index] = annotated;
|
|
2404
|
+
}
|
|
2405
|
+
return {
|
|
2406
|
+
lines: result,
|
|
2407
|
+
complete
|
|
2408
|
+
};
|
|
2409
|
+
};
|
|
2410
|
+
/**
|
|
2411
|
+
* Attaches strictly recognized Kugou language tracks by original timed slot before sorting or filtering.
|
|
2412
|
+
*
|
|
2413
|
+
* Existing annotations take precedence; incomplete or ambiguous payloads remain in metadata.
|
|
2414
|
+
* Numeric language codes other than the verified default are retained without inventing BCP 47 tags.
|
|
2415
|
+
*
|
|
2416
|
+
* @returns Annotated lines, reusing the original array when no visible content changes.
|
|
2417
|
+
*/
|
|
2418
|
+
var attachKugouLanguage = (lines, meta) => {
|
|
2419
|
+
if (!lines.length) return lines;
|
|
2420
|
+
let result = lines;
|
|
2421
|
+
const unknowns = [];
|
|
2422
|
+
const selected = /* @__PURE__ */ new Map();
|
|
2423
|
+
for (const entry of meta.unknowns) {
|
|
2424
|
+
const payload = entry.key === "language" ? readPayload(entry.value) : void 0;
|
|
2425
|
+
if (!payload) {
|
|
2426
|
+
unknowns.push(entry);
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
let complete = Object.keys(payload).every((key) => key === "version" || key === "content" || key === "contentV2");
|
|
2430
|
+
if ("contentV2" in payload && (!Array.isArray(payload.contentV2) || payload.contentV2.length !== 0)) complete = false;
|
|
2431
|
+
for (const value of payload.content) {
|
|
2432
|
+
const track = readTrack$1(value);
|
|
2433
|
+
if (!track || track.language !== 0 || track.lyricContent.length !== lines.length) {
|
|
2434
|
+
complete = false;
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
if (Object.keys(value).some((key) => key !== "type" && key !== "language" && key !== "lyricContent")) complete = false;
|
|
2438
|
+
const previous = selected.get(track.type);
|
|
2439
|
+
if (previous) {
|
|
2440
|
+
complete = complete && JSON.stringify(previous) === JSON.stringify(track.lyricContent);
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
selected.set(track.type, track.lyricContent);
|
|
2444
|
+
const attached = attachTrack(result, track);
|
|
2445
|
+
result = attached.lines;
|
|
2446
|
+
complete = complete && attached.complete;
|
|
2447
|
+
}
|
|
2448
|
+
if (!complete) unknowns.push(entry);
|
|
2449
|
+
}
|
|
2450
|
+
meta.unknowns = unknowns;
|
|
2451
|
+
return result;
|
|
2452
|
+
};
|
|
2453
|
+
/**
|
|
2454
|
+
* Parses decrypted Kugou lyrics, retaining explicit durations and attaching embedded annotations by source slot.
|
|
2455
|
+
*
|
|
2456
|
+
* A source carrying no KRC row is read as LRC, so a platform fallback keeps its container annotations.
|
|
2457
|
+
* Placeholder slots carry no annotation, so they never keep a credit line in the stream.
|
|
2458
|
+
* Kana supplies whole-word ruby; type 0 language tracks retain complete line romanization.
|
|
2459
|
+
* Incomplete metadata remains available in unknowns, and source timing violations remain visible in diagnostics.
|
|
2460
|
+
*
|
|
2461
|
+
* @param input Decrypted plaintext or an object containing its original text.
|
|
2462
|
+
*
|
|
2463
|
+
* @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
|
|
2464
|
+
*/
|
|
2465
|
+
var parseKrc = (input) => {
|
|
2466
|
+
const source = readKrcContent(input);
|
|
2467
|
+
if (!source) return finalizeLyric(makeLyric({
|
|
2468
|
+
format: "krc",
|
|
2469
|
+
status: LyricStatus.INVALID
|
|
2470
|
+
}));
|
|
2471
|
+
const native = readKrc(source.original);
|
|
2472
|
+
const parsed = native.lines.length ? native : readLrc(source.original);
|
|
2473
|
+
const meta = readLrcMetaTags(parsed.metaTags);
|
|
2474
|
+
const lines = attachKugouLanguage(attachKana(parsed.lines, meta), meta);
|
|
2475
|
+
return finalizeLyric(makeLyric({
|
|
2476
|
+
format: "krc",
|
|
2477
|
+
status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
|
|
2478
|
+
timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
|
|
2479
|
+
meta,
|
|
2480
|
+
lines,
|
|
2481
|
+
languages: deriveLanguageUsages(lines)
|
|
2482
|
+
}));
|
|
2483
|
+
};
|
|
2484
|
+
//#endregion
|
|
2485
|
+
//#region src/formats/kwrc/common.ts
|
|
2486
|
+
var FORMAT$1 = "kwrc";
|
|
2487
|
+
var LINE_PATTERN$1 = /^\[([^\]]*)\]/;
|
|
2488
|
+
var WORD_PATTERN$1 = /<(-?\d+,-?\d+(?:,-?\d+)?)>/g;
|
|
2489
|
+
var VISIBLE_TEXT = /[^\p{White_Space}]/u;
|
|
2490
|
+
/**
|
|
2491
|
+
* Removes only complete encoded tags so literal angle-bracket text survives.
|
|
2492
|
+
*/
|
|
2493
|
+
var readText$1 = (text) => {
|
|
2494
|
+
return text.replace(WORD_PATTERN$1, "");
|
|
2495
|
+
};
|
|
2496
|
+
/**
|
|
2497
|
+
* Accepts decoded plaintext without unwrapping XML or auxiliary tracks.
|
|
2498
|
+
*/
|
|
2499
|
+
var readKwrcContent = (input) => {
|
|
2500
|
+
const source = typeof input === "string" ? { original: input } : input;
|
|
2501
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return null;
|
|
2502
|
+
const original = source.original;
|
|
2503
|
+
if (typeof original !== "string" || /^\s*</.test(original)) return null;
|
|
2504
|
+
return { original };
|
|
2505
|
+
};
|
|
2506
|
+
/**
|
|
2507
|
+
* Retains source slots and literal bodies before word timing or bilingual boundaries are interpreted.
|
|
2508
|
+
*/
|
|
2509
|
+
var readKwrcSource = (text) => {
|
|
2510
|
+
const lines = [];
|
|
2511
|
+
const metaTags = [];
|
|
2512
|
+
const rows = text.replace(/^\uFEFF/, "").split(/\r\n?|\n/);
|
|
2513
|
+
for (const [row, raw] of rows.entries()) {
|
|
2514
|
+
const times = [];
|
|
2515
|
+
let body = raw.trimStart();
|
|
2516
|
+
let header;
|
|
2517
|
+
while (header = LINE_PATTERN$1.exec(body)) {
|
|
2518
|
+
const tag = header[1].trim();
|
|
2519
|
+
const start = parseTime(tag);
|
|
2520
|
+
if (start !== void 0) times.push({
|
|
2521
|
+
start,
|
|
2522
|
+
clock: /^[\d:.]+$/.test(tag)
|
|
2523
|
+
});
|
|
2524
|
+
else {
|
|
2525
|
+
const meta = META_PATTERN.exec(tag);
|
|
2526
|
+
if (!meta || times.length) break;
|
|
2527
|
+
metaTags.push({
|
|
2528
|
+
key: meta[1].toLowerCase(),
|
|
2529
|
+
value: meta[2].trim()
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
body = body.slice(header[0].length);
|
|
2533
|
+
if (!times.length) body = body.trimStart();
|
|
2534
|
+
}
|
|
2535
|
+
for (const time of times) lines.push({
|
|
2536
|
+
row,
|
|
2537
|
+
...time,
|
|
2538
|
+
text: body
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
return {
|
|
2542
|
+
lines,
|
|
2543
|
+
metaTags
|
|
2544
|
+
};
|
|
2545
|
+
};
|
|
2546
|
+
/**
|
|
2547
|
+
* Requires complete octal values and agreement across all parameter tags, without selecting a default scale.
|
|
2548
|
+
*/
|
|
2549
|
+
var readKwrcScales = (tags) => {
|
|
2550
|
+
let value;
|
|
2551
|
+
for (const tag of tags) {
|
|
2552
|
+
if (tag.key !== "kuwo") continue;
|
|
2553
|
+
if (!/^[0-7]+$/.test(tag.value)) return;
|
|
2554
|
+
const next = Number.parseInt(tag.value, 8);
|
|
2555
|
+
if (!Number.isSafeInteger(next) || value !== void 0 && value !== next) return;
|
|
2556
|
+
value = next;
|
|
2557
|
+
}
|
|
2558
|
+
if (value === void 0) return;
|
|
2559
|
+
const start = Math.trunc(value / 10);
|
|
2560
|
+
const duration = value % 10;
|
|
2561
|
+
return start > 0 && duration > 0 ? {
|
|
2562
|
+
start,
|
|
2563
|
+
duration
|
|
2564
|
+
} : void 0;
|
|
2565
|
+
};
|
|
2566
|
+
/**
|
|
2567
|
+
* A repeated timestamp opens the previous original's translation slot before the next original.
|
|
2568
|
+
* The entire sequence must agree so one missing slot cannot shift all later translations.
|
|
2569
|
+
*/
|
|
2570
|
+
var hasKwrcTranslations = ({ lines, metaTags }) => {
|
|
2571
|
+
const markers = metaTags.filter((tag) => tag.key === "ml");
|
|
2572
|
+
if (!markers.length || markers.some((tag) => tag.value !== "1.0") || lines.length < 3 || lines.length % 2 !== 1) return false;
|
|
2573
|
+
if (!lines.every((line, index) => {
|
|
2574
|
+
return line.clock && Number.isSafeInteger(line.start) && line.row !== lines[index - 1]?.row;
|
|
2575
|
+
})) return false;
|
|
2576
|
+
let previous = lines[0];
|
|
2577
|
+
for (let index = 1; index < lines.length; index += 2) {
|
|
2578
|
+
const translation = lines[index];
|
|
2579
|
+
const original = lines[index + 1];
|
|
2580
|
+
if (translation.start <= previous.start || translation.start !== original.start) return false;
|
|
2581
|
+
if (!VISIBLE_TEXT.test(readText$1(previous.text)) && VISIBLE_TEXT.test(readText$1(translation.text))) return false;
|
|
2582
|
+
previous = original;
|
|
2583
|
+
}
|
|
2584
|
+
return true;
|
|
2585
|
+
};
|
|
2586
|
+
//#endregion
|
|
2587
|
+
//#region src/formats/kwrc/detect.ts
|
|
2588
|
+
/**
|
|
2589
|
+
* Recognizes octal parameters with encoded clock rows, or a complete marked bilingual sequence.
|
|
2590
|
+
*/
|
|
2591
|
+
var detectKwrc = (input) => {
|
|
2592
|
+
const source = readKwrcContent(input);
|
|
2593
|
+
if (!source) return false;
|
|
2594
|
+
const track = readKwrcSource(source.original);
|
|
2595
|
+
if (readKwrcScales(track.metaTags)) {
|
|
2596
|
+
for (const line of track.lines) if (line.clock && Number.isSafeInteger(line.start) && line.text.search(WORD_PATTERN$1) !== -1) return true;
|
|
2597
|
+
}
|
|
2598
|
+
return hasKwrcTranslations(track);
|
|
2599
|
+
};
|
|
2600
|
+
//#endregion
|
|
2601
|
+
//#region src/formats/kwrc/parser.ts
|
|
2602
|
+
var SEPARATORS$1 = /^\p{White_Space}+$/u;
|
|
2603
|
+
var readWordTime = (tag, lineStart, scales) => {
|
|
2604
|
+
const values = tag.split(",").map(Number);
|
|
2605
|
+
if (!Number.isSafeInteger(lineStart) || !values.every(Number.isSafeInteger)) return;
|
|
2606
|
+
const [x, y] = values;
|
|
2607
|
+
const sum = x + y;
|
|
2608
|
+
const difference = x - y;
|
|
2609
|
+
if (!Number.isSafeInteger(sum) || !Number.isSafeInteger(difference) || sum % (2 * scales.start) !== 0 || difference % (2 * scales.duration) !== 0) return;
|
|
2610
|
+
const relativeStart = Math.abs(sum / (2 * scales.start));
|
|
2611
|
+
const duration = Math.abs(difference / (2 * scales.duration));
|
|
2612
|
+
const start = lineStart + relativeStart;
|
|
2613
|
+
const end = start + duration;
|
|
2614
|
+
if (![
|
|
2615
|
+
relativeStart,
|
|
2616
|
+
duration,
|
|
2617
|
+
start,
|
|
2618
|
+
end
|
|
2619
|
+
].every(Number.isSafeInteger)) return;
|
|
2620
|
+
return makeTimeRange({
|
|
2621
|
+
start,
|
|
2622
|
+
end
|
|
2623
|
+
});
|
|
2624
|
+
};
|
|
2625
|
+
var readLine$2 = ({ text, start }, nextStart, scales) => {
|
|
2626
|
+
const boundary = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
|
|
2627
|
+
let end = boundary;
|
|
2628
|
+
const pieces = scanWordTags(text, WORD_PATTERN$1);
|
|
2629
|
+
if (!pieces.some((piece) => piece.tag !== void 0)) {
|
|
2630
|
+
if (Number.isSafeInteger(start) && splitWordTags(text).some((piece) => piece.tag !== void 0)) return readLrc(`[${start}ms]${text}${end === void 0 ? "" : `\n[${end}ms]`}`).lines[0];
|
|
2631
|
+
const time = makeTimeRange({
|
|
2632
|
+
start,
|
|
2633
|
+
end
|
|
2634
|
+
});
|
|
2635
|
+
return VISIBLE_TEXT.test(text) ? makeLineNormal({
|
|
2636
|
+
time,
|
|
2637
|
+
content: makeLineContentText(text)
|
|
2638
|
+
}) : makeLineInstrumental({ time });
|
|
2639
|
+
}
|
|
2640
|
+
const words = [];
|
|
2641
|
+
for (const piece of pieces) {
|
|
2642
|
+
const time = piece.tag !== void 0 && scales ? readWordTime(piece.tag, start, scales) : void 0;
|
|
2643
|
+
if (piece.tag !== void 0 && !time) return makeLineNormal({
|
|
2644
|
+
time: makeTimeRange({
|
|
2645
|
+
start,
|
|
2646
|
+
end: boundary
|
|
2647
|
+
}),
|
|
2648
|
+
content: makeLineContentText(text)
|
|
2649
|
+
});
|
|
2650
|
+
if (time?.end !== void 0) end = Math.max(end ?? start, time.end);
|
|
2651
|
+
for (const fragment of piece.text.split(/(\p{White_Space}+)/u)) if (fragment) words.push(SEPARATORS$1.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
|
|
2652
|
+
text: fragment,
|
|
2653
|
+
time
|
|
2654
|
+
}));
|
|
2655
|
+
}
|
|
2656
|
+
const time = makeTimeRange({
|
|
2657
|
+
start,
|
|
2658
|
+
end
|
|
2659
|
+
});
|
|
2660
|
+
return VISIBLE_TEXT.test(readText$1(text)) ? makeLineNormal({
|
|
2661
|
+
time,
|
|
2662
|
+
content: makeLineContentWords(words)
|
|
2663
|
+
}) : makeLineInstrumental({ time });
|
|
2664
|
+
};
|
|
2665
|
+
/**
|
|
2666
|
+
* Parses decoded KWRC with explicit word durations and structurally verified embedded translations.
|
|
2667
|
+
*
|
|
2668
|
+
* Missing or conflicting parameters and unsafe word calculations retain the complete row as line-level text.
|
|
2669
|
+
* Plain LRC remains readable; offset is stored as metadata without changing source timestamps.
|
|
2670
|
+
*
|
|
2671
|
+
* @param input Decoded plaintext or an object containing its original text.
|
|
2672
|
+
*
|
|
2673
|
+
* @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
|
|
2674
|
+
*/
|
|
2675
|
+
var parseKwrc = (input) => {
|
|
2676
|
+
const source = readKwrcContent(input);
|
|
2677
|
+
if (!source) return finalizeLyric(makeLyric({
|
|
2678
|
+
format: FORMAT$1,
|
|
2679
|
+
status: LyricStatus.INVALID
|
|
2680
|
+
}));
|
|
2681
|
+
const track = readKwrcSource(source.original);
|
|
2682
|
+
const bilingual = hasKwrcTranslations(track);
|
|
2683
|
+
const encoded = track.lines.some((line) => !!line.text.match(WORD_PATTERN$1));
|
|
2684
|
+
let lines;
|
|
2685
|
+
let tags = track.metaTags;
|
|
2686
|
+
if (!encoded && !bilingual) {
|
|
2687
|
+
const fallback = readLrc(source.original);
|
|
2688
|
+
lines = fallback.lines;
|
|
2689
|
+
tags = fallback.metaTags;
|
|
2690
|
+
} else {
|
|
2691
|
+
const scales = readKwrcScales(tags);
|
|
2692
|
+
const originals = bilingual ? track.lines.filter((_, index) => index % 2 === 0) : track.lines;
|
|
2693
|
+
lines = originals.map((original, index) => {
|
|
2694
|
+
const line = readLine$2(original, originals[index + 1]?.start, scales);
|
|
2695
|
+
const translation = bilingual ? readText$1(track.lines[index * 2 + 1]?.text ?? "") : "";
|
|
2696
|
+
if (VISIBLE_TEXT.test(translation)) line.annotation = makeLineAnnotation({ translations: [makeLineAnnotationTranslation({ text: translation })] });
|
|
2697
|
+
return line;
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
const last = lines.at(-1);
|
|
2701
|
+
if (last?.type === LineType.INSTRUMENTAL && (last.time?.end === void 0 || last.time.end === last.time.start)) lines.pop();
|
|
2702
|
+
return finalizeLyric(makeLyric({
|
|
2703
|
+
format: FORMAT$1,
|
|
2704
|
+
status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
|
|
2705
|
+
timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
|
|
2706
|
+
meta: readLrcMetaTags(tags),
|
|
2707
|
+
lines,
|
|
2708
|
+
languages: deriveLanguageUsages(lines)
|
|
2709
|
+
}));
|
|
2710
|
+
};
|
|
2711
|
+
//#endregion
|
|
1279
2712
|
//#region src/formats/ttml/common.ts
|
|
1280
2713
|
var FORMAT = "ttml";
|
|
1281
2714
|
var AMLL_HINT_PATTERN = /xmlns:amll|amll:meta/iu;
|
|
@@ -1404,7 +2837,7 @@ var renderTime = (time, offset) => {
|
|
|
1404
2837
|
* Writes known bounds without inventing an end.
|
|
1405
2838
|
*/
|
|
1406
2839
|
var renderTimeAttributes = (time, offset) => {
|
|
1407
|
-
if (time
|
|
2840
|
+
if (!time) throw new Error("TTML sung lines, words, and ruby tokens require a time range");
|
|
1408
2841
|
const attributes = [["begin", renderTime(time.start, offset)]];
|
|
1409
2842
|
if (time.end !== void 0) attributes.push(["end", renderTime(time.end, offset)]);
|
|
1410
2843
|
return attributes;
|
|
@@ -1426,7 +2859,7 @@ var renderWord = (word, context, language) => {
|
|
|
1426
2859
|
if (word.type === WordType.SPACE) return createXmlText(word.text);
|
|
1427
2860
|
const attributes = [...renderTimeAttributes(word.time, context.offset), ...renderLanguage(word.language, language)];
|
|
1428
2861
|
const ruby = context.profile === "amll" ? word.annotation?.rubies[0] : void 0;
|
|
1429
|
-
if (ruby
|
|
2862
|
+
if (!ruby) return createXmlElement("span", attributes, [createXmlText(word.text)]);
|
|
1430
2863
|
const tokens = ruby.tokens.map((token) => {
|
|
1431
2864
|
return createXmlElement("span", [["tts:ruby", RUBY_TEXT], ...renderTimeAttributes(resolveAnnotationTokenTime(token, ruby, word), context.offset)], [createXmlText(token.text)]);
|
|
1432
2865
|
});
|
|
@@ -1441,7 +2874,7 @@ var renderWord = (word, context, language) => {
|
|
|
1441
2874
|
*/
|
|
1442
2875
|
var renderContent = (line, context) => {
|
|
1443
2876
|
const content = line.content;
|
|
1444
|
-
if (content
|
|
2877
|
+
if (!content) throw new Error("TTML sung lines require content");
|
|
1445
2878
|
if (content.text !== void 0) return [createXmlText(content.text)];
|
|
1446
2879
|
return content.words.map((word) => {
|
|
1447
2880
|
return renderWord(word, context, getLineLanguage(line));
|
|
@@ -1450,10 +2883,10 @@ var renderContent = (line, context) => {
|
|
|
1450
2883
|
/**
|
|
1451
2884
|
* Keeps annotations outside the sung word stream.
|
|
1452
2885
|
*/
|
|
1453
|
-
var renderInlineAnnotations = (
|
|
2886
|
+
var renderInlineAnnotations = (line, language) => {
|
|
1454
2887
|
const nodes = [];
|
|
1455
|
-
for (const item of annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
|
|
1456
|
-
for (const item of
|
|
2888
|
+
for (const item of line.annotation?.translations ?? []) nodes.push(createXmlElement("span", [["ttm:role", ROLE_TRANSLATION], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
|
|
2889
|
+
for (const item of getLineRomans(line)) nodes.push(createXmlElement("span", [["ttm:role", ROLE_ROMAN], ...renderLanguage(item.language, language)], [createXmlText(item.text)]));
|
|
1457
2890
|
return nodes;
|
|
1458
2891
|
};
|
|
1459
2892
|
/**
|
|
@@ -1469,7 +2902,7 @@ var renderLineAttributes = (line, context) => {
|
|
|
1469
2902
|
const lead = line.agents[0];
|
|
1470
2903
|
if (lead !== void 0) {
|
|
1471
2904
|
const id = context.agents.get(lead);
|
|
1472
|
-
if (id
|
|
2905
|
+
if (!id) throw new Error("TTML performing agents must have a declaration");
|
|
1473
2906
|
attributes.push(["ttm:agent", id]);
|
|
1474
2907
|
}
|
|
1475
2908
|
return attributes;
|
|
@@ -1482,7 +2915,7 @@ var createLyricElement = (name, attributes, children) => {
|
|
|
1482
2915
|
};
|
|
1483
2916
|
var renderBackground = (line, context) => {
|
|
1484
2917
|
const children = renderContent(line, context);
|
|
1485
|
-
if (context.profile === "amll") children.push(...renderInlineAnnotations(line
|
|
2918
|
+
if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
|
|
1486
2919
|
return createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ...renderLineAttributes(line, context)], children);
|
|
1487
2920
|
};
|
|
1488
2921
|
var renderLine = (line, context) => {
|
|
@@ -1490,7 +2923,7 @@ var renderLine = (line, context) => {
|
|
|
1490
2923
|
const after = [];
|
|
1491
2924
|
for (const background of line.backgrounds) {
|
|
1492
2925
|
const node = renderBackground(background, context);
|
|
1493
|
-
if (background.time
|
|
2926
|
+
if (background.time && line.time && background.time.start < line.time.start) before.push(node);
|
|
1494
2927
|
else after.push(node);
|
|
1495
2928
|
}
|
|
1496
2929
|
const children = [
|
|
@@ -1498,7 +2931,7 @@ var renderLine = (line, context) => {
|
|
|
1498
2931
|
...renderContent(line, context),
|
|
1499
2932
|
...after
|
|
1500
2933
|
];
|
|
1501
|
-
if (context.profile === "amll") children.push(...renderInlineAnnotations(line
|
|
2934
|
+
if (context.profile === "amll") children.push(...renderInlineAnnotations(line, getLineLanguage(line)));
|
|
1502
2935
|
return createLyricElement("p", renderLineAttributes(line, context), children);
|
|
1503
2936
|
};
|
|
1504
2937
|
var renderAgents = (lyric, context) => {
|
|
@@ -1533,10 +2966,10 @@ var renderSongwriters = (meta) => {
|
|
|
1533
2966
|
if (credit.role !== CreditRole.LYRICIST) continue;
|
|
1534
2967
|
for (const name of credit.names) names.push(createXmlElement("songwriter", renderLanguage(name.language), [createXmlText(name.text)]));
|
|
1535
2968
|
}
|
|
1536
|
-
return names.length
|
|
2969
|
+
return names.length ? [createXmlElement("songwriters", [], names)] : [];
|
|
1537
2970
|
};
|
|
1538
2971
|
var renderAnnotationTracks = (tracks, kind, context) => {
|
|
1539
|
-
if (tracks.size
|
|
2972
|
+
if (!tracks.size) return [];
|
|
1540
2973
|
const translated = kind === "translations";
|
|
1541
2974
|
const blocks = Array.from(tracks, ([language, track]) => {
|
|
1542
2975
|
const attributes = translated ? [["type", "subtitle"]] : [];
|
|
@@ -1556,26 +2989,29 @@ var renderHeadAnnotations = (lines, context) => {
|
|
|
1556
2989
|
translations: /* @__PURE__ */ new Map(),
|
|
1557
2990
|
romans: /* @__PURE__ */ new Map()
|
|
1558
2991
|
};
|
|
1559
|
-
const append = (
|
|
1560
|
-
|
|
2992
|
+
const append = (source, line, background) => {
|
|
2993
|
+
const annotation = {
|
|
2994
|
+
translations: source.annotation?.translations ?? [],
|
|
2995
|
+
romans: getLineRomans(source)
|
|
2996
|
+
};
|
|
1561
2997
|
for (const kind of ANNOTATION_KINDS) for (const item of annotation[kind]) {
|
|
1562
2998
|
const language = item.language?.toLowerCase();
|
|
1563
2999
|
let track = tracks[kind].get(language);
|
|
1564
|
-
if (track
|
|
3000
|
+
if (!track) {
|
|
1565
3001
|
track = /* @__PURE__ */ new Map();
|
|
1566
3002
|
tracks[kind].set(language, track);
|
|
1567
3003
|
}
|
|
1568
3004
|
let nodes = track.get(line);
|
|
1569
|
-
if (nodes
|
|
3005
|
+
if (!nodes) {
|
|
1570
3006
|
nodes = [];
|
|
1571
3007
|
track.set(line, nodes);
|
|
1572
3008
|
}
|
|
1573
|
-
nodes.push(background
|
|
3009
|
+
nodes.push(!background ? createXmlText(item.text) : createXmlElement("span", [["ttm:role", ROLE_BACKGROUND], ["for", context.keys.get(background)]], [createXmlText(item.text)]));
|
|
1574
3010
|
}
|
|
1575
3011
|
};
|
|
1576
3012
|
for (const line of lines) {
|
|
1577
|
-
append(line
|
|
1578
|
-
for (const background of line.backgrounds) append(background
|
|
3013
|
+
append(line, line);
|
|
3014
|
+
for (const background of line.backgrounds) append(background, line, background);
|
|
1579
3015
|
}
|
|
1580
3016
|
return ANNOTATION_KINDS.flatMap((kind) => {
|
|
1581
3017
|
return renderAnnotationTracks(tracks[kind], kind, context);
|
|
@@ -1584,12 +3020,12 @@ var renderHeadAnnotations = (lines, context) => {
|
|
|
1584
3020
|
var renderMetadata = (lyric, lines, context, includeMeta) => {
|
|
1585
3021
|
const metadata = renderAgents(lyric, context);
|
|
1586
3022
|
const itunes = [];
|
|
1587
|
-
if (includeMeta && lyric.meta
|
|
3023
|
+
if (includeMeta && lyric.meta) {
|
|
1588
3024
|
if (context.profile === "amll") metadata.push(...renderAmllMeta(lyric.meta));
|
|
1589
3025
|
itunes.push(...renderSongwriters(lyric.meta));
|
|
1590
3026
|
}
|
|
1591
3027
|
if (context.profile === "itunes") itunes.push(...renderHeadAnnotations(lines, context));
|
|
1592
|
-
if (itunes.length
|
|
3028
|
+
if (itunes.length) metadata.push(createXmlElement("iTunesMetadata", [["xmlns", NAMESPACE_ITUNES_METADATA]], itunes));
|
|
1593
3029
|
return createXmlElement("metadata", renderLanguage(void 0, lyric.meta?.language), metadata);
|
|
1594
3030
|
};
|
|
1595
3031
|
var renderRootAttributes = (lyric, profile) => {
|
|
@@ -1608,6 +3044,7 @@ var renderRootAttributes = (lyric, profile) => {
|
|
|
1608
3044
|
*
|
|
1609
3045
|
* Offsets are baked into timestamps; a negative result or a missing required time throws.
|
|
1610
3046
|
* Profile mappings retain the lead agent and first AMLL ruby, omitting unsupported fields and instrumental nodes.
|
|
3047
|
+
* Word romanizations are emitted as line text without their token timing.
|
|
1611
3048
|
*/
|
|
1612
3049
|
var generateTtml = (lyric, options = {}) => {
|
|
1613
3050
|
if (lyric.timing !== Timing.LINE && lyric.timing !== Timing.WORD) throw new Error("TTML generation requires line or word timing");
|
|
@@ -1691,7 +3128,7 @@ var readText = (element, inherited) => {
|
|
|
1691
3128
|
* Preserves the parser's whitespace segmentation when turning text into word drafts.
|
|
1692
3129
|
*/
|
|
1693
3130
|
var splitWords = (text, language) => {
|
|
1694
|
-
if (text
|
|
3131
|
+
if (!text) return [];
|
|
1695
3132
|
if (!WORD_SEPARATOR_PATTERN.test(text)) return [{
|
|
1696
3133
|
text,
|
|
1697
3134
|
space: false,
|
|
@@ -1699,7 +3136,7 @@ var splitWords = (text, language) => {
|
|
|
1699
3136
|
rubies: []
|
|
1700
3137
|
}];
|
|
1701
3138
|
const words = [];
|
|
1702
|
-
for (const run of text.split(/(\p{White_Space}+)/u)) if (run
|
|
3139
|
+
for (const run of text.split(/(\p{White_Space}+)/u)) if (run) {
|
|
1703
3140
|
const space = /^\p{White_Space}+$/u.test(run);
|
|
1704
3141
|
words.push({
|
|
1705
3142
|
text: run,
|
|
@@ -1714,7 +3151,7 @@ var splitWords = (text, language) => {
|
|
|
1714
3151
|
* Distinguishes indentation runs from separators using the parser's existing rule.
|
|
1715
3152
|
*/
|
|
1716
3153
|
var isFormattingWhitespace = (text) => {
|
|
1717
|
-
return text.trim()
|
|
3154
|
+
return !text.trim() && text.includes("\n");
|
|
1718
3155
|
};
|
|
1719
3156
|
/**
|
|
1720
3157
|
* Builds words from source drafts without deriving missing timestamps.
|
|
@@ -1730,7 +3167,7 @@ var buildWords = (draft) => {
|
|
|
1730
3167
|
start: word.start,
|
|
1731
3168
|
end: word.end
|
|
1732
3169
|
});
|
|
1733
|
-
if (word.rubies.length
|
|
3170
|
+
if (!word.rubies.length) {
|
|
1734
3171
|
words.push(makeWordNormal({
|
|
1735
3172
|
text: word.text,
|
|
1736
3173
|
time,
|
|
@@ -1769,7 +3206,7 @@ var buildWords = (draft) => {
|
|
|
1769
3206
|
* Builds only annotations explicitly present in the source drafts.
|
|
1770
3207
|
*/
|
|
1771
3208
|
var buildLineAnnotation = (draft) => {
|
|
1772
|
-
if (draft.translations.length
|
|
3209
|
+
if (!draft.translations.length && !draft.romans.length) return;
|
|
1773
3210
|
return makeLineAnnotation({
|
|
1774
3211
|
translations: draft.translations.map((item) => {
|
|
1775
3212
|
return makeLineAnnotationTranslation({
|
|
@@ -1820,9 +3257,9 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
|
|
|
1820
3257
|
preserveSpace,
|
|
1821
3258
|
language
|
|
1822
3259
|
}];
|
|
1823
|
-
while (stack.length
|
|
3260
|
+
while (stack.length) {
|
|
1824
3261
|
const entry = stack.pop();
|
|
1825
|
-
if (entry
|
|
3262
|
+
if (!entry) continue;
|
|
1826
3263
|
const element = entry.element;
|
|
1827
3264
|
const currentSpace = readPreserveSpace(element, entry.preserveSpace);
|
|
1828
3265
|
const currentLanguage = readLanguage(element, entry.language);
|
|
@@ -1833,7 +3270,7 @@ var groupElements = (root, names, preserveSpace, stopAtMatch = false, language)
|
|
|
1833
3270
|
preserveSpace: currentSpace,
|
|
1834
3271
|
language: currentLanguage
|
|
1835
3272
|
};
|
|
1836
|
-
if (group
|
|
3273
|
+
if (!group) result.set(element.localName, [context]);
|
|
1837
3274
|
else group.push(context);
|
|
1838
3275
|
if (stopAtMatch) continue;
|
|
1839
3276
|
}
|
|
@@ -1893,22 +3330,22 @@ var readRuby = (container, preserveSpace, language) => {
|
|
|
1893
3330
|
const last = tokens.findLast((token) => {
|
|
1894
3331
|
return token.start !== void 0;
|
|
1895
3332
|
});
|
|
1896
|
-
const time = readSpanTime(container) ?? rubyTime ?? (first
|
|
3333
|
+
const time = readSpanTime(container) ?? rubyTime ?? (first ? {
|
|
1897
3334
|
start: first.start,
|
|
1898
3335
|
end: last?.end
|
|
1899
|
-
});
|
|
3336
|
+
} : void 0);
|
|
1900
3337
|
return {
|
|
1901
3338
|
text: base,
|
|
1902
3339
|
space: false,
|
|
1903
3340
|
start: time?.start,
|
|
1904
3341
|
end: time?.end,
|
|
1905
3342
|
language: baseLanguage,
|
|
1906
|
-
rubies: tokens.length
|
|
3343
|
+
rubies: tokens.length ? [{
|
|
1907
3344
|
start: rubyTime?.start,
|
|
1908
3345
|
end: rubyTime?.end,
|
|
1909
3346
|
language: rubyLanguage,
|
|
1910
3347
|
tokens
|
|
1911
|
-
}]
|
|
3348
|
+
}] : []
|
|
1912
3349
|
};
|
|
1913
3350
|
};
|
|
1914
3351
|
/**
|
|
@@ -1932,7 +3369,7 @@ var readWords = (element, preserveSpace, language) => {
|
|
|
1932
3369
|
if (getXmlAttribute(child, "role") !== void 0) continue;
|
|
1933
3370
|
if (getXmlAttribute(child, "ruby") === "container") {
|
|
1934
3371
|
const ruby = readRuby(child, preserveSpace, language);
|
|
1935
|
-
if (ruby
|
|
3372
|
+
if (ruby) {
|
|
1936
3373
|
for (const part of splitWords(ruby.text)) words.push(part.space ? part : {
|
|
1937
3374
|
...ruby,
|
|
1938
3375
|
text: part.text
|
|
@@ -1946,7 +3383,7 @@ var readWords = (element, preserveSpace, language) => {
|
|
|
1946
3383
|
if (!text || !childSpace && isFormattingWhitespace(text)) continue;
|
|
1947
3384
|
const time = readSpanTime(child);
|
|
1948
3385
|
for (const part of splitWords(text, readLanguage(child, language))) {
|
|
1949
|
-
if (!part.space && time
|
|
3386
|
+
if (!part.space && time) {
|
|
1950
3387
|
part.start = time.start;
|
|
1951
3388
|
part.end = time.end;
|
|
1952
3389
|
wordLevel = true;
|
|
@@ -1982,11 +3419,11 @@ var readInlineAnnotations = (element, preserveSpace, language) => {
|
|
|
1982
3419
|
/**
|
|
1983
3420
|
* Reads one paragraph, pulling its inline annotations and background lines out of the word stream.
|
|
1984
3421
|
*/
|
|
1985
|
-
var readLine = (element, preserveSpace, language) => {
|
|
3422
|
+
var readLine$1 = (element, preserveSpace, language) => {
|
|
1986
3423
|
preserveSpace = readPreserveSpace(element, preserveSpace);
|
|
1987
3424
|
language = readLanguage(element, language);
|
|
1988
3425
|
const time = readLineTime(element);
|
|
1989
|
-
if (time
|
|
3426
|
+
if (!time) return;
|
|
1990
3427
|
const draft = {
|
|
1991
3428
|
start: time.start,
|
|
1992
3429
|
end: time.end,
|
|
@@ -2027,7 +3464,7 @@ var readBackground = (element, preserveSpace, language) => {
|
|
|
2027
3464
|
preserveSpace = readPreserveSpace(element, preserveSpace);
|
|
2028
3465
|
language = readLanguage(element, language);
|
|
2029
3466
|
const read = readWords(element, preserveSpace, language);
|
|
2030
|
-
if (read.words.length
|
|
3467
|
+
if (!read.words.length) return;
|
|
2031
3468
|
const begin = getXmlAttribute(element, "begin");
|
|
2032
3469
|
const end = getXmlAttribute(element, "end");
|
|
2033
3470
|
const first = read.words.find((word) => {
|
|
@@ -2069,7 +3506,7 @@ var buildBackground = (draft) => {
|
|
|
2069
3506
|
end: draft.end
|
|
2070
3507
|
}),
|
|
2071
3508
|
agents: draft.agentId === void 0 ? [] : [draft.agentId],
|
|
2072
|
-
languages: draft.language
|
|
3509
|
+
languages: draft.language ? [draft.language] : [],
|
|
2073
3510
|
content: buildContent(draft),
|
|
2074
3511
|
annotation: buildLineAnnotation(draft)
|
|
2075
3512
|
});
|
|
@@ -2085,7 +3522,7 @@ var buildLine = (draft) => {
|
|
|
2085
3522
|
end: draft.end
|
|
2086
3523
|
}),
|
|
2087
3524
|
agents: draft.agentId === void 0 ? [] : [draft.agentId],
|
|
2088
|
-
languages: draft.language
|
|
3525
|
+
languages: draft.language ? [draft.language] : [],
|
|
2089
3526
|
content: buildContent(draft),
|
|
2090
3527
|
annotation: buildLineAnnotation(draft),
|
|
2091
3528
|
backgrounds: draft.backgrounds.map(buildBackground)
|
|
@@ -2105,7 +3542,7 @@ var readAgents = (elements) => {
|
|
|
2105
3542
|
language: readLanguage(name, language)
|
|
2106
3543
|
});
|
|
2107
3544
|
}).filter((name) => {
|
|
2108
|
-
return name.text
|
|
3545
|
+
return !!name.text;
|
|
2109
3546
|
});
|
|
2110
3547
|
const kind = readAgentType(getXmlAttribute(element, "type"));
|
|
2111
3548
|
agents.push(makeAgent({
|
|
@@ -2129,9 +3566,9 @@ var readCredits = (elements, meta) => {
|
|
|
2129
3566
|
language
|
|
2130
3567
|
});
|
|
2131
3568
|
}).filter((name) => {
|
|
2132
|
-
return name.text
|
|
3569
|
+
return !!name.text;
|
|
2133
3570
|
});
|
|
2134
|
-
if (names.length
|
|
3571
|
+
if (!names.length) return;
|
|
2135
3572
|
meta.credits.push(makeMetaCredit({
|
|
2136
3573
|
role: CreditRole.LYRICIST,
|
|
2137
3574
|
names
|
|
@@ -2185,7 +3622,7 @@ var applyAmllMeta = (meta, element, language) => {
|
|
|
2185
3622
|
* A replacement translation rewrites the line's text, which is how a source swaps one script for another.
|
|
2186
3623
|
*/
|
|
2187
3624
|
var attachHeadAnnotations = (metadata, lineMap, backgroundMap) => {
|
|
2188
|
-
if (lineMap.size
|
|
3625
|
+
if (!lineMap.size) return;
|
|
2189
3626
|
const attach = (blocks, kind) => {
|
|
2190
3627
|
for (const { element: block, preserveSpace, language } of blocks) {
|
|
2191
3628
|
const type = getXmlQualifiedAttribute(block, "type") ?? getXmlAttribute(block, "type");
|
|
@@ -2254,23 +3691,23 @@ var parseTtml = (input, options = {}) => {
|
|
|
2254
3691
|
const language = readLanguage(root, lowerTag(options.language));
|
|
2255
3692
|
const body = findElements(root, "body", false, true, language)[0];
|
|
2256
3693
|
const metadata = findElements(root, "metadata", false, true, language)[0];
|
|
2257
|
-
const metadataElements = metadata
|
|
3694
|
+
const metadataElements = metadata ? groupElements(metadata.element, METADATA_NAMES, metadata.preserveSpace, false, metadata.language) : /* @__PURE__ */ new Map();
|
|
2258
3695
|
const meta = makeMeta();
|
|
2259
|
-
if (language
|
|
3696
|
+
if (language) meta.language = language;
|
|
2260
3697
|
readCredits(metadataElements.get("songwriter") ?? [], meta);
|
|
2261
3698
|
for (const { element, language: itemLanguage } of metadataElements.get("meta") ?? []) applyAmllMeta(meta, element, itemLanguage);
|
|
2262
3699
|
const drafts = [];
|
|
2263
3700
|
const lineMap = /* @__PURE__ */ new Map();
|
|
2264
3701
|
const backgroundMap = /* @__PURE__ */ new Map();
|
|
2265
3702
|
for (const { element, preserveSpace, language: lineLanguage } of body ? findElements(body.element, "p", body.preserveSpace, true, body.language) : []) {
|
|
2266
|
-
const draft = readLine(element, preserveSpace, lineLanguage);
|
|
3703
|
+
const draft = readLine$1(element, preserveSpace, lineLanguage);
|
|
2267
3704
|
if (!draft) continue;
|
|
2268
3705
|
drafts.push(draft);
|
|
2269
3706
|
if (draft.key) lineMap.set(draft.key, draft);
|
|
2270
3707
|
for (const background of draft.backgrounds) if (background.key) backgroundMap.set(background.key, background);
|
|
2271
3708
|
}
|
|
2272
3709
|
attachHeadAnnotations(metadataElements, lineMap, backgroundMap);
|
|
2273
|
-
if (drafts.length
|
|
3710
|
+
if (!drafts.length) return finalizeLyric(makeLyric({
|
|
2274
3711
|
format: FORMAT,
|
|
2275
3712
|
status: LyricStatus.INVALID,
|
|
2276
3713
|
meta,
|
|
@@ -2294,6 +3731,260 @@ var parseTtml = (input, options = {}) => {
|
|
|
2294
3731
|
}));
|
|
2295
3732
|
};
|
|
2296
3733
|
//#endregion
|
|
3734
|
+
//#region src/formats/yrc/common.ts
|
|
3735
|
+
var LINE_PATTERN = /^\[(\d+),(\d+)\]/;
|
|
3736
|
+
var WORD_PATTERN = /\((\d+,\d+,\d+)\)/g;
|
|
3737
|
+
/**
|
|
3738
|
+
* Accepts plaintext tracks on one timeline without interpreting an API response or an XML container.
|
|
3739
|
+
*
|
|
3740
|
+
* @returns Original and optional auxiliary text, or null when the original payload is unreadable.
|
|
3741
|
+
*/
|
|
3742
|
+
var readYrcContent = (input) => {
|
|
3743
|
+
const value = typeof input === "string" ? { original: input } : input;
|
|
3744
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
3745
|
+
const source = value;
|
|
3746
|
+
if (typeof source.original !== "string" || /^\s*</.test(source.original)) return null;
|
|
3747
|
+
return {
|
|
3748
|
+
original: source.original,
|
|
3749
|
+
translations: typeof source.translations === "string" ? source.translations : void 0,
|
|
3750
|
+
romans: typeof source.romans === "string" ? source.romans : void 0
|
|
3751
|
+
};
|
|
3752
|
+
};
|
|
3753
|
+
//#endregion
|
|
3754
|
+
//#region src/formats/yrc/parser.ts
|
|
3755
|
+
var SEPARATORS = /^\p{White_Space}+$/u;
|
|
3756
|
+
var CREDIT_LABEL_PATTERN = /[::]$/u;
|
|
3757
|
+
var isObject = (value) => {
|
|
3758
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
3759
|
+
};
|
|
3760
|
+
var readCredit = (fragments) => {
|
|
3761
|
+
const label = trimRuleWhitespace(fragments[0] ?? "");
|
|
3762
|
+
const raw = trimRuleWhitespace(label.replace(CREDIT_LABEL_PATTERN, ""));
|
|
3763
|
+
if (!raw) return;
|
|
3764
|
+
const names = fragments.slice(1).join("").split("/").map(trimRuleWhitespace).filter(Boolean);
|
|
3765
|
+
return names.length ? {
|
|
3766
|
+
raw,
|
|
3767
|
+
names
|
|
3768
|
+
} : void 0;
|
|
3769
|
+
};
|
|
3770
|
+
/**
|
|
3771
|
+
* Reads JSON display text and splits the joined credit content at slash separators.
|
|
3772
|
+
*
|
|
3773
|
+
* @returns A readable display line and whether all source fields are represented, or null for other JSON.
|
|
3774
|
+
*/
|
|
3775
|
+
var readJsonLine = (text) => {
|
|
3776
|
+
try {
|
|
3777
|
+
const value = JSON.parse(text);
|
|
3778
|
+
if (!isObject(value) || typeof value.t !== "number" || !Number.isSafeInteger(value.t) || value.t < 0 || !Array.isArray(value.c)) return null;
|
|
3779
|
+
const fragments = [];
|
|
3780
|
+
let complete = Object.keys(value).every((key) => key === "t" || key === "c");
|
|
3781
|
+
for (const fragment of value.c) {
|
|
3782
|
+
if (!isObject(fragment) || typeof fragment.tx !== "string") return null;
|
|
3783
|
+
fragments.push(fragment.tx);
|
|
3784
|
+
complete = complete && Object.keys(fragment).every((key) => key === "tx");
|
|
3785
|
+
}
|
|
3786
|
+
const content = fragments.join("");
|
|
3787
|
+
return /[^\p{White_Space}]/u.test(content) ? {
|
|
3788
|
+
start: value.t,
|
|
3789
|
+
text: content,
|
|
3790
|
+
complete,
|
|
3791
|
+
credit: readCredit(fragments)
|
|
3792
|
+
} : null;
|
|
3793
|
+
} catch {
|
|
3794
|
+
return null;
|
|
3795
|
+
}
|
|
3796
|
+
};
|
|
3797
|
+
var appendWords = (words, text, time) => {
|
|
3798
|
+
for (const fragment of text.split(/(\p{White_Space}+)/u)) if (fragment) words.push(SEPARATORS.test(fragment) ? makeWordSpace({ text: fragment }) : makeWordNormal({
|
|
3799
|
+
text: fragment,
|
|
3800
|
+
time
|
|
3801
|
+
}));
|
|
3802
|
+
};
|
|
3803
|
+
var readLine = (body, start, duration) => {
|
|
3804
|
+
const time = makeTimeRange({
|
|
3805
|
+
start,
|
|
3806
|
+
end: start + duration
|
|
3807
|
+
});
|
|
3808
|
+
const pieces = scanWordTags(body, WORD_PATTERN);
|
|
3809
|
+
if (!pieces.some((piece) => piece.tag !== void 0)) return /[^\p{White_Space}]/u.test(body) ? makeLineNormal({
|
|
3810
|
+
time,
|
|
3811
|
+
content: makeLineContentText(body)
|
|
3812
|
+
}) : makeLineInstrumental({ time });
|
|
3813
|
+
const words = [];
|
|
3814
|
+
for (const piece of pieces) {
|
|
3815
|
+
if (piece.tag === void 0) {
|
|
3816
|
+
appendWords(words, piece.text);
|
|
3817
|
+
continue;
|
|
3818
|
+
}
|
|
3819
|
+
const [wordStart, wordDuration] = piece.tag.split(",").map(Number);
|
|
3820
|
+
appendWords(words, piece.text, makeTimeRange({
|
|
3821
|
+
start: wordStart,
|
|
3822
|
+
end: wordStart + wordDuration
|
|
3823
|
+
}));
|
|
3824
|
+
}
|
|
3825
|
+
return words.some((word) => word.type === WordType.NORMAL) ? makeLineNormal({
|
|
3826
|
+
time,
|
|
3827
|
+
content: makeLineContentWords(words)
|
|
3828
|
+
}) : makeLineInstrumental({ time });
|
|
3829
|
+
};
|
|
3830
|
+
var readTrack = (text, timings) => {
|
|
3831
|
+
const tags = [];
|
|
3832
|
+
const native = [];
|
|
3833
|
+
const fallback = [];
|
|
3834
|
+
const displays = [];
|
|
3835
|
+
const rows = /^\s*</.test(text) ? [] : text.replace(/^\uFEFF/, "").split(/\r\n?|\n/);
|
|
3836
|
+
for (const [row, raw] of rows.entries()) {
|
|
3837
|
+
const line = raw.trimStart();
|
|
3838
|
+
const header = LINE_PATTERN.exec(line);
|
|
3839
|
+
if (header) {
|
|
3840
|
+
native.push({
|
|
3841
|
+
row,
|
|
3842
|
+
line: readLine(line.slice(header[0].length), Number(header[1]), Number(header[2]))
|
|
3843
|
+
});
|
|
3844
|
+
fallback.push("");
|
|
3845
|
+
} else if (line.startsWith("{")) {
|
|
3846
|
+
const display = readJsonLine(line);
|
|
3847
|
+
if (display) {
|
|
3848
|
+
displays.push({
|
|
3849
|
+
row,
|
|
3850
|
+
display: true,
|
|
3851
|
+
credit: !!display.credit,
|
|
3852
|
+
line: makeLineNormal({
|
|
3853
|
+
time: makeTimeRange({ start: display.start }),
|
|
3854
|
+
content: makeLineContentText(display.text)
|
|
3855
|
+
})
|
|
3856
|
+
});
|
|
3857
|
+
if (display.credit) {
|
|
3858
|
+
const { raw, names } = display.credit;
|
|
3859
|
+
for (const value of names) tags.push({
|
|
3860
|
+
row,
|
|
3861
|
+
unknown: true,
|
|
3862
|
+
tag: {
|
|
3863
|
+
key: raw,
|
|
3864
|
+
value
|
|
3865
|
+
}
|
|
3866
|
+
});
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
if (!display?.complete) tags.push({
|
|
3870
|
+
row,
|
|
3871
|
+
unknown: true,
|
|
3872
|
+
tag: {
|
|
3873
|
+
key: "yrc:json",
|
|
3874
|
+
value: raw
|
|
3875
|
+
}
|
|
3876
|
+
});
|
|
3877
|
+
fallback.push(display ? `[${display.start}ms]` : "");
|
|
3878
|
+
} else fallback.push(raw);
|
|
3879
|
+
}
|
|
3880
|
+
const clock = [];
|
|
3881
|
+
const displayRows = new Set(displays.map((entry) => entry.row));
|
|
3882
|
+
readLrc(fallback.join("\n"), {
|
|
3883
|
+
onLine: (line, timing, row) => {
|
|
3884
|
+
if (displayRows.has(row)) return;
|
|
3885
|
+
clock.push({
|
|
3886
|
+
row,
|
|
3887
|
+
line
|
|
3888
|
+
});
|
|
3889
|
+
timings?.set(line, timing);
|
|
3890
|
+
},
|
|
3891
|
+
onMeta: (tag, row) => {
|
|
3892
|
+
tags.push({
|
|
3893
|
+
row,
|
|
3894
|
+
tag
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
const entries = [...displays, ...native.length ? native : clock].sort((first, second) => first.row - second.row);
|
|
3899
|
+
for (const [index, entry] of entries.entries()) {
|
|
3900
|
+
if (!entry.display) continue;
|
|
3901
|
+
const next = entries[index + 1]?.line.time?.start;
|
|
3902
|
+
if (next !== void 0 && next >= entry.line.time.start) entry.line.time.end = next;
|
|
3903
|
+
}
|
|
3904
|
+
const metaTags = tags.sort((first, second) => first.row - second.row);
|
|
3905
|
+
return {
|
|
3906
|
+
entries: entries.filter((entry) => !entry.credit),
|
|
3907
|
+
metaTags
|
|
3908
|
+
};
|
|
3909
|
+
};
|
|
3910
|
+
var readAnnotations = (text, timings) => {
|
|
3911
|
+
return text ? readTrack(text, timings).entries.filter((entry) => !entry.display).map((entry) => entry.line) : [];
|
|
3912
|
+
};
|
|
3913
|
+
/**
|
|
3914
|
+
* Parses native YRC or LRC fallback, keeping JSON display rows separate from annotation matching.
|
|
3915
|
+
* Explicit line and word durations remain unchanged, including zero durations and source timing violations.
|
|
3916
|
+
* JSON credit content becomes separate plain-text unknown entries sharing the source label and is omitted from lyric lines.
|
|
3917
|
+
*
|
|
3918
|
+
* @param input Plaintext tracks on the same timeline: yrc with ytlrc/yromalrc, or lrc with tlyric/romalrc.
|
|
3919
|
+
* @param options Independent line and word tolerances, with optional local roman recovery.
|
|
3920
|
+
*
|
|
3921
|
+
* @returns Canonical lyrics and model diagnostics; no readable timed rows yields INVALID status.
|
|
3922
|
+
*/
|
|
3923
|
+
var parseYrc = (input, options = {}) => {
|
|
3924
|
+
const source = readYrcContent(input);
|
|
3925
|
+
if (!source) return finalizeLyric(makeLyric({
|
|
3926
|
+
format: "yrc",
|
|
3927
|
+
status: LyricStatus.INVALID
|
|
3928
|
+
}));
|
|
3929
|
+
const parsed = readTrack(source.original);
|
|
3930
|
+
const meta = readLrcMetaTags(parsed.metaTags.filter((entry) => !entry.unknown).map((entry) => entry.tag));
|
|
3931
|
+
let unknownIndex = 0;
|
|
3932
|
+
meta.unknowns = parsed.metaTags.flatMap(({ tag, unknown }) => {
|
|
3933
|
+
if (unknown) return [makeUnknownEntry(tag)];
|
|
3934
|
+
const entry = meta.unknowns[unknownIndex];
|
|
3935
|
+
if (entry?.key === tag.key && entry.value === tag.value) {
|
|
3936
|
+
unknownIndex += 1;
|
|
3937
|
+
return [entry];
|
|
3938
|
+
}
|
|
3939
|
+
return [];
|
|
3940
|
+
});
|
|
3941
|
+
const body = parsed.entries.filter((entry) => !entry.display).map((entry) => entry.line);
|
|
3942
|
+
const timings = /* @__PURE__ */ new WeakMap();
|
|
3943
|
+
const annotated = attachAnnotations(body, {
|
|
3944
|
+
translations: readAnnotations(source.translations, timings),
|
|
3945
|
+
romans: readAnnotations(source.romans, timings)
|
|
3946
|
+
}, {
|
|
3947
|
+
preservePlaceholders: true,
|
|
3948
|
+
tolerance: options.annotationTolerance,
|
|
3949
|
+
wordTolerance: options.wordAnnotationTolerance,
|
|
3950
|
+
wordFallback: options.wordAnnotationFallback,
|
|
3951
|
+
getTiming: (line) => timings.get(line)
|
|
3952
|
+
});
|
|
3953
|
+
let index = 0;
|
|
3954
|
+
const lines = parsed.entries.map((entry) => {
|
|
3955
|
+
return entry.display ? entry.line : annotated[index++];
|
|
3956
|
+
});
|
|
3957
|
+
return finalizeLyric(makeLyric({
|
|
3958
|
+
format: "yrc",
|
|
3959
|
+
status: lines.length ? LyricStatus.VALID : LyricStatus.INVALID,
|
|
3960
|
+
timing: lines.some(isWordLevelLine) ? Timing.WORD : Timing.LINE,
|
|
3961
|
+
meta,
|
|
3962
|
+
lines,
|
|
3963
|
+
languages: deriveLanguageUsages(lines)
|
|
3964
|
+
}));
|
|
3965
|
+
};
|
|
3966
|
+
//#endregion
|
|
3967
|
+
//#region src/formats/yrc/detect.ts
|
|
3968
|
+
/**
|
|
3969
|
+
* Recognizes native word tags or a JSON display line accompanied by LRC content in the original track.
|
|
3970
|
+
*
|
|
3971
|
+
* @returns Whether the original plaintext carries a YRC signal stronger than its shared QRC line header.
|
|
3972
|
+
*/
|
|
3973
|
+
var detectYrc = (input) => {
|
|
3974
|
+
const source = readYrcContent(input);
|
|
3975
|
+
if (!source) return false;
|
|
3976
|
+
let display = false;
|
|
3977
|
+
const fallback = [];
|
|
3978
|
+
for (const raw of source.original.split(/\r\n?|\n/)) {
|
|
3979
|
+
const line = raw.trimStart();
|
|
3980
|
+
const header = LINE_PATTERN.exec(line);
|
|
3981
|
+
if (header && line.slice(header[0].length).match(WORD_PATTERN)) return true;
|
|
3982
|
+
if (line.startsWith("{")) display = display || !!readJsonLine(line);
|
|
3983
|
+
else fallback.push(raw);
|
|
3984
|
+
}
|
|
3985
|
+
return display && detectLrc(fallback.join("\n"));
|
|
3986
|
+
};
|
|
3987
|
+
//#endregion
|
|
2297
3988
|
//#region src/formats/index.ts
|
|
2298
3989
|
/**
|
|
2299
3990
|
* Recognition reads only the original text; the annotation tracks never decide the format.
|
|
@@ -2301,6 +3992,10 @@ var parseTtml = (input, options = {}) => {
|
|
|
2301
3992
|
* Unrecognized text returns `undefined`.
|
|
2302
3993
|
*/
|
|
2303
3994
|
var detectFormat = (input) => {
|
|
3995
|
+
if (detectKrc(input)) return "krc";
|
|
3996
|
+
if (detectYrc(input)) return "yrc";
|
|
3997
|
+
if (detectQrc(input)) return "qrc";
|
|
3998
|
+
if (detectKwrc(input)) return "kwrc";
|
|
2304
3999
|
if (detectLrc(input)) return "lrc";
|
|
2305
4000
|
if (detectTtml(input)) return "ttml";
|
|
2306
4001
|
};
|
|
@@ -2312,8 +4007,12 @@ var parse = (input, options = {}) => {
|
|
|
2312
4007
|
const format = detectFormat(input);
|
|
2313
4008
|
if (!format) return null;
|
|
2314
4009
|
switch (format) {
|
|
4010
|
+
case "krc": return parseKrc(input);
|
|
4011
|
+
case "kwrc": return parseKwrc(input);
|
|
2315
4012
|
case "lrc": return parseLrc(input, options);
|
|
4013
|
+
case "qrc": return parseQrc(input, options);
|
|
2316
4014
|
case "ttml": return parseTtml(input, options);
|
|
4015
|
+
case "yrc": return parseYrc(input, options);
|
|
2317
4016
|
}
|
|
2318
4017
|
};
|
|
2319
4018
|
var GENERATORS = {
|
|
@@ -2323,7 +4022,7 @@ var GENERATORS = {
|
|
|
2323
4022
|
/**
|
|
2324
4023
|
* Generates text in the requested format, which need not match `lyric.format`.
|
|
2325
4024
|
*
|
|
2326
|
-
* The writer map pairs
|
|
4025
|
+
* The writer map pairs options with results only for formats that have an implemented writer.
|
|
2327
4026
|
* An unknown target returns `null`.
|
|
2328
4027
|
*/
|
|
2329
4028
|
var generate = (lyric, target, options = {}) => {
|
|
@@ -2331,6 +4030,6 @@ var generate = (lyric, target, options = {}) => {
|
|
|
2331
4030
|
return GENERATORS[target](lyric, options);
|
|
2332
4031
|
};
|
|
2333
4032
|
//#endregion
|
|
2334
|
-
export { detectFormat, detectLrc, detectTtml, generate, generateLrc, generateTtml, parse, parseLrc, parseTtml };
|
|
4033
|
+
export { META_PATTERN, TAG_PATTERN, attachRubies, detectFormat, detectKrc, detectKwrc, detectLrc, detectQrc, detectTtml, detectYrc, generate, generateLrc, generateTtml, isPlaceholderText, parse, parseKrc, parseKwrc, parseLrc, parseQrc, parseTtml, parseWordTime, parseYrc, readJsonLine, readKrc, readLrc, readLrcMetaTags, readRubyEntries, scanWordTags, splitWordTags };
|
|
2335
4034
|
|
|
2336
4035
|
//# sourceMappingURL=formats.js.map
|