@gondola-lyrics/kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +130 -2
- package/dist/formats.d.ts +85 -0
- package/dist/formats.js +610 -0
- package/dist/formats.js.map +1 -0
- package/dist/generator.d.ts +47 -0
- package/dist/generator.js +1 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +3 -0
- package/dist/parser.d.ts +46 -0
- package/dist/parser.js +1 -0
- package/dist/transforms.d.ts +1 -0
- package/dist/transforms.js +0 -0
- package/package.json +72 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 folltoshe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Gondola Lyric Kit For Node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gondola lyrics toolkit: parses and generates lyric.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @gondola-lyrics/kit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Supports
|
|
12
|
+
|
|
13
|
+
| Format | Parse | Generate | Annotation tracks | Notes |
|
|
14
|
+
| ------ | ----- | -------- | ----------------- | -------------------------------------------------------------------- |
|
|
15
|
+
| LRC | ✅ | ✅ | ✅ | Line and enhanced word timing, metadata, translations, romanizations |
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
> [!IMPORTANT]
|
|
20
|
+
> This package is ESM-only.
|
|
21
|
+
> In your TS config, use `module: node20` / `module: nodenext` / `moduleResolution: bundler`.
|
|
22
|
+
|
|
23
|
+
Parsing returns a `runtime.Lyric`, so the result is ready to validate and hand to a player without a second conversion step.
|
|
24
|
+
|
|
25
|
+
### Parse
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { parse, parseLrc } from '@gondola-lyrics/kit'
|
|
29
|
+
|
|
30
|
+
const content = `[00:01.00]Like the first time tangled by the fishing line`
|
|
31
|
+
const lyric = parse(content)
|
|
32
|
+
|
|
33
|
+
lyric?.format // => 'lrc'
|
|
34
|
+
lyric?.lines[0]?.time?.start // => 1000
|
|
35
|
+
|
|
36
|
+
// `parse` recognizes the format and returns `null` when it cannot.
|
|
37
|
+
// `parseLrc` takes a known format and never returns `null`, so an unreadable source only carries `status: INVALID`.
|
|
38
|
+
parseLrc('no timestamps here').status // => LyricStatus.INVALID
|
|
39
|
+
|
|
40
|
+
// LRC keeps translations and romanizations in separate files, attached here by timestamp.
|
|
41
|
+
const annotated = parseLrc({
|
|
42
|
+
original: `[00:01.00]Like the first time
|
|
43
|
+
[00:03.00]tangled by the line`,
|
|
44
|
+
translations: `[00:01.00]就像第一次
|
|
45
|
+
[00:03.00]被渔线缠住那样`,
|
|
46
|
+
romans: `[00:01.00]raiku za fasuto taimu
|
|
47
|
+
[00:03.00]tanguru do bai za rain`,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
annotated.lines[0]?.annotation?.translations[0].text // => '就像第一次'
|
|
51
|
+
annotated.lines[0]?.annotation?.romans[0].text // => 'raiku za fasuto taimu'
|
|
52
|
+
|
|
53
|
+
// An exact timestamp match is assigned before any fuzzy one, and rows matching no line are dropped.
|
|
54
|
+
// A drifting track attaches within a tolerance, where the nearest unclaimed line wins.
|
|
55
|
+
const drifted = {
|
|
56
|
+
original: `[00:01.00]Like the first time
|
|
57
|
+
[00:03.00]tangled by the line`,
|
|
58
|
+
translations: `[00:01.30]就像第一次
|
|
59
|
+
[00:03.20]被渔线缠住那样`,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
parseLrc(drifted).lines[0]?.annotation // => undefined
|
|
63
|
+
parseLrc(drifted, { annotationTolerance: 500 }).lines[0]?.annotation?.translations[0].text // => '就像第一次'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Generate
|
|
67
|
+
|
|
68
|
+
`generate` takes the target format explicitly, so a lyric parsed from one format can be written out as another.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { generate, parseLrc } from '@gondola-lyrics/kit'
|
|
72
|
+
|
|
73
|
+
const lyric = parseLrc('[00:01.00]Like the first time')
|
|
74
|
+
|
|
75
|
+
// Separate tracks come back as separate fields, so the result parses again unchanged.
|
|
76
|
+
const { original, translations, romans } = generate(lyric, 'lrc')
|
|
77
|
+
|
|
78
|
+
parseLrc(generate(lyric, 'lrc')) // round-trips
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`generateLrc` is the same thing without the dispatch step.
|
|
82
|
+
|
|
83
|
+
### Detect
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { detectFormat } from '@gondola-lyrics/kit'
|
|
87
|
+
|
|
88
|
+
detectFormat('[00:01.00]Like the first time') // => 'lrc'
|
|
89
|
+
detectFormat('plain text without timestamps') // => undefined
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Detection reads only the original text. It answers whether the source is recognizable, not whether it is valid: a recognized source can still parse into an `INVALID` lyric.
|
|
93
|
+
|
|
94
|
+
### Validation
|
|
95
|
+
|
|
96
|
+
The kit does not report diagnostics of its own. Parsing keeps the source as written, and `@gondola-lyrics/model` decides what is valid.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { canonicalizeLyric, validateLyric } from '@gondola-lyrics/model/runtime'
|
|
100
|
+
|
|
101
|
+
const lyric = parseLrc(source)
|
|
102
|
+
|
|
103
|
+
validateLyric(canonicalizeLyric(lyric)) // => []
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Entries
|
|
107
|
+
|
|
108
|
+
| Import | Contains |
|
|
109
|
+
| ------------------------------- | ---------- |
|
|
110
|
+
| `@gondola-lyrics/kit` | Everything |
|
|
111
|
+
| `@gondola-lyrics/kit/parser` | Parsing |
|
|
112
|
+
| `@gondola-lyrics/kit/generator` | Generation |
|
|
113
|
+
|
|
114
|
+
## Commands
|
|
115
|
+
|
|
116
|
+
**Build**
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm run build
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Check**
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm run check
|
|
126
|
+
npm run format
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { runtime } from '@gondola-lyrics/model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recognition reads only the original text; the annotation tracks never decide the format.
|
|
5
|
+
*
|
|
6
|
+
* Unrecognized text returns `undefined`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const detectFormat: (input: unknown) => "lrc" | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single readable time tag is enough for recognition; metadata alone is not.
|
|
12
|
+
* This is a format probe, not a validity check, so an input of another shape is simply not LRC.
|
|
13
|
+
*/
|
|
14
|
+
export declare const detectLrc: (input: unknown) => boolean;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates text in the requested format, which need not match `lyric.format`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const generate: (lyric: runtime.Lyric, target: LyricFormat, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates the LRC text plus the annotations a single file cannot hold.
|
|
23
|
+
*
|
|
24
|
+
* Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
|
|
25
|
+
*/
|
|
26
|
+
export declare const generateLrc: (lyric: runtime.Lyric, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
27
|
+
|
|
28
|
+
declare interface LrcContent {
|
|
29
|
+
original: string;
|
|
30
|
+
translations?: string;
|
|
31
|
+
romans?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export declare interface LrcGenerateOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Word-level timestamp style; defaults to `angle`.
|
|
37
|
+
*
|
|
38
|
+
* `angle` is the enhanced LRC form and round-trips through this parser.
|
|
39
|
+
*
|
|
40
|
+
* `bracket` matches line timestamps, so this parser reads it as new lines rather than words.
|
|
41
|
+
*/
|
|
42
|
+
wordTiming?: 'angle' | 'bracket' | 'none';
|
|
43
|
+
/** Clock format for every timestamp; defaults to `mm:ss.SSS`. */
|
|
44
|
+
timeFormat?: string;
|
|
45
|
+
/** Emit the metadata header; defaults to `true`. */
|
|
46
|
+
includeMeta?: boolean;
|
|
47
|
+
/** Append the word duration to its tag, as in `<00:01.00,500>`. */
|
|
48
|
+
includeWordEnd?: boolean;
|
|
49
|
+
/** Emit instrumental lines as a timestamp with no text; defaults to `true`. */
|
|
50
|
+
includeInstrumental?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export declare type LrcGenerateResult = LrcContent;
|
|
54
|
+
|
|
55
|
+
export declare type LrcParseInput = LrcContent;
|
|
56
|
+
|
|
57
|
+
export declare interface LrcParseOptions {
|
|
58
|
+
/**
|
|
59
|
+
* How far an annotation's timestamp may sit from a line's start, in milliseconds.
|
|
60
|
+
* Defaults to `0`, which requires an exact match.
|
|
61
|
+
*/
|
|
62
|
+
annotationTolerance?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Values match the lowercase format names stored in `Lyric.format`.
|
|
67
|
+
*/
|
|
68
|
+
export declare type LyricFormat = 'lrc';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
|
|
72
|
+
* Annotation tracks travel with the source and are attached by the format's parser.
|
|
73
|
+
*/
|
|
74
|
+
export declare const parse: (input: unknown, options?: LrcParseOptions) => runtime.Lyric | null;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Source order, empty timed lines, and missing word times are preserved for model validation.
|
|
78
|
+
*
|
|
79
|
+
* Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
|
|
80
|
+
*
|
|
81
|
+
* `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
|
|
82
|
+
*/
|
|
83
|
+
export declare const parseLrc: (input: string | LrcParseInput, options?: LrcParseOptions) => runtime.Lyric;
|
|
84
|
+
|
|
85
|
+
export { }
|
package/dist/formats.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { LineType, LyricStatus, Timing, WordType, deriveLanguageUsages, getWordsText, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation, makeLineContentText, makeLineContentWords, makeLineNormal, makeLyric, makeMeta, makeText, makeTimeRange, makeUnknownEntry, makeWordNormal, makeWordSpace } from "@gondola-lyrics/model/runtime";
|
|
2
|
+
//#region src/utils/annotation.ts
|
|
3
|
+
/**
|
|
4
|
+
* An annotation line carries its text in either field, depending on whether the track used word tags.
|
|
5
|
+
*/
|
|
6
|
+
var readText = (line) => {
|
|
7
|
+
return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim();
|
|
8
|
+
};
|
|
9
|
+
var isReadable = (line) => {
|
|
10
|
+
return line.time?.start !== void 0 && readText(line) !== "";
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Assigns each annotation line to a lyric line index.
|
|
14
|
+
*
|
|
15
|
+
* Exact matches claim their lines first, so a fuzzy match never takes a line an exact one needed.
|
|
16
|
+
* The remaining annotations take the nearest unclaimed line within the tolerance, and ties keep the earlier line.
|
|
17
|
+
*/
|
|
18
|
+
var matchEntries = (lines, annotations, tolerance) => {
|
|
19
|
+
const matched = /* @__PURE__ */ new Map();
|
|
20
|
+
if (annotations.length === 0) return matched;
|
|
21
|
+
const starts = lines.map((line) => line.time?.start);
|
|
22
|
+
const queues = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const [index, start] of starts.entries()) {
|
|
24
|
+
if (start === void 0) continue;
|
|
25
|
+
const queue = queues.get(start);
|
|
26
|
+
if (queue) queue.push(index);
|
|
27
|
+
else queues.set(start, [index]);
|
|
28
|
+
}
|
|
29
|
+
const used = /* @__PURE__ */ new Set();
|
|
30
|
+
const pending = [];
|
|
31
|
+
for (const annotation of annotations) {
|
|
32
|
+
const start = annotation.time?.start;
|
|
33
|
+
if (start === void 0) continue;
|
|
34
|
+
const index = queues.get(start)?.shift();
|
|
35
|
+
if (index === void 0) {
|
|
36
|
+
pending.push(annotation);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
matched.set(index, annotation);
|
|
40
|
+
used.add(index);
|
|
41
|
+
}
|
|
42
|
+
if (tolerance <= 0) return matched;
|
|
43
|
+
for (const annotation of pending) {
|
|
44
|
+
const start = annotation.time?.start;
|
|
45
|
+
if (start === void 0) continue;
|
|
46
|
+
let target = -1;
|
|
47
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
48
|
+
for (const [index, lineStart] of starts.entries()) {
|
|
49
|
+
if (lineStart === void 0 || used.has(index)) continue;
|
|
50
|
+
const current = Math.abs(lineStart - start);
|
|
51
|
+
if (current <= tolerance && current < distance) {
|
|
52
|
+
target = index;
|
|
53
|
+
distance = current;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (target !== -1) {
|
|
57
|
+
matched.set(target, annotation);
|
|
58
|
+
used.add(target);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return matched;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Attaches annotation tracks to parsed lines by start time.
|
|
65
|
+
*
|
|
66
|
+
* Annotations that match nothing are dropped, and an instrumental line consumes its annotation without carrying one.
|
|
67
|
+
* Any annotation already on a line is kept, with the matched track appended to its layer.
|
|
68
|
+
*/
|
|
69
|
+
var attachLineAnnotations = (lines, sources, options = {}) => {
|
|
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(roman),
|
|
83
|
+
language: roman.languages[0]
|
|
84
|
+
})]],
|
|
85
|
+
translations: [...line.annotation?.translations ?? [], ...translation === void 0 ? [] : [makeLineAnnotationTranslation({
|
|
86
|
+
text: readText(translation),
|
|
87
|
+
language: translation.languages[0]
|
|
88
|
+
})]]
|
|
89
|
+
})
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/utils/index.ts
|
|
95
|
+
var TIME_PATTERN = /^(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d+))?$/;
|
|
96
|
+
/**
|
|
97
|
+
* Bare integers are excluded because formats may use them as millisecond counts or offsets.
|
|
98
|
+
*/
|
|
99
|
+
var isTime = (body) => {
|
|
100
|
+
const trimmed = body.trim();
|
|
101
|
+
return (trimmed.includes(":") || trimmed.includes(".")) && TIME_PATTERN.test(trimmed);
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Fractional seconds are padded or truncated to millisecond precision.
|
|
105
|
+
*
|
|
106
|
+
* Invalid clock syntax returns `undefined`; time bounds are left to model validation.
|
|
107
|
+
*/
|
|
108
|
+
var parseTime = (body) => {
|
|
109
|
+
if (!isTime(body)) return;
|
|
110
|
+
const match = TIME_PATTERN.exec(body.trim());
|
|
111
|
+
const hour = Number(match[1] ?? 0);
|
|
112
|
+
const minute = Number(match[2] ?? 0);
|
|
113
|
+
const second = Number(match[3]);
|
|
114
|
+
const millisecond = Number((match[4] ?? "").padEnd(3, "0").slice(0, 3));
|
|
115
|
+
return ((hour * 60 + minute) * 60 + second) * 1e3 + millisecond;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Tokens `h`, `m`, `s`, and `S` select hours, minutes, seconds, and fractional seconds.
|
|
119
|
+
* Repeated unit tokens set minimum width; `S` tokens select up to three fractional digits.
|
|
120
|
+
*
|
|
121
|
+
* Omitting hours carries them into minutes; negative or non-finite input writes zero fields.
|
|
122
|
+
*/
|
|
123
|
+
var formatTime = (time, format = "mm:ss.SSS") => {
|
|
124
|
+
if (!Number.isFinite(time) || time < 0) return format.replace(/h+|m+|s+|S+/g, (run) => "0".repeat(run.length));
|
|
125
|
+
const total = Math.floor(time);
|
|
126
|
+
const totalSeconds = Math.floor(total / 1e3);
|
|
127
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
128
|
+
const millisecond = String(total % 1e3).padStart(3, "0");
|
|
129
|
+
const fields = {
|
|
130
|
+
h: String(Math.floor(totalMinutes / 60)),
|
|
131
|
+
m: String(format.includes("h") ? totalMinutes % 60 : totalMinutes),
|
|
132
|
+
s: String(totalSeconds % 60),
|
|
133
|
+
S: millisecond
|
|
134
|
+
};
|
|
135
|
+
return format.replace(/h+|m+|s+|S+/g, (run) => {
|
|
136
|
+
const field = fields[run[0]];
|
|
137
|
+
if (run[0] === "S") return field.slice(0, run.length);
|
|
138
|
+
return field.padStart(run.length, "0");
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/formats/lrc/common.ts
|
|
143
|
+
var readLrcContent = (input) => {
|
|
144
|
+
if (typeof input === "string") return { original: input };
|
|
145
|
+
if (typeof input !== "object" || input === null) return null;
|
|
146
|
+
const source = input;
|
|
147
|
+
if (typeof source.original !== "string") return null;
|
|
148
|
+
return {
|
|
149
|
+
original: source.original,
|
|
150
|
+
translations: typeof source.translations === "string" ? source.translations : void 0,
|
|
151
|
+
romans: typeof source.romans === "string" ? source.romans : void 0
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/formats/lrc/utils.ts
|
|
156
|
+
var WORD_OFFSET_PATTERN = /^(\d+)(?:,(\d+))?$/;
|
|
157
|
+
var WORD_CLOCK_DURATION_PATTERN = /^([^,]+),(\d+)$/;
|
|
158
|
+
var WORD_TAG_PATTERN = /<([^>]*)>/g;
|
|
159
|
+
var TAG_PATTERN = /\[([^\]]*)\]/g;
|
|
160
|
+
var META_PATTERN = /^([A-Za-z0-9_-]+)\s*:\s*([\s\S]*)$/;
|
|
161
|
+
/**
|
|
162
|
+
* Integer tags use `offset[,duration]` in milliseconds relative to the line start.
|
|
163
|
+
* Clock tags are absolute and may append a duration in milliseconds, as in `<00:01.50,500>`.
|
|
164
|
+
* A missing or zero duration leaves the word end unspecified.
|
|
165
|
+
*/
|
|
166
|
+
var parseWordTime = (body, lineStart) => {
|
|
167
|
+
if (isTime(body)) {
|
|
168
|
+
const start = parseTime(body);
|
|
169
|
+
return start === void 0 ? void 0 : {
|
|
170
|
+
start,
|
|
171
|
+
end: void 0
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const match = WORD_OFFSET_PATTERN.exec(body.trim());
|
|
175
|
+
if (match) {
|
|
176
|
+
const start = lineStart + Number(match[1]);
|
|
177
|
+
const duration = Number(match[2] ?? 0);
|
|
178
|
+
return {
|
|
179
|
+
start,
|
|
180
|
+
end: duration > 0 ? start + duration : void 0
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const clock = WORD_CLOCK_DURATION_PATTERN.exec(body.trim());
|
|
184
|
+
if (clock && isTime(clock[1] ?? "")) {
|
|
185
|
+
const start = parseTime(clock[1] ?? "");
|
|
186
|
+
const duration = Number(clock[2] ?? 0);
|
|
187
|
+
return start === void 0 ? void 0 : {
|
|
188
|
+
start,
|
|
189
|
+
end: duration > 0 ? start + duration : void 0
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Untagged leading text is preserved; tags without following text produce no piece.
|
|
195
|
+
* A trailing tag still yields an empty piece so it can close the preceding word.
|
|
196
|
+
*/
|
|
197
|
+
var splitWordTags = (content) => {
|
|
198
|
+
const pieces = [];
|
|
199
|
+
let tag;
|
|
200
|
+
let cursor = 0;
|
|
201
|
+
for (const match of content.matchAll(WORD_TAG_PATTERN)) {
|
|
202
|
+
const at = match.index;
|
|
203
|
+
const before = content.slice(cursor, at);
|
|
204
|
+
if (before) pieces.push({
|
|
205
|
+
text: before,
|
|
206
|
+
tag
|
|
207
|
+
});
|
|
208
|
+
tag = match[1] ?? "";
|
|
209
|
+
cursor = at + match[0].length;
|
|
210
|
+
}
|
|
211
|
+
const tail = content.slice(cursor);
|
|
212
|
+
if (tail) pieces.push({
|
|
213
|
+
text: tail,
|
|
214
|
+
tag
|
|
215
|
+
});
|
|
216
|
+
else if (tag !== void 0) pieces.push({
|
|
217
|
+
text: "",
|
|
218
|
+
tag
|
|
219
|
+
});
|
|
220
|
+
return pieces;
|
|
221
|
+
};
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/formats/lrc/detect.ts
|
|
224
|
+
/**
|
|
225
|
+
* A single readable time tag is enough for recognition; metadata alone is not.
|
|
226
|
+
* This is a format probe, not a validity check, so an input of another shape is simply not LRC.
|
|
227
|
+
*/
|
|
228
|
+
var detectLrc = (input) => {
|
|
229
|
+
const content = readLrcContent(input);
|
|
230
|
+
if (!content) return false;
|
|
231
|
+
return content.original.replace(/\r\n?/g, "\n").split("\n").some((line) => [...line.matchAll(TAG_PATTERN)].some((match) => isTime(match[1] ?? "")));
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/formats/lrc/generator.ts
|
|
235
|
+
var resolveOptions = (options = {}) => {
|
|
236
|
+
return {
|
|
237
|
+
wordTiming: options.wordTiming ?? "angle",
|
|
238
|
+
timeFormat: options.timeFormat ?? "mm:ss.SSS",
|
|
239
|
+
includeMeta: options.includeMeta ?? true,
|
|
240
|
+
includeWordEnd: options.includeWordEnd ?? false,
|
|
241
|
+
includeInstrumental: options.includeInstrumental ?? true
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
var renderTimeTag = (time, options) => {
|
|
245
|
+
return `[${formatTime(time, options.timeFormat)}]`;
|
|
246
|
+
};
|
|
247
|
+
var renderWordTag = (start, end, options) => {
|
|
248
|
+
if (options.wordTiming === "none") return "";
|
|
249
|
+
const open = options.wordTiming === "bracket" ? "[" : "<";
|
|
250
|
+
const close = options.wordTiming === "bracket" ? "]" : ">";
|
|
251
|
+
const duration = options.includeWordEnd && end !== void 0 ? `,${Math.max(0, end - start)}` : "";
|
|
252
|
+
return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`;
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Plain text joins words as written, including the separators stored as space words.
|
|
256
|
+
*/
|
|
257
|
+
var renderPlainText = (line) => {
|
|
258
|
+
const content = line.content;
|
|
259
|
+
if (!content) return "";
|
|
260
|
+
if (content.text !== void 0) return content.text;
|
|
261
|
+
return content.words.map((word) => word.text).join("");
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Word-level lines interleave tags with their text; untimed words stay untagged.
|
|
265
|
+
*/
|
|
266
|
+
var renderSyllableText = (line, options) => {
|
|
267
|
+
const content = line.content;
|
|
268
|
+
if (!content?.words.length) return renderPlainText(line);
|
|
269
|
+
return content.words.map((word) => {
|
|
270
|
+
if (word.type === WordType.SPACE) return word.text;
|
|
271
|
+
const start = word.time?.start;
|
|
272
|
+
if (start === void 0) return word.text;
|
|
273
|
+
return `${renderWordTag(start, word.time?.end, options)}${word.text}`;
|
|
274
|
+
}).join("");
|
|
275
|
+
};
|
|
276
|
+
var renderMetaRows = (meta, options) => {
|
|
277
|
+
const rows = [];
|
|
278
|
+
for (const title of meta.titles) rows.push(`[ti:${title.text}]`);
|
|
279
|
+
for (const artist of meta.artists) rows.push(`[ar:${artist.text}]`);
|
|
280
|
+
for (const album of meta.albums) rows.push(`[al:${album.text}]`);
|
|
281
|
+
for (const author of meta.authors) rows.push(`[by:${author.text}]`);
|
|
282
|
+
if (meta.offset) rows.push(`[offset:${meta.offset}]`);
|
|
283
|
+
if (meta.duration !== void 0) rows.push(`[length:${formatTime(meta.duration, options.timeFormat)}]`);
|
|
284
|
+
for (const isrc of meta.isrcs) rows.push(`[isrc:${isrc}]`);
|
|
285
|
+
for (const unknown of meta.unknowns) rows.push(`[${unknown.key}:${unknown.value}]`);
|
|
286
|
+
return rows;
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Returns `undefined` for a line the options leave out, so callers can drop it rather than emit a blank row.
|
|
290
|
+
*/
|
|
291
|
+
var renderLineRow = (line, options, wordLevel) => {
|
|
292
|
+
if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) return;
|
|
293
|
+
const time = renderTimeTag(line.time?.start ?? 0, options);
|
|
294
|
+
if (line.type === LineType.INSTRUMENTAL) return time;
|
|
295
|
+
return `${time}${wordLevel && line.content?.words.length ? renderSyllableText(line, options) : renderPlainText(line)}`;
|
|
296
|
+
};
|
|
297
|
+
var renderAnnotationRows = (lines, options, pick) => {
|
|
298
|
+
const rows = [];
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
const start = line.time?.start;
|
|
301
|
+
if (start === void 0) continue;
|
|
302
|
+
for (const item of pick(line)) rows.push(`${renderTimeTag(start, options)}${item.text}`);
|
|
303
|
+
}
|
|
304
|
+
return rows;
|
|
305
|
+
};
|
|
306
|
+
var joinBlocks = (header, body) => {
|
|
307
|
+
const blocks = [];
|
|
308
|
+
if (header.length) blocks.push(header.join("\n"));
|
|
309
|
+
if (body.length) blocks.push(body.join("\n"));
|
|
310
|
+
return blocks.join("\n\n");
|
|
311
|
+
};
|
|
312
|
+
/**
|
|
313
|
+
* Generates the LRC text plus the annotations a single file cannot hold.
|
|
314
|
+
*
|
|
315
|
+
* Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
|
|
316
|
+
*/
|
|
317
|
+
var generateLrc = (lyric, options = {}) => {
|
|
318
|
+
const resolved = resolveOptions(options);
|
|
319
|
+
const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : [];
|
|
320
|
+
const wordLevel = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0) && resolved.wordTiming !== "none";
|
|
321
|
+
const result = { original: joinBlocks(header, lyric.lines.map((line) => {
|
|
322
|
+
return renderLineRow(line, resolved, wordLevel);
|
|
323
|
+
}).filter((row) => row !== void 0)) };
|
|
324
|
+
const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? []);
|
|
325
|
+
if (translations.length) result.translations = joinBlocks(header, translations);
|
|
326
|
+
const romans = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.romans ?? []);
|
|
327
|
+
if (romans.length) result.romans = joinBlocks(header, romans);
|
|
328
|
+
return result;
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region src/formats/lrc/parser.ts
|
|
332
|
+
var isBareNumber = (value) => {
|
|
333
|
+
return /^\d+$/.test(value.trim());
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* A trailing tag alone still makes the line word-level, so it can close the last word.
|
|
337
|
+
* Unreadable angle-bracket text stays plain lyric content.
|
|
338
|
+
*/
|
|
339
|
+
var isWordTimed = (text) => {
|
|
340
|
+
return splitWordTags(text).some((piece) => piece.tag !== void 0 && parseWordTime(piece.tag, 0) !== void 0);
|
|
341
|
+
};
|
|
342
|
+
var readMeta = (body) => {
|
|
343
|
+
const match = META_PATTERN.exec(body);
|
|
344
|
+
if (!match) return;
|
|
345
|
+
return {
|
|
346
|
+
key: (match[1] ?? "").toLowerCase(),
|
|
347
|
+
value: (match[2] ?? "").trim()
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
var readGroup = (tags, text, timed, meta) => {
|
|
351
|
+
for (const tag of tags) {
|
|
352
|
+
const start = isTime(tag) ? parseTime(tag) : void 0;
|
|
353
|
+
if (start !== void 0) {
|
|
354
|
+
timed.push({
|
|
355
|
+
text,
|
|
356
|
+
start
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const pair = readMeta(tag);
|
|
361
|
+
if (pair) meta.push(pair);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Stacked timestamps share the following text, as in `[00:10][00:20]twice`.
|
|
366
|
+
* Text before the first tag is discarded because it has no timing association.
|
|
367
|
+
*/
|
|
368
|
+
var readTimedLines = (text) => {
|
|
369
|
+
const timed = [];
|
|
370
|
+
const meta = [];
|
|
371
|
+
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
372
|
+
const line = raw.trim();
|
|
373
|
+
if (!line) continue;
|
|
374
|
+
const matches = [...line.matchAll(TAG_PATTERN)];
|
|
375
|
+
let group = [];
|
|
376
|
+
for (const [index, match] of matches.entries()) {
|
|
377
|
+
const at = match.index + match[0].length;
|
|
378
|
+
const next = matches[index + 1]?.index ?? line.length;
|
|
379
|
+
const shared = line.slice(at, next);
|
|
380
|
+
group.push(match[1] ?? "");
|
|
381
|
+
if (!shared.trim()) continue;
|
|
382
|
+
readGroup(group, shared, timed, meta);
|
|
383
|
+
group = [];
|
|
384
|
+
}
|
|
385
|
+
readGroup(group, "", timed, meta);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
timed,
|
|
389
|
+
meta
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.
|
|
394
|
+
* Keys are lowercased and values trimmed, so original tag spelling is not preserved.
|
|
395
|
+
*/
|
|
396
|
+
var readMetaTags = (tags) => {
|
|
397
|
+
const meta = makeMeta();
|
|
398
|
+
for (const tag of tags) switch (tag.key) {
|
|
399
|
+
case "length":
|
|
400
|
+
case "duration": {
|
|
401
|
+
const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : NaN);
|
|
402
|
+
if (Number.isFinite(duration)) meta.duration = Math.trunc(duration);
|
|
403
|
+
else meta.unknowns.push(makeUnknownEntry({
|
|
404
|
+
key: tag.key,
|
|
405
|
+
value: tag.value
|
|
406
|
+
}));
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "offset":
|
|
410
|
+
meta.offset = Math.trunc(Number(tag.value)) || 0;
|
|
411
|
+
break;
|
|
412
|
+
case "ti":
|
|
413
|
+
case "title":
|
|
414
|
+
meta.titles.push(makeText({ text: tag.value }));
|
|
415
|
+
break;
|
|
416
|
+
case "ar":
|
|
417
|
+
case "artist":
|
|
418
|
+
meta.artists.push(makeText({ text: tag.value }));
|
|
419
|
+
break;
|
|
420
|
+
case "al":
|
|
421
|
+
case "album":
|
|
422
|
+
meta.albums.push(makeText({ text: tag.value }));
|
|
423
|
+
break;
|
|
424
|
+
case "by":
|
|
425
|
+
case "author":
|
|
426
|
+
meta.authors.push(makeText({ text: tag.value }));
|
|
427
|
+
break;
|
|
428
|
+
case "isrc":
|
|
429
|
+
meta.isrcs.push(tag.value);
|
|
430
|
+
break;
|
|
431
|
+
default: meta.unknowns.push(makeUnknownEntry({
|
|
432
|
+
key: tag.key,
|
|
433
|
+
value: tag.value
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
return meta;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* Whitespace becomes a space word preserving the source run, as in `lo World`.
|
|
440
|
+
* A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.
|
|
441
|
+
* Explicit word ends are kept as written, so duration tags survive unchanged.
|
|
442
|
+
*/
|
|
443
|
+
var readWords = (text, lineStart) => {
|
|
444
|
+
const drafts = [];
|
|
445
|
+
const open = /* @__PURE__ */ new Set();
|
|
446
|
+
const closeSegment = (boundary) => {
|
|
447
|
+
if (boundary === void 0) return;
|
|
448
|
+
for (const draft of open) if (draft.start !== void 0 && draft.end === void 0 && boundary >= draft.start) draft.end = boundary;
|
|
449
|
+
open.clear();
|
|
450
|
+
};
|
|
451
|
+
let segmentStart = lineStart;
|
|
452
|
+
let cursor = lineStart;
|
|
453
|
+
for (const piece of splitWordTags(text)) {
|
|
454
|
+
const time = piece.tag === void 0 ? void 0 : parseWordTime(piece.tag, lineStart);
|
|
455
|
+
if (time?.start !== void 0) {
|
|
456
|
+
closeSegment(time.start);
|
|
457
|
+
segmentStart = time.start;
|
|
458
|
+
cursor = time.start;
|
|
459
|
+
}
|
|
460
|
+
if (!piece.text) continue;
|
|
461
|
+
let applied = false;
|
|
462
|
+
for (const run of piece.text.split(/(\s+)/)) {
|
|
463
|
+
if (!run) continue;
|
|
464
|
+
if (!run.trim()) {
|
|
465
|
+
if (drafts.length) drafts.push({
|
|
466
|
+
text: run,
|
|
467
|
+
space: true
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const tagged = !applied && time?.start !== void 0;
|
|
472
|
+
applied = true;
|
|
473
|
+
const start = tagged ? segmentStart : cursor;
|
|
474
|
+
const end = tagged ? time?.end : void 0;
|
|
475
|
+
const draft = {
|
|
476
|
+
text: run,
|
|
477
|
+
start,
|
|
478
|
+
end
|
|
479
|
+
};
|
|
480
|
+
drafts.push(draft);
|
|
481
|
+
if (end !== void 0) cursor = end;
|
|
482
|
+
else open.add(draft);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
drafts,
|
|
487
|
+
closeSegment
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
/**
|
|
491
|
+
* A line ends at the next line's start; without one, it ends at its final word's end.
|
|
492
|
+
* An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.
|
|
493
|
+
*/
|
|
494
|
+
var readLine = (text, start, nextStart) => {
|
|
495
|
+
const wordTimed = isWordTimed(text);
|
|
496
|
+
const { drafts, closeSegment } = wordTimed ? readWords(text, start) : {
|
|
497
|
+
drafts: [],
|
|
498
|
+
closeSegment: () => {}
|
|
499
|
+
};
|
|
500
|
+
let end = nextStart !== void 0 && nextStart >= start ? nextStart : void 0;
|
|
501
|
+
if (end === void 0) end = [...drafts].reverse().find((draft) => !draft.space && draft.start !== void 0)?.end;
|
|
502
|
+
closeSegment(end);
|
|
503
|
+
const words = drafts.map((draft) => {
|
|
504
|
+
if (draft.space) return makeWordSpace({ text: draft.text });
|
|
505
|
+
const time = draft.start === void 0 ? void 0 : makeTimeRange({
|
|
506
|
+
start: draft.start,
|
|
507
|
+
end: draft.end
|
|
508
|
+
});
|
|
509
|
+
return makeWordNormal({
|
|
510
|
+
text: draft.text,
|
|
511
|
+
time
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
const time = makeTimeRange({
|
|
515
|
+
start,
|
|
516
|
+
end
|
|
517
|
+
});
|
|
518
|
+
const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(text.trim());
|
|
519
|
+
return makeLineNormal({
|
|
520
|
+
time,
|
|
521
|
+
content
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.
|
|
526
|
+
* An empty timed row still yields a line, since it marks a gap rather than a parse failure.
|
|
527
|
+
*/
|
|
528
|
+
var readLines = (timed) => {
|
|
529
|
+
const lines = [];
|
|
530
|
+
for (const [index, item] of timed.entries()) {
|
|
531
|
+
const next = timed[index + 1]?.start;
|
|
532
|
+
lines.push(readLine(item.text, item.start, next));
|
|
533
|
+
}
|
|
534
|
+
return lines;
|
|
535
|
+
};
|
|
536
|
+
var parseLrcText = (text) => {
|
|
537
|
+
const { timed, meta } = readTimedLines(text);
|
|
538
|
+
const metaTags = readMetaTags(meta);
|
|
539
|
+
if (!timed.length) return makeLyric({
|
|
540
|
+
format: "lrc",
|
|
541
|
+
status: LyricStatus.INVALID,
|
|
542
|
+
meta: metaTags
|
|
543
|
+
});
|
|
544
|
+
const lines = readLines(timed);
|
|
545
|
+
const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0);
|
|
546
|
+
return makeLyric({
|
|
547
|
+
format: "lrc",
|
|
548
|
+
status: LyricStatus.VALID,
|
|
549
|
+
timing: wordTimed ? Timing.WORD : Timing.LINE,
|
|
550
|
+
meta: metaTags,
|
|
551
|
+
languages: deriveLanguageUsages(lines),
|
|
552
|
+
lines
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
/**
|
|
556
|
+
* Source order, empty timed lines, and missing word times are preserved for model validation.
|
|
557
|
+
*
|
|
558
|
+
* Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
|
|
559
|
+
*
|
|
560
|
+
* `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
|
|
561
|
+
*/
|
|
562
|
+
var parseLrc = (input, options = {}) => {
|
|
563
|
+
const source = readLrcContent(input) ?? { original: "" };
|
|
564
|
+
const lyric = parseLrcText(source.original);
|
|
565
|
+
if (lyric.status === LyricStatus.INVALID) return lyric;
|
|
566
|
+
const translations = source.translations === void 0 ? [] : readLines(readTimedLines(source.translations).timed);
|
|
567
|
+
const romans = source.romans === void 0 ? [] : readLines(readTimedLines(source.romans).timed);
|
|
568
|
+
if (translations.length === 0 && romans.length === 0) return lyric;
|
|
569
|
+
const lines = attachLineAnnotations(lyric.lines, {
|
|
570
|
+
translations,
|
|
571
|
+
romans
|
|
572
|
+
}, { tolerance: options.annotationTolerance });
|
|
573
|
+
return {
|
|
574
|
+
...lyric,
|
|
575
|
+
lines
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
//#endregion
|
|
579
|
+
//#region src/formats/index.ts
|
|
580
|
+
/**
|
|
581
|
+
* Recognition reads only the original text; the annotation tracks never decide the format.
|
|
582
|
+
*
|
|
583
|
+
* Unrecognized text returns `undefined`.
|
|
584
|
+
*/
|
|
585
|
+
var detectFormat = (input) => {
|
|
586
|
+
if (detectLrc(input)) return "lrc";
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
|
|
590
|
+
* Annotation tracks travel with the source and are attached by the format's parser.
|
|
591
|
+
*/
|
|
592
|
+
var parse = (input, options = {}) => {
|
|
593
|
+
const format = detectFormat(input);
|
|
594
|
+
if (!format) return null;
|
|
595
|
+
switch (format) {
|
|
596
|
+
case "lrc": return parseLrc(input, options);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
/**
|
|
600
|
+
* Generates text in the requested format, which need not match `lyric.format`.
|
|
601
|
+
*/
|
|
602
|
+
var generate = (lyric, target, options = {}) => {
|
|
603
|
+
switch (target) {
|
|
604
|
+
case "lrc": return generateLrc(lyric, options);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
//#endregion
|
|
608
|
+
export { detectFormat, detectLrc, generate, generateLrc, parse, parseLrc };
|
|
609
|
+
|
|
610
|
+
//# sourceMappingURL=formats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formats.js","names":[],"sources":["../src/utils/annotation.ts","../src/utils/index.ts","../src/formats/lrc/common.ts","../src/formats/lrc/utils.ts","../src/formats/lrc/detect.ts","../src/formats/lrc/generator.ts","../src/formats/lrc/parser.ts","../src/formats/index.ts"],"sourcesContent":["import type { runtime } from '@gondola-lyrics/model'\n\nimport { LineType, getWordsText, makeLineAnnotation, makeLineAnnotationRoman, makeLineAnnotationTranslation } from '@gondola-lyrics/model/runtime'\n\nexport interface AnnotationSources {\n translations?: runtime.Line[]\n romans?: runtime.Line[]\n}\n\nexport interface AnnotationMatchOptions {\n /**\n * How far an annotation's start may sit from a line's start, in milliseconds.\n * Defaults to `0`, which requires an exact match.\n */\n tolerance?: number\n}\n\n/**\n * An annotation line carries its text in either field, depending on whether the track used word tags.\n */\nconst readText = (line: runtime.Line) => {\n return (line.content?.text ?? getWordsText(line.content?.words ?? [])).trim()\n}\n\nconst isReadable = (line: runtime.Line) => {\n return line.time?.start !== undefined && readText(line) !== ''\n}\n\n/**\n * Assigns each annotation line to a lyric line index.\n *\n * Exact matches claim their lines first, so a fuzzy match never takes a line an exact one needed.\n * The remaining annotations take the nearest unclaimed line within the tolerance, and ties keep the earlier line.\n */\nconst matchEntries = (lines: runtime.Line[], annotations: runtime.Line[], tolerance: number) => {\n const matched = new Map<number, runtime.Line>()\n if (annotations.length === 0) {\n return matched\n }\n\n const starts = lines.map((line) => line.time?.start)\n const queues = new Map<number, number[]>()\n\n for (const [index, start] of starts.entries()) {\n if (start === undefined) {\n continue\n }\n const queue = queues.get(start)\n if (queue) {\n queue.push(index)\n } else {\n queues.set(start, [index])\n }\n }\n\n const used = new Set<number>()\n const pending: runtime.Line[] = []\n\n for (const annotation of annotations) {\n const start = annotation.time?.start\n if (start === undefined) {\n continue\n }\n const index = queues.get(start)?.shift()\n if (index === undefined) {\n pending.push(annotation)\n continue\n }\n matched.set(index, annotation)\n used.add(index)\n }\n\n if (tolerance <= 0) {\n return matched\n }\n\n for (const annotation of pending) {\n const start = annotation.time?.start\n if (start === undefined) {\n continue\n }\n\n let target = -1\n let distance = Number.POSITIVE_INFINITY\n\n for (const [index, lineStart] of starts.entries()) {\n if (lineStart === undefined || used.has(index)) {\n continue\n }\n const current = Math.abs(lineStart - start)\n // Only a strictly smaller distance wins, so equally distant lines keep the earlier one.\n if (current <= tolerance && current < distance) {\n target = index\n distance = current\n }\n }\n\n if (target !== -1) {\n matched.set(target, annotation)\n used.add(target)\n }\n }\n\n return matched\n}\n\n/**\n * Attaches annotation tracks to parsed lines by start time.\n *\n * Annotations that match nothing are dropped, and an instrumental line consumes its annotation without carrying one.\n * Any annotation already on a line is kept, with the matched track appended to its layer.\n */\nexport const attachLineAnnotations = (lines: runtime.Line[], sources: AnnotationSources, options: AnnotationMatchOptions = {}): runtime.Line[] => {\n // A negative or non-finite tolerance would make every comparison meaningless, so it degrades to exact matching.\n const tolerance = Number.isFinite(options.tolerance) ? Math.max(0, options.tolerance ?? 0) : 0\n\n const translations = matchEntries(lines, (sources.translations ?? []).filter(isReadable), tolerance)\n const romans = matchEntries(lines, (sources.romans ?? []).filter(isReadable), tolerance)\n if (translations.size === 0 && romans.size === 0) {\n return lines\n }\n\n return lines.map((line, index) => {\n const translation = translations.get(index)\n const roman = romans.get(index)\n\n // An instrumental line has no singing to annotate, and the model rejects annotation on one.\n if (line.type === LineType.INSTRUMENTAL || (translation === undefined && roman === undefined)) {\n return line\n }\n\n return {\n ...line,\n annotation: makeLineAnnotation({\n romans: [\n ...(line.annotation?.romans ?? []),\n ...(roman === undefined ? [] : [makeLineAnnotationRoman({ text: readText(roman), language: roman.languages[0] })]),\n ],\n translations: [\n ...(line.annotation?.translations ?? []),\n ...(translation === undefined ? [] : [makeLineAnnotationTranslation({ text: readText(translation), language: translation.languages[0] })]),\n ],\n }),\n }\n })\n}\n","export * from './annotation'\n\n// A colon always separates units, so `1:14:514` means hours, minutes, and seconds, not minutes, seconds, and milliseconds.\nconst TIME_PATTERN = /^(?:(?:(\\d+):)?(\\d+):)?(\\d+)(?:\\.(\\d+))?$/\n\n/**\n * Bare integers are excluded because formats may use them as millisecond counts or offsets.\n */\nexport const isTime = (body: string) => {\n const trimmed = body.trim()\n return (trimmed.includes(':') || trimmed.includes('.')) && TIME_PATTERN.test(trimmed)\n}\n\n/**\n * Fractional seconds are padded or truncated to millisecond precision.\n *\n * Invalid clock syntax returns `undefined`; time bounds are left to model validation.\n */\nexport const parseTime = (body: string) => {\n if (!isTime(body)) {\n return undefined\n }\n\n const match = TIME_PATTERN.exec(body.trim()) as RegExpExecArray\n\n const hour = Number(match[1] ?? 0)\n const minute = Number(match[2] ?? 0)\n const second = Number(match[3])\n const millisecond = Number((match[4] ?? '').padEnd(3, '0').slice(0, 3))\n\n return ((hour * 60 + minute) * 60 + second) * 1000 + millisecond\n}\n\n/**\n * Tokens `h`, `m`, `s`, and `S` select hours, minutes, seconds, and fractional seconds.\n * Repeated unit tokens set minimum width; `S` tokens select up to three fractional digits.\n *\n * Omitting hours carries them into minutes; negative or non-finite input writes zero fields.\n */\nexport const formatTime = (time: number, format = 'mm:ss.SSS') => {\n if (!Number.isFinite(time) || time < 0) {\n return format.replace(/h+|m+|s+|S+/g, (run) => '0'.repeat(run.length))\n }\n\n const total = Math.floor(time)\n const totalSeconds = Math.floor(total / 1000)\n const totalMinutes = Math.floor(totalSeconds / 60)\n const millisecond = String(total % 1000).padStart(3, '0')\n\n const fields: Record<string, string> = {\n h: String(Math.floor(totalMinutes / 60)),\n m: String(format.includes('h') ? totalMinutes % 60 : totalMinutes),\n s: String(totalSeconds % 60),\n S: millisecond,\n }\n\n return format.replace(/h+|m+|s+|S+/g, (run) => {\n const field = fields[run[0] as string] as string\n\n if (run[0] === 'S') {\n return field.slice(0, run.length)\n }\n\n return field.padStart(run.length, '0')\n })\n}\n","export const FORMAT = 'lrc'\n\nexport interface LrcContent {\n original: string\n translations?: string\n romans?: string\n}\n\nexport const readLrcContent = (input: unknown): LrcContent | null => {\n if (typeof input === 'string') {\n return { original: input }\n }\n if (typeof input !== 'object' || input === null) {\n return null\n }\n\n const source = input as Partial<LrcContent>\n if (typeof source.original !== 'string') {\n return null\n }\n\n return {\n original: source.original,\n translations: typeof source.translations === 'string' ? source.translations : undefined,\n romans: typeof source.romans === 'string' ? source.romans : undefined,\n }\n}\n","import { isTime, parseTime } from '@root/utils'\n\nconst WORD_OFFSET_PATTERN = /^(\\d+)(?:,(\\d+))?$/\n\nconst WORD_CLOCK_DURATION_PATTERN = /^([^,]+),(\\d+)$/\n\nconst WORD_TAG_PATTERN = /<([^>]*)>/g\n\nexport const TAG_PATTERN = /\\[([^\\]]*)\\]/g\n\nexport const META_PATTERN = /^([A-Za-z0-9_-]+)\\s*:\\s*([\\s\\S]*)$/\n\n/**\n * Integer tags use `offset[,duration]` in milliseconds relative to the line start.\n * Clock tags are absolute and may append a duration in milliseconds, as in `<00:01.50,500>`.\n * A missing or zero duration leaves the word end unspecified.\n */\nexport const parseWordTime = (body: string, lineStart: number) => {\n if (isTime(body)) {\n const start = parseTime(body)\n\n return start === undefined ? undefined : { start, end: undefined }\n }\n\n const match = WORD_OFFSET_PATTERN.exec(body.trim())\n if (match) {\n const start = lineStart + Number(match[1])\n const duration = Number(match[2] ?? 0)\n\n return { start, end: duration > 0 ? start + duration : undefined }\n }\n\n const clock = WORD_CLOCK_DURATION_PATTERN.exec(body.trim())\n if (clock && isTime(clock[1] ?? '')) {\n const start = parseTime(clock[1] ?? '')\n const duration = Number(clock[2] ?? 0)\n\n return start === undefined ? undefined : { start, end: duration > 0 ? start + duration : undefined }\n }\n\n return undefined\n}\n\n/**\n * Tags precede their text, as in `<0,500>hel`.\n */\nexport interface WordPiece {\n text: string\n tag?: string\n}\n\n/**\n * Untagged leading text is preserved; tags without following text produce no piece.\n * A trailing tag still yields an empty piece so it can close the preceding word.\n */\nexport const splitWordTags = (content: string) => {\n const pieces: WordPiece[] = []\n let tag: string | undefined\n let cursor = 0\n\n for (const match of content.matchAll(WORD_TAG_PATTERN)) {\n const at = match.index\n const before = content.slice(cursor, at)\n\n if (before) {\n pieces.push({ text: before, tag })\n }\n\n tag = match[1] ?? ''\n cursor = at + match[0].length\n }\n\n const tail = content.slice(cursor)\n if (tail) {\n pieces.push({ text: tail, tag })\n } else if (tag !== undefined) {\n pieces.push({ text: '', tag })\n }\n\n return pieces\n}\n","import { isTime } from '@root/utils'\n\nimport { readLrcContent } from './common'\nimport { TAG_PATTERN } from './utils'\n\n/**\n * A single readable time tag is enough for recognition; metadata alone is not.\n * This is a format probe, not a validity check, so an input of another shape is simply not LRC.\n */\nexport const detectLrc = (input: unknown) => {\n const content = readLrcContent(input)\n if (!content) {\n return false\n }\n\n const lines = content.original.replace(/\\r\\n?/g, '\\n').split('\\n')\n return lines.some((line) => [...line.matchAll(TAG_PATTERN)].some((match) => isTime(match[1] ?? '')))\n}\n","import type { runtime } from '@gondola-lyrics/model'\nimport type { LrcContent } from './common'\n\nimport { LineType, WordType } from '@gondola-lyrics/model/runtime'\n\nimport { formatTime } from '@root/utils'\n\nexport type LrcGenerateResult = LrcContent\n\nexport interface LrcGenerateOptions {\n /**\n * Word-level timestamp style; defaults to `angle`.\n *\n * `angle` is the enhanced LRC form and round-trips through this parser.\n *\n * `bracket` matches line timestamps, so this parser reads it as new lines rather than words.\n */\n wordTiming?: 'angle' | 'bracket' | 'none'\n /** Clock format for every timestamp; defaults to `mm:ss.SSS`. */\n timeFormat?: string\n /** Emit the metadata header; defaults to `true`. */\n includeMeta?: boolean\n /** Append the word duration to its tag, as in `<00:01.00,500>`. */\n includeWordEnd?: boolean\n /** Emit instrumental lines as a timestamp with no text; defaults to `true`. */\n includeInstrumental?: boolean\n}\n\ntype LrcGenerateOptionsRequired = Required<LrcGenerateOptions>\n\nconst resolveOptions = (options: LrcGenerateOptions = {}): LrcGenerateOptionsRequired => {\n return {\n wordTiming: options.wordTiming ?? 'angle',\n timeFormat: options.timeFormat ?? 'mm:ss.SSS',\n includeMeta: options.includeMeta ?? true,\n includeWordEnd: options.includeWordEnd ?? false,\n includeInstrumental: options.includeInstrumental ?? true,\n }\n}\n\nconst renderTimeTag = (time: number, options: LrcGenerateOptionsRequired) => {\n return `[${formatTime(time, options.timeFormat)}]`\n}\n\nconst renderWordTag = (start: number, end: number | undefined, options: LrcGenerateOptionsRequired) => {\n if (options.wordTiming === 'none') {\n return ''\n }\n\n const open = options.wordTiming === 'bracket' ? '[' : '<'\n const close = options.wordTiming === 'bracket' ? ']' : '>'\n // A duration of zero still states where the word stops, so it is written when the end is known.\n const duration = options.includeWordEnd && end !== undefined ? `,${Math.max(0, end - start)}` : ''\n\n return `${open}${formatTime(start, options.timeFormat)}${duration}${close}`\n}\n\n/**\n * Plain text joins words as written, including the separators stored as space words.\n */\nconst renderPlainText = (line: runtime.Line) => {\n const content = line.content\n if (!content) {\n return ''\n }\n if (content.text !== undefined) {\n return content.text\n }\n\n return content.words.map((word) => word.text).join('')\n}\n\n/**\n * Word-level lines interleave tags with their text; untimed words stay untagged.\n */\nconst renderSyllableText = (line: runtime.Line, options: LrcGenerateOptionsRequired) => {\n const content = line.content\n if (!content?.words.length) {\n return renderPlainText(line)\n }\n\n return content.words\n .map((word) => {\n if (word.type === WordType.SPACE) {\n return word.text\n }\n const start = word.time?.start\n if (start === undefined) {\n return word.text\n }\n return `${renderWordTag(start, word.time?.end, options)}${word.text}`\n })\n .join('')\n}\n\nconst renderMetaRows = (meta: runtime.Meta, options: LrcGenerateOptionsRequired) => {\n const rows: string[] = []\n\n for (const title of meta.titles) {\n rows.push(`[ti:${title.text}]`)\n }\n for (const artist of meta.artists) {\n rows.push(`[ar:${artist.text}]`)\n }\n for (const album of meta.albums) {\n rows.push(`[al:${album.text}]`)\n }\n for (const author of meta.authors) {\n rows.push(`[by:${author.text}]`)\n }\n if (meta.offset) {\n rows.push(`[offset:${meta.offset}]`)\n }\n if (meta.duration !== undefined) {\n rows.push(`[length:${formatTime(meta.duration, options.timeFormat)}]`)\n }\n for (const isrc of meta.isrcs) {\n rows.push(`[isrc:${isrc}]`)\n }\n for (const unknown of meta.unknowns) {\n rows.push(`[${unknown.key}:${unknown.value}]`)\n }\n\n return rows\n}\n\n/**\n * Returns `undefined` for a line the options leave out, so callers can drop it rather than emit a blank row.\n */\nconst renderLineRow = (line: runtime.Line, options: LrcGenerateOptionsRequired, wordLevel: boolean) => {\n if (line.type === LineType.INSTRUMENTAL && !options.includeInstrumental) {\n return undefined\n }\n\n // An untimed lyric still needs a stamp, so it starts at zero like any other line.\n const time = renderTimeTag(line.time?.start ?? 0, options)\n\n // An instrumental line carries no content, so its timestamp alone marks the gap.\n if (line.type === LineType.INSTRUMENTAL) {\n return time\n }\n\n const text = wordLevel && line.content?.words.length ? renderSyllableText(line, options) : renderPlainText(line)\n return `${time}${text}`\n}\n\nconst renderAnnotationRows = (lines: runtime.Line[], options: LrcGenerateOptionsRequired, pick: (line: runtime.Line) => { text: string }[]) => {\n const rows: string[] = []\n\n for (const line of lines) {\n const start = line.time?.start\n if (start === undefined) {\n continue\n }\n for (const item of pick(line)) {\n rows.push(`${renderTimeTag(start, options)}${item.text}`)\n }\n }\n\n return rows\n}\n\nconst joinBlocks = (header: string[], body: string[]) => {\n const blocks: string[] = []\n if (header.length) {\n blocks.push(header.join('\\n'))\n }\n if (body.length) {\n blocks.push(body.join('\\n'))\n }\n\n return blocks.join('\\n\\n')\n}\n\n/**\n * Generates the LRC text plus the annotations a single file cannot hold.\n *\n * Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.\n */\nexport const generateLrc = (lyric: runtime.Lyric, options: LrcGenerateOptions = {}): LrcGenerateResult => {\n const resolved = resolveOptions(options)\n const header = resolved.includeMeta && lyric.meta ? renderMetaRows(lyric.meta, resolved) : []\n\n const hasWords = lyric.lines.some((line) => (line.content?.words.length ?? 0) > 0)\n const wordLevel = hasWords && resolved.wordTiming !== 'none'\n\n const body = lyric.lines\n .map((line) => {\n return renderLineRow(line, resolved, wordLevel)\n })\n .filter((row) => row !== undefined)\n\n const result: LrcGenerateResult = {\n original: joinBlocks(header, body),\n }\n\n const translations = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.translations ?? [])\n if (translations.length) {\n result.translations = joinBlocks(header, translations)\n }\n\n const romans = renderAnnotationRows(lyric.lines, resolved, (line) => line.annotation?.romans ?? [])\n if (romans.length) {\n result.romans = joinBlocks(header, romans)\n }\n\n return result\n}\n","import type { runtime } from '@gondola-lyrics/model'\nimport type { LrcContent } from './common'\n\nimport {\n Timing,\n LyricStatus,\n makeLineContentText,\n makeLineContentWords,\n makeLineNormal,\n makeLyric,\n makeTimeRange,\n makeMeta,\n makeText,\n makeUnknownEntry,\n makeWordNormal,\n makeWordSpace,\n deriveLanguageUsages,\n} from '@gondola-lyrics/model/runtime'\n\nimport { attachLineAnnotations, isTime, parseTime } from '@root/utils'\n\nimport { FORMAT, readLrcContent } from './common'\nimport { META_PATTERN, TAG_PATTERN, parseWordTime, splitWordTags } from './utils'\n\nexport interface LrcParseOptions {\n /**\n * How far an annotation's timestamp may sit from a line's start, in milliseconds.\n * Defaults to `0`, which requires an exact match.\n */\n annotationTolerance?: number\n}\n\nexport type LrcParseInput = LrcContent\n\ninterface TimedLine {\n text: string\n start: number\n}\n\ninterface MetaTag {\n key: string\n value: string\n}\n\nconst isBareNumber = (value: string) => {\n return /^\\d+$/.test(value.trim())\n}\n\n/**\n * A trailing tag alone still makes the line word-level, so it can close the last word.\n * Unreadable angle-bracket text stays plain lyric content.\n */\nconst isWordTimed = (text: string) => {\n return splitWordTags(text).some((piece) => piece.tag !== undefined && parseWordTime(piece.tag, 0) !== undefined)\n}\n\nconst readMeta = (body: string) => {\n const match = META_PATTERN.exec(body)\n if (!match) {\n return undefined\n }\n\n return { key: (match[1] ?? '').toLowerCase(), value: (match[2] ?? '').trim() }\n}\n\nconst readGroup = (tags: string[], text: string, timed: TimedLine[], meta: MetaTag[]) => {\n for (const tag of tags) {\n const start = isTime(tag) ? parseTime(tag) : undefined\n if (start !== undefined) {\n timed.push({ text, start })\n continue\n }\n\n const pair = readMeta(tag)\n if (pair) {\n meta.push(pair)\n }\n }\n}\n\n/**\n * Stacked timestamps share the following text, as in `[00:10][00:20]twice`.\n * Text before the first tag is discarded because it has no timing association.\n */\nconst readTimedLines = (text: string) => {\n const timed: TimedLine[] = []\n const meta: MetaTag[] = []\n\n for (const raw of text.replace(/\\r\\n?/g, '\\n').split('\\n')) {\n const line = raw.trim()\n if (!line) {\n continue\n }\n\n const matches = [...line.matchAll(TAG_PATTERN)]\n let group: string[] = []\n\n for (const [index, match] of matches.entries()) {\n const at = match.index + match[0].length\n const next = matches[index + 1]?.index ?? line.length\n const shared = line.slice(at, next)\n\n group.push(match[1] ?? '')\n\n if (!shared.trim()) {\n continue\n }\n\n readGroup(group, shared, timed, meta)\n group = []\n }\n\n // Trailing timestamps still represent empty timed lines.\n readGroup(group, '', timed, meta)\n }\n\n return { timed, meta }\n}\n\n/**\n * Unmapped tags and unparseable durations remain in `unknowns` to avoid discarding metadata.\n * Keys are lowercased and values trimmed, so original tag spelling is not preserved.\n */\nconst readMetaTags = (tags: MetaTag[]) => {\n const meta = makeMeta()\n\n for (const tag of tags) {\n switch (tag.key) {\n case 'length':\n case 'duration': {\n // Duration tags accept both clock notation and integer milliseconds.\n const duration = parseTime(tag.value) ?? (isBareNumber(tag.value) ? Number(tag.value) : Number.NaN)\n if (Number.isFinite(duration)) {\n meta.duration = Math.trunc(duration)\n } else {\n meta.unknowns.push(makeUnknownEntry({ key: tag.key, value: tag.value }))\n }\n break\n }\n case 'offset':\n meta.offset = Math.trunc(Number(tag.value)) || 0\n break\n case 'ti':\n case 'title':\n meta.titles.push(makeText({ text: tag.value }))\n break\n case 'ar':\n case 'artist':\n meta.artists.push(makeText({ text: tag.value }))\n break\n case 'al':\n case 'album':\n meta.albums.push(makeText({ text: tag.value }))\n break\n case 'by':\n case 'author':\n meta.authors.push(makeText({ text: tag.value }))\n break\n case 'isrc':\n meta.isrcs.push(tag.value)\n break\n default:\n meta.unknowns.push(makeUnknownEntry({ key: tag.key, value: tag.value }))\n break\n }\n }\n\n return meta\n}\n\n/**\n * Whitespace becomes a space word preserving the source run, as in `lo World`.\n * A tag opens a segment; untagged words continue from the previous end so the timeline has no holes.\n * Explicit word ends are kept as written, so duration tags survive unchanged.\n */\nconst readWords = (text: string, lineStart: number) => {\n const drafts: { text: string; start?: number; end?: number; space?: boolean }[] = []\n const open = new Set<{ start?: number; end?: number }>()\n\n const closeSegment = (boundary: number | undefined) => {\n if (boundary === undefined) {\n return\n }\n for (const draft of open) {\n if (draft.start !== undefined && draft.end === undefined && boundary >= draft.start) {\n draft.end = boundary\n }\n }\n open.clear()\n }\n\n let segmentStart: number | undefined = lineStart\n let cursor: number | undefined = lineStart\n\n for (const piece of splitWordTags(text)) {\n const time = piece.tag === undefined ? undefined : parseWordTime(piece.tag, lineStart)\n\n // A tag, even without following text, closes the segment it opens.\n if (time?.start !== undefined) {\n closeSegment(time.start)\n segmentStart = time.start\n cursor = time.start\n }\n\n if (!piece.text) {\n continue\n }\n\n let applied = false\n\n for (const run of piece.text.split(/(\\s+)/)) {\n if (!run) {\n continue\n }\n\n // A separator keeps its own text and never carries a tag.\n if (!run.trim()) {\n if (drafts.length) {\n drafts.push({ text: run, space: true })\n }\n continue\n }\n\n const tagged = !applied && time?.start !== undefined\n applied = true\n\n const start = tagged ? segmentStart : cursor\n const end = tagged ? time?.end : undefined\n const draft = { text: run, start, end }\n\n drafts.push(draft)\n if (end !== undefined) {\n cursor = end\n } else {\n open.add(draft)\n }\n }\n }\n\n return { drafts, closeSegment }\n}\n\n/**\n * A line ends at the next line's start; without one, it ends at its final word's end.\n * An open word is closed by the next timed word, or by the line end, so every sung word stays inside the line.\n */\nconst readLine = (text: string, start: number, nextStart: number | undefined) => {\n const wordTimed = isWordTimed(text)\n const { drafts, closeSegment } = wordTimed ? readWords(text, start) : { drafts: [], closeSegment: () => {} }\n\n let end = nextStart !== undefined && nextStart >= start ? nextStart : undefined\n\n // Without a later line, a final word carrying its own end bounds the line; otherwise it stays open.\n if (end === undefined) {\n const last = [...drafts].reverse().find((draft) => !draft.space && draft.start !== undefined)\n end = last?.end\n }\n\n // Earlier segments were closed by their next tag, so this only closes the final one.\n closeSegment(end)\n\n const words = drafts.map((draft) => {\n if (draft.space) {\n return makeWordSpace({ text: draft.text })\n }\n // A missing start omits the range instead of inventing a word timestamp.\n const time = draft.start === undefined ? undefined : makeTimeRange({ start: draft.start, end: draft.end })\n return makeWordNormal({ text: draft.text, time })\n })\n\n const time = makeTimeRange({ start, end })\n const content = wordTimed ? makeLineContentWords(words) : makeLineContentText(text.trim())\n\n return makeLineNormal({ time, content })\n}\n\n/**\n * Builds one line per timed row, so an annotation track reuses the original text's reading unchanged.\n * An empty timed row still yields a line, since it marks a gap rather than a parse failure.\n */\nconst readLines = (timed: TimedLine[]): runtime.Line[] => {\n const lines: runtime.Line[] = []\n\n for (const [index, item] of timed.entries()) {\n const next = timed[index + 1]?.start\n // LRC has no line ends; only a nondecreasing next timestamp can supply one.\n lines.push(readLine(item.text, item.start, next))\n }\n\n return lines\n}\n\nconst parseLrcText = (text: string): runtime.Lyric => {\n const { timed, meta } = readTimedLines(text)\n const metaTags = readMetaTags(meta)\n\n if (!timed.length) {\n return makeLyric({ format: FORMAT, status: LyricStatus.INVALID, meta: metaTags })\n }\n\n const lines = readLines(timed)\n const wordTimed = lines.some((line) => (line.content?.words.length ?? 0) > 0)\n\n return makeLyric({\n format: FORMAT,\n status: LyricStatus.VALID,\n timing: wordTimed ? Timing.WORD : Timing.LINE,\n meta: metaTags,\n languages: deriveLanguageUsages(lines),\n lines,\n })\n}\n\n/**\n * Source order, empty timed lines, and missing word times are preserved for model validation.\n *\n * Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.\n *\n * `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.\n */\nexport const parseLrc = (input: string | LrcParseInput, options: LrcParseOptions = {}): runtime.Lyric => {\n const source = readLrcContent(input) ?? { original: '' }\n const lyric = parseLrcText(source.original)\n\n if (lyric.status === LyricStatus.INVALID) {\n return lyric\n }\n\n const translations = source.translations === undefined ? [] : readLines(readTimedLines(source.translations).timed)\n const romans = source.romans === undefined ? [] : readLines(readTimedLines(source.romans).timed)\n\n if (translations.length === 0 && romans.length === 0) {\n return lyric\n }\n\n const lines = attachLineAnnotations(lyric.lines, { translations, romans }, { tolerance: options.annotationTolerance })\n\n return { ...lyric, lines }\n}\n","import type { runtime } from '@gondola-lyrics/model'\nimport type { LyricFormat } from './format'\nimport type { LrcGenerateOptions, LrcGenerateResult, LrcParseInput, LrcParseOptions } from './lrc'\n\nimport { detectLrc, generateLrc, parseLrc } from './lrc'\n\n/**\n * Recognition reads only the original text; the annotation tracks never decide the format.\n *\n * Unrecognized text returns `undefined`.\n */\nexport const detectFormat = (input: unknown) => {\n if (detectLrc(input)) {\n return 'lrc'\n }\n\n return undefined\n}\n\n/**\n * Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.\n * Annotation tracks travel with the source and are attached by the format's parser.\n */\nexport const parse = (input: unknown, options: LrcParseOptions = {}): runtime.Lyric | null => {\n const format = detectFormat(input)\n if (!format) {\n return null\n }\n\n switch (format) {\n case 'lrc':\n return parseLrc(input as string | LrcParseInput, options)\n }\n}\n\n/**\n * Generates text in the requested format, which need not match `lyric.format`.\n */\nexport const generate = (lyric: runtime.Lyric, target: LyricFormat, options: LrcGenerateOptions = {}): LrcGenerateResult => {\n switch (target) {\n case 'lrc':\n return generateLrc(lyric, options)\n }\n}\n\nexport type { LyricFormat } from './format'\n\nexport * from './lrc'\n"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { runtime } from '@gondola-lyrics/model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates text in the requested format, which need not match `lyric.format`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const generate: (lyric: runtime.Lyric, target: LyricFormat, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates the LRC text plus the annotations a single file cannot hold.
|
|
10
|
+
*
|
|
11
|
+
* Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
|
|
12
|
+
*/
|
|
13
|
+
export declare const generateLrc: (lyric: runtime.Lyric, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
14
|
+
|
|
15
|
+
declare interface LrcContent {
|
|
16
|
+
original: string;
|
|
17
|
+
translations?: string;
|
|
18
|
+
romans?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare interface LrcGenerateOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Word-level timestamp style; defaults to `angle`.
|
|
24
|
+
*
|
|
25
|
+
* `angle` is the enhanced LRC form and round-trips through this parser.
|
|
26
|
+
*
|
|
27
|
+
* `bracket` matches line timestamps, so this parser reads it as new lines rather than words.
|
|
28
|
+
*/
|
|
29
|
+
wordTiming?: 'angle' | 'bracket' | 'none';
|
|
30
|
+
/** Clock format for every timestamp; defaults to `mm:ss.SSS`. */
|
|
31
|
+
timeFormat?: string;
|
|
32
|
+
/** Emit the metadata header; defaults to `true`. */
|
|
33
|
+
includeMeta?: boolean;
|
|
34
|
+
/** Append the word duration to its tag, as in `<00:01.00,500>`. */
|
|
35
|
+
includeWordEnd?: boolean;
|
|
36
|
+
/** Emit instrumental lines as a timestamp with no text; defaults to `true`. */
|
|
37
|
+
includeInstrumental?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export declare type LrcGenerateResult = LrcContent;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Values match the lowercase format names stored in `Lyric.format`.
|
|
44
|
+
*/
|
|
45
|
+
export declare type LyricFormat = 'lrc';
|
|
46
|
+
|
|
47
|
+
export { }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generate, generateLrc } from "./formats.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { runtime } from '@gondola-lyrics/model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recognition reads only the original text; the annotation tracks never decide the format.
|
|
5
|
+
*
|
|
6
|
+
* Unrecognized text returns `undefined`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const detectFormat: (input: unknown) => "lrc" | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generates text in the requested format, which need not match `lyric.format`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const generate: (lyric: runtime.Lyric, target: LyricFormat, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generates the LRC text plus the annotations a single file cannot hold.
|
|
17
|
+
*
|
|
18
|
+
* Word tags appear only for a word-timed lyric when `wordTiming` is not `none`; otherwise every line stays whole.
|
|
19
|
+
*/
|
|
20
|
+
export declare const generateLrc: (lyric: runtime.Lyric, options?: LrcGenerateOptions) => LrcGenerateResult;
|
|
21
|
+
|
|
22
|
+
declare interface LrcContent {
|
|
23
|
+
original: string;
|
|
24
|
+
translations?: string;
|
|
25
|
+
romans?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export declare interface LrcGenerateOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Word-level timestamp style; defaults to `angle`.
|
|
31
|
+
*
|
|
32
|
+
* `angle` is the enhanced LRC form and round-trips through this parser.
|
|
33
|
+
*
|
|
34
|
+
* `bracket` matches line timestamps, so this parser reads it as new lines rather than words.
|
|
35
|
+
*/
|
|
36
|
+
wordTiming?: 'angle' | 'bracket' | 'none';
|
|
37
|
+
/** Clock format for every timestamp; defaults to `mm:ss.SSS`. */
|
|
38
|
+
timeFormat?: string;
|
|
39
|
+
/** Emit the metadata header; defaults to `true`. */
|
|
40
|
+
includeMeta?: boolean;
|
|
41
|
+
/** Append the word duration to its tag, as in `<00:01.00,500>`. */
|
|
42
|
+
includeWordEnd?: boolean;
|
|
43
|
+
/** Emit instrumental lines as a timestamp with no text; defaults to `true`. */
|
|
44
|
+
includeInstrumental?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export declare type LrcGenerateResult = LrcContent;
|
|
48
|
+
|
|
49
|
+
export declare type LrcParseInput = LrcContent;
|
|
50
|
+
|
|
51
|
+
export declare interface LrcParseOptions {
|
|
52
|
+
/**
|
|
53
|
+
* How far an annotation's timestamp may sit from a line's start, in milliseconds.
|
|
54
|
+
* Defaults to `0`, which requires an exact match.
|
|
55
|
+
*/
|
|
56
|
+
annotationTolerance?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Values match the lowercase format names stored in `Lyric.format`.
|
|
61
|
+
*/
|
|
62
|
+
export declare type LyricFormat = 'lrc';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
|
|
66
|
+
* Annotation tracks travel with the source and are attached by the format's parser.
|
|
67
|
+
*/
|
|
68
|
+
export declare const parse: (input: unknown, options?: LrcParseOptions) => runtime.Lyric | null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Source order, empty timed lines, and missing word times are preserved for model validation.
|
|
72
|
+
*
|
|
73
|
+
* Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
|
|
74
|
+
*
|
|
75
|
+
* `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
|
|
76
|
+
*/
|
|
77
|
+
export declare const parseLrc: (input: string | LrcParseInput, options?: LrcParseOptions) => runtime.Lyric;
|
|
78
|
+
|
|
79
|
+
export { }
|
package/dist/index.js
ADDED
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { runtime } from '@gondola-lyrics/model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recognition reads only the original text; the annotation tracks never decide the format.
|
|
5
|
+
*
|
|
6
|
+
* Unrecognized text returns `undefined`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const detectFormat: (input: unknown) => "lrc" | undefined;
|
|
9
|
+
|
|
10
|
+
declare interface LrcContent {
|
|
11
|
+
original: string;
|
|
12
|
+
translations?: string;
|
|
13
|
+
romans?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export declare type LrcParseInput = LrcContent;
|
|
17
|
+
|
|
18
|
+
export declare interface LrcParseOptions {
|
|
19
|
+
/**
|
|
20
|
+
* How far an annotation's timestamp may sit from a line's start, in milliseconds.
|
|
21
|
+
* Defaults to `0`, which requires an exact match.
|
|
22
|
+
*/
|
|
23
|
+
annotationTolerance?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Values match the lowercase format names stored in `Lyric.format`.
|
|
28
|
+
*/
|
|
29
|
+
export declare type LyricFormat = 'lrc';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unrecognized text returns `null`; a recognized format returns a lyric with its parser-assigned status.
|
|
33
|
+
* Annotation tracks travel with the source and are attached by the format's parser.
|
|
34
|
+
*/
|
|
35
|
+
export declare const parse: (input: unknown, options?: LrcParseOptions) => runtime.Lyric | null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Source order, empty timed lines, and missing word times are preserved for model validation.
|
|
39
|
+
*
|
|
40
|
+
* Annotation tracks are read as lines too and attached by timestamp, and rows that match no line are dropped.
|
|
41
|
+
*
|
|
42
|
+
* `INVALID` means no readable timed lines; `VALID` does not imply model validation has passed.
|
|
43
|
+
*/
|
|
44
|
+
export declare const parseLrc: (input: string | LrcParseInput, options?: LrcParseOptions) => runtime.Lyric;
|
|
45
|
+
|
|
46
|
+
export { }
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { detectFormat, parse, parseLrc } from "./formats.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gondola-lyrics/kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gondola lyrics toolkit",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gondola",
|
|
7
|
+
"lyrics",
|
|
8
|
+
"lyric",
|
|
9
|
+
"karaoke",
|
|
10
|
+
"parser",
|
|
11
|
+
"generator"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "folltoshe",
|
|
15
|
+
"homepage": "https://github.com/gondola-lyrics/kit-node#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/gondola-lyrics/kit-node/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/gondola-lyrics/kit-node.git"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.12.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"build-change-log": "tsx script/build-change-log.ts",
|
|
30
|
+
"bump-version": "tsx script/bump-version.ts",
|
|
31
|
+
"release": "tsx script/release.ts",
|
|
32
|
+
"check": "tsc --noEmit",
|
|
33
|
+
"format": "prettier --write src"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"main": "./dist/index.js",
|
|
37
|
+
"module": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./parser": {
|
|
45
|
+
"types": "./dist/parser.d.ts",
|
|
46
|
+
"default": "./dist/parser.js"
|
|
47
|
+
},
|
|
48
|
+
"./generator": {
|
|
49
|
+
"types": "./dist/generator.d.ts",
|
|
50
|
+
"default": "./dist/generator.js"
|
|
51
|
+
},
|
|
52
|
+
"./package.json": "./package.json"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist"
|
|
56
|
+
],
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@gondola-lyrics/model": "^1.1.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@bufbuild/protobuf": "2.14.1",
|
|
65
|
+
"@gondola-lyrics/model": "1.1.0",
|
|
66
|
+
"@microsoft/api-extractor": "^7.59.0",
|
|
67
|
+
"@types/node": "26.4.1",
|
|
68
|
+
"prettier": "3.9.6",
|
|
69
|
+
"tsx": "4.23.13",
|
|
70
|
+
"typescript": "5.9.3",
|
|
71
|
+
"vite": "8.2.2",
|
|
72
|
+
"vite-plugin-dts": "5.1.0"
|
|
73
|
+
}
|
|
74
|
+
}
|