lyric-align 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .DS_Store
10
+ *.wav
11
+ *.mp3
12
+ *.flac
13
+ *.ogg
14
+ demo_local/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ijuinryukichi
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.
@@ -0,0 +1,15 @@
1
+ Scope of the MIT license
2
+ ========================
3
+
4
+ The MIT license in LICENSE applies to the software in this repository: the
5
+ source code, the tests and the documentation.
6
+
7
+ It does not grant any rights to the song lyrics quoted here. Short lyric
8
+ fragments appear in the test fixtures as sample input. They are the
9
+ copyrighted work of their author (屠龍 / TORYU), reproduced as test data only.
10
+ Reusing them is not covered by LICENSE.
11
+
12
+ The exception is examples/amazing_grace.txt. "Amazing Grace" is a public-domain
13
+ hymn, and the recording the README points at is a public-domain work of the
14
+ United States Air Force Reserve Band. That is why the README walkthrough uses
15
+ it: anyone can reproduce that run end to end.
@@ -0,0 +1,627 @@
1
+ Metadata-Version: 2.4
2
+ Name: lyric-align
3
+ Version: 0.3.0
4
+ Summary: Place known lyrics on an audio timeline — CJK-first, built for sung vocals and rap.
5
+ Project-URL: Homepage, https://github.com/ijuinryukichi/lyric-align
6
+ Project-URL: Source, https://github.com/ijuinryukichi/lyric-align
7
+ Project-URL: Issues, https://github.com/ijuinryukichi/lyric-align/issues
8
+ Project-URL: Changelog, https://github.com/ijuinryukichi/lyric-align/releases
9
+ Author: ijuinryukichi
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: ass,chinese,cjk,faster-whisper,forced-alignment,japanese,karaoke,lrc,lyrics,lyrics-alignment,srt,stable-ts,subtitle,timestamps,transcription,ttml,whisper
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: End Users/Desktop
18
+ Classifier: Natural Language :: Chinese (Simplified)
19
+ Classifier: Natural Language :: English
20
+ Classifier: Natural Language :: Japanese
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: Programming Language :: Python :: Implementation :: CPython
30
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
31
+ Classifier: Topic :: Multimedia :: Video
32
+ Classifier: Topic :: Text Processing :: Linguistic
33
+ Requires-Python: >=3.9
34
+ Provides-Extra: asr
35
+ Requires-Dist: faster-whisper>=1.0; extra == 'asr'
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=7.0; extra == 'dev'
38
+ Provides-Extra: separate
39
+ Requires-Dist: demucs>=4.0; extra == 'separate'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # lyric-align
43
+
44
+ [![PyPI](https://img.shields.io/pypi/v/lyric-align)](https://pypi.org/project/lyric-align/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/lyric-align)](https://pypi.org/project/lyric-align/)
46
+ [![tests](https://github.com/ijuinryukichi/lyric-align/actions/workflows/ci.yml/badge.svg)](https://github.com/ijuinryukichi/lyric-align/actions/workflows/ci.yml)
47
+ [![License](https://img.shields.io/pypi/l/lyric-align)](LICENSE)
48
+
49
+ **You already have the correct lyrics. You only need the *times*.**
50
+
51
+ `lyric-align` anchors known lyric lines onto ASR word timings by *character-level*
52
+ fuzzy matching, and emits line- or character-level timestamps as **LRC / SRT / ASS / JSON**.
53
+
54
+ It is built for two things most aligners handle poorly:
55
+
56
+ - **Space-less languages** (Japanese, Chinese). Word-level tools split on
57
+ whitespace, which does not exist here. `lyric-align` compares characters, so
58
+ it works where `str.split()` fails.
59
+ - **Sung vocals, including rap.** ASR mis-hears lyrics constantly, but you don't
60
+ care about its *text* — you have the official lyrics. You only borrow its
61
+ *timing*. Character-level fuzzy matching tolerates the ASR errors and keeps
62
+ the timestamps.
63
+
64
+ ## Install
65
+
66
+ Python 3.9–3.14. The core has **no dependencies at all**; the ASR extra pulls in
67
+ `faster-whisper` (`ctranslate2`, not torch).
68
+
69
+ **As a command-line tool** (recommended — puts `lyric-align` on your PATH, in its
70
+ own isolated environment):
71
+
72
+ ```bash
73
+ uv tool install "lyric-align[asr]"
74
+ # or: pipx install "lyric-align[asr]"
75
+
76
+ lyric-align --version
77
+ ```
78
+
79
+ Add `separate` to the extras (`lyric-align[asr,separate]`) if you want Demucs
80
+ vocal splitting; it pulls in torch, so leave it out until you need it.
81
+
82
+ **As a library**, into your own environment:
83
+
84
+ ```bash
85
+ pip install lyric-align # core only, pure stdlib — no dependencies
86
+ pip install "lyric-align[asr]" # + faster-whisper, to transcribe audio
87
+ ```
88
+
89
+ If you already have segments with word timings from somewhere else, the core
90
+ install is enough: feed them in with `--segments` and nothing gets downloaded.
91
+
92
+ ## Use
93
+
94
+ ```bash
95
+ # transcribe audio and align known lyrics → LRC
96
+ lyric-align song.wav lyrics.txt -o out.lrc
97
+
98
+ # full mix? split the vocal first — this matters a lot (see below)
99
+ lyric-align song.wav lyrics.txt --separate -o out.lrc
100
+
101
+ # already have Whisper segments? skip ASR
102
+ lyric-align --segments segments.json lyrics.txt -f srt
103
+
104
+ # per-character karaoke ASS (\k tags)
105
+ lyric-align song.wav lyrics.txt -f ass --karaoke -o out.ass
106
+ ```
107
+
108
+ `lyrics.txt` is plain text, one lyric line per line. Blank lines, `# comments`
109
+ and section markers (`[Verse 1]`, `[Hook]`) are skipped, so a pasted lyric sheet
110
+ works as-is. `segments.json` is a list of
111
+ `{"start", "end", "text", "words": [{"start","end","word"}]}` — the shape any
112
+ Whisper flavor produces.
113
+
114
+ ## Output: pick the format your next tool eats
115
+
116
+ `-f all -o out` writes every format at once.
117
+
118
+ | format | what it is | where it goes next |
119
+ |---|---|---|
120
+ | `lrc` | line-timed lyrics | music players; contributing to [LRCLIB](https://lrclib.net) |
121
+ | `elrc` | LRC A2 — same file, inline per-syllable timestamps | word-by-word players (AIMP, QQ/NetEase/Kugou, Chronograph), karaoke editors |
122
+ | `srt` | universal subtitles | `ffmpeg`, video editors, YouTube captions |
123
+ | `vtt` | WebVTT | HTML5 `<track>`, web players |
124
+ | `ass` | styling + `\k` karaoke sweeps | Aegisub, `ffmpeg` burn-in |
125
+ | `ttml` | per-syllable rich lyrics | Apple-style / AMLL-ecosystem players |
126
+ | `aud` | Audacity label track (`start⇥end⇥text`) | **fixing the timings by hand**, then `--from-labels` back into any format; also plain TSV |
127
+ | `json` | everything, including scores and unmatched lines | your own code |
128
+
129
+ Per-syllable formats split by script: alphabetic text is grouped into words
130
+ (`<00:06.60>Amazing <00:08.82>grace`), CJK stays one unit per character
131
+ (`<00:21.78>治<00:21.94>部`), which is how per-character karaoke formats treat
132
+ Japanese and Chinese. The separator between units is taken from the source line
133
+ rather than inferred, so a Japanese line that carries a phrasing space
134
+ (`硫黄が満ちる 道の奥`) rebuilds character-for-character instead of gaining a
135
+ space between every character.
136
+
137
+ `lrc`/`elrc` cannot express an end time — the last syllable of a line has no
138
+ close. Every other format carries the end times this aligner computes.
139
+
140
+ The `ttml` output is checked against the AMLL reference parser
141
+ ([`@applemusic-like-lyrics/ttml`](https://github.com/amll-dev/applemusic-like-lyrics)),
142
+ not just against the XML schema: lines, per-unit text and millisecond timings
143
+ survive a real parse unchanged, in both scripts. It declares a single
144
+ `ttm:agent` because the spec wants one per line, and carries `xml:lang` from
145
+ `--language`. The title/artist/album an AMLL *database submission* also wants
146
+ are deliberately absent — this tool is handed audio and lyrics and nothing else,
147
+ so it does not invent them.
148
+
149
+ ### Recipes
150
+
151
+ ```bash
152
+ # burn subtitles into a video
153
+ ffmpeg -i video.mp4 -vf "ass=out.ass" -c:a copy out.mp4
154
+
155
+ # karaoke sweep instead of plain lines
156
+ lyric-align song.wav lyrics.txt -f ass --karaoke -o out.ass
157
+
158
+ # fix a mistimed line by hand — the correction loop, in three steps
159
+ lyric-align song.wav lyrics.txt -f aud -o out.labels.txt # 1. export labels
160
+ # 2. Audacity: File > Import > Labels, drag the wrong line over the waveform,
161
+ # then File > Export > Export Labels
162
+ lyric-align --from-labels out.labels.txt -f lrc -o final.lrc # 3. back to LRC
163
+
164
+ # web player
165
+ lyric-align song.wav lyrics.txt -f vtt -o out.vtt
166
+
167
+ # a file shaped for an AMLL TTML DB submission
168
+ lyric-align song.wav lyrics.txt -f ttml -o out.ttml \
169
+ --meta musicName="Song" --meta artists="Artist" --meta album="Album" \
170
+ --meta ncmMusicId=1234567
171
+ ```
172
+
173
+ The [AMLL TTML DB](https://github.com/amll-dev/amll-ttml-db) is the largest open
174
+ collection of word-by-word lyric files — tens of thousands of them, timed by
175
+ hand. Its checker requires `musicName`, `artists`, `album` and at least one
176
+ platform id (`ncmMusicId` / `appleMusicId` / `spotifyId` / `qqMusicId`), none of
177
+ which can be inferred from audio and lyrics, so `--meta` is how you supply them.
178
+ Everything else was already in the right shape: one `<span>` per CJK character,
179
+ spaces as text nodes *between* spans (the form their spec calls most compliant),
180
+ and every child timestamp contained by its parent.
181
+
182
+ Checked, not assumed — the output parses with the AMLL reference parser
183
+ (`@applemusic-like-lyrics/ttml`) and clears every rule in the database's own
184
+ checker (`scripts/lyric_checker_bot/src/validator.rs`): 70 lines, 623 syllables,
185
+ metadata read back intact. Without `--meta` the same file fails on exactly the
186
+ four metadata rules, which is the point of the flag.
187
+
188
+ Formats deliberately left out: UltraStar `.txt` and CDG need sung **pitch**, not
189
+ just timing — [UltraSinger](https://github.com/rakuri255/UltraSinger) and
190
+ [karaoke-gen](https://github.com/nomadkaraoke/karaoke-gen) cover that. Apple
191
+ Music and Spotify do not accept user-supplied lyric files at all.
192
+
193
+ ### Try it in one minute
194
+
195
+ Both the recording and the lyrics below are public domain, so this runs
196
+ end-to-end with nothing of your own:
197
+
198
+ ```bash
199
+ curl -L -o amazing_grace.mp3 \
200
+ "https://upload.wikimedia.org/wikipedia/commons/8/8f/Amazing_Grace_%28vocalist_with_guitar%29_-_Southern_Aire_-_United_States_Air_Force_Reserve_Band.mp3"
201
+ # examples/amazing_grace.txt ships with this repo
202
+ lyric-align amazing_grace.mp3 examples/amazing_grace.txt \
203
+ --language en --pairing 1 --no-vad -o amazing_grace.lrc
204
+ ```
205
+
206
+ ```
207
+ segments: 12
208
+ match threshold: 0.5 (alphabetic script)
209
+ aligned 12/12 lines
210
+ ```
211
+
212
+ ```
213
+ [00:06.60]Amazing grace, how sweet the sound
214
+ [00:14.30]That saved a wretch like me
215
+ [00:23.70]I once was lost, but now am found
216
+ [00:34.52]Was blind, but now I see
217
+ ...
218
+ [01:46.24]We've no less days to sing God's praise
219
+ [01:57.46]Than when we first begun
220
+ ```
221
+
222
+ Note `--no-vad`: this is a slow hymn, and the ASR's voice-activity filter
223
+ mistakes sustained singing for silence. With the filter on, the same file yields
224
+ **one** garbage segment for 130 seconds; with it off, twelve clean ones. Keep the
225
+ filter for rap, drop it for anything sung slowly. (The `--pairing 1` is what
226
+ `auto` picks here anyway — this ASR already split one lyric line per segment.)
227
+
228
+ Drop `--no-vad` and you can watch the honest-gap contract hold: every line is
229
+ reported unmatched, nothing is written, and the cause is named.
230
+
231
+ ```
232
+ only 0/12 lines matched from 1 segments — try --no-vad (the voice-activity
233
+ filter silences slow singing), or --separate (a full mix hides the vocal
234
+ from the ASR)
235
+ ```
236
+
237
+ ### Feed it a vocal stem
238
+
239
+ Alignment quality is dominated by this one choice. Same track, same settings,
240
+ 20 human-marked lines — only the input differs:
241
+
242
+ | input | matched | mean \|err\| |
243
+ |---|---|---|
244
+ | Demucs vocal stem | **19/20** | **0.50 s** |
245
+ | full mix | 8/20 | 1.59 s |
246
+
247
+ On the full mix the ASR returned 11 segments for a 4-minute song instead of 41,
248
+ merging whole sections, and everything past the first chorus went unmatched. Use
249
+ `--separate` (or point the tool at a stem you already have). Separation is the
250
+ slow step, so the stem is cached and reused.
251
+
252
+ ### Library
253
+
254
+ ```python
255
+ from lyric_align import align, Segment
256
+
257
+ segments = [Segment.from_dict(d) for d in whisper_output]
258
+ aligned = align(segments, lyric_lines, karaoke=True) # pairing="auto"
259
+ for a in aligned:
260
+ print(a.start, a.matched, a.line)
261
+ ```
262
+
263
+ ## Design: honest gaps over silent drift
264
+
265
+ When a line can't be confidently matched, `lyric-align` marks it **unmatched**
266
+ rather than inventing a timestamp. Forced aligners always emit an answer and so
267
+ fail *silently* — drifting into the intro, or smearing a chorus. For a
268
+ review-in-the-loop workflow (subtitling, MV production) an honest gap you can
269
+ see beats a wrong number you can't. Pass `--interpolate` if you do want gaps
270
+ filled (they stay flagged as guessed).
271
+
272
+ ## How it works
273
+
274
+ 1. **(optional) vocal separation** — Demucs, if installed (`[separate]`).
275
+ 2. **ASR** — faster-whisper with word timestamps (`[asr]`), or bring your own segments.
276
+ 3. **anchor** — each known lyric line/stanza is matched to a segment by
277
+ character-level similarity, scanning forward monotonically so repeated
278
+ choruses consume segments in order.
279
+ 4. **breath split** — when one segment covers several lines, they're cut at the
280
+ largest inter-word silence (a breath), near the split point implied by
281
+ character counts.
282
+ 5. **char map** — known characters are distributed across the word span by
283
+ proportional interpolation, for karaoke `\k` timing.
284
+
285
+ The core (steps 3–5) is **pure Python standard library** — no numpy, no torch.
286
+ The heavy pieces (Whisper, Demucs) are optional extras behind lazy imports.
287
+
288
+ ## Where this came from
289
+
290
+ This was written to put lyrics on the timeline for a set of music videos, where
291
+ the lyrics are Japanese and the delivery is rap. That is the whole reason the
292
+ matcher compares characters instead of words, and why the accuracy below is
293
+ measured on sung Japanese rather than on read speech.
294
+
295
+ The two tracks the numbers come from are
296
+ [過ぎたるもの](https://youtu.be/cpXhuZK5rug) (20 lines, ±0.5 s ground truth) and
297
+ [黒砂の誓い](https://youtu.be/b8mjRge4Ffk) (33 lines, 1 s granularity). Others
298
+ from the same catalogue: [六の巷](https://youtu.be/OIonX0bZjmI),
299
+ [永遠の炎](https://youtu.be/lZIW59t9O-M) — [toryu.tokyo](https://toryu.tokyo).
300
+
301
+ Short lyric fragments from those tracks appear in the test fixtures. They are the
302
+ author's own work and are **not** covered by this project's MIT license — see
303
+ [NOTICE](NOTICE). The runnable example (`examples/amazing_grace.txt`) is public
304
+ domain, so anyone can reproduce it end to end.
305
+
306
+ ## Accuracy
307
+
308
+ Measured against 20 human-marked lyric lines of a 4-minute Japanese rap track
309
+ (±0.5 s ground-truth precision), aligning from a Demucs-separated vocal stem:
310
+
311
+ | method | matched | mean \|err\| | median | ≤0.5 s | ≤1.0 s |
312
+ |---|---|---|---|---|---|
313
+ | **lyric-align** (faster-whisper medium + fuzzy anchor) | 19/20 | 0.50 s | **0.36 s** | **14/20** | 18/20 |
314
+ | stable-ts `align()` (known text, now archived) | 20/20 | **0.43 s** | 0.43 s | 12/20 | **19/20** |
315
+ | WhisperX ja (transcribe + wav2vec2) | 4/20 | 47 s | 30.7 s | 0/20 | 1/20 |
316
+
317
+ `lyric-align` and `stable-ts` are practically equivalent: they split the columns,
318
+ and every gap between them is smaller than the ±0.5 s the ground truth was
319
+ eyeballed to. WhisperX's batched VAD
320
+ merges whole verses into single segments, which is fine for captions but loses
321
+ line-level timing (and it can't take known text as input).
322
+
323
+ A second track (3-minute Japanese rap, 33 human-marked lines, 1 s ground-truth
324
+ granularity) reproduces this: **33/33 matched, median |err| 0.30 s**, 26/33
325
+ within 0.5 s. Its mean of 0.94 s comes almost entirely from four outliers, all on
326
+ the *same* line — see below.
327
+
328
+ The ground truth on both tracks is marked per two-line pair, so these figures
329
+ score the *first* line of each pair — 20 and 33 lines, not every line placed.
330
+ Second lines have a weaker but free check: each must start inside its pair's
331
+ window. The shipped configuration passes it (19/19 and 29/33, the four misses
332
+ being the repeated hook already described), so the tables are not hiding a second
333
+ failure mode — but the distinction matters as soon as a change is judged on how
334
+ many lines it places, because placements land mostly on the unscored half. One
335
+ did, and it is the last entry in [Known limits](#known-limits).
336
+
337
+ ### A bigger ASR model is a trap unless the pairing follows
338
+
339
+ Lines are matched in stanza units, and `pairing` says how many lyric lines make
340
+ one unit. That is not a property of the song — it is a property of **the ASR's
341
+ segmentation**, and models differ. On the same four-minute track:
342
+
343
+ | model | segments | mean segment | pairing | mean \|err\| | ≤0.5 s | worst | lines placed |
344
+ |---|---|---|---|---|---|---|---|
345
+ | `medium` | 41 | 4.73 s | 2 | 0.50 s | 14/20 | 3.58 s | **74/76** |
346
+ | `large-v3` | 64 | 2.78 s | **1** | **0.31 s** | **15/20** | **0.76 s** | 49/76 |
347
+ | `large-v3` | 64 | 2.78 s | 2 | 1.13 s | 10/20 | 3.70 s | 68/76 |
348
+
349
+ `large-v3` transcribes visibly better, and with the pairing it deserves it
350
+ **removes the 3.6 s outlier entirely** — worst case 3.58 s → 0.76 s, mean down
351
+ 39 %. Left at a pairing tuned for `medium`, the same upgrade is *worse than not
352
+ upgrading*, because a two-line unit now straddles a segment boundary.
353
+
354
+ **Read the last column before switching.** At pairing 1 there are only 64
355
+ segments for 76 lines, so a quarter of them cannot place at all — that row is
356
+ more accurate *and* much less complete. Both `large-v3` rows match the same 18
357
+ of the 20 measured lines; the extra lines pairing 2 places are ones the ground
358
+ truth cannot check, and that configuration also produces six measurable
359
+ outliers against pairing 1's none. Which trade you want depends on whether you
360
+ are hand-correcting afterwards. `medium` remains the default because 74/76 with
361
+ one bad line is the better starting point for most people.
362
+
363
+ So the default is `--pairing auto`, which reads lines-per-segment off the ASR
364
+ output. On every case measured it matches or beats the old fixed 2:
365
+
366
+ | | lines / segments | auto picks | vs. fixed 2 |
367
+ |---|---|---|---|
368
+ | 過ぎたるもの, `medium` | 76 / 41 | 2 | identical |
369
+ | 過ぎたるもの, `large-v3` | 76 / 64 | **1** | 0.50 s → **0.31 s** |
370
+ | 黒砂, `medium` | 80 / 46 | 2 | identical |
371
+ | 黒砂, `large-v3` | 80 / 46 | 2 | identical |
372
+ | 過ぎたるもの, full mix (ASR collapsed) | 76 / 11 | 3 (capped) | no worse |
373
+
374
+ It does **not** rescue the repeated-hook track: `large-v3` is behind `medium`
375
+ there at every pairing, because four identical hook lines carry no information
376
+ about which repetition they are, whatever transcribes them. Better ASR fixes
377
+ outliers caused by *garbled text*; it cannot fix outliers caused by *identical*
378
+ text.
379
+
380
+ ### Against Vilm, the one other maintained tool here
381
+
382
+ [Vilm Lyrics Aligner](https://github.com/banjuman/vilm-lyrics-aligner) solves the
383
+ same problem for a different audience — live performance, Korean/English
384
+ code-switching, SRT into DaVinci Resolve, with a GUI and a Resolve panel where
385
+ this has a CLI. Same two tracks, from vocal stems, same ASR model size on both
386
+ sides:
387
+
388
+ | | 過ぎたるもの (no repeats) ||| 黒砂の誓い (4× repeated hook) |||
389
+ |---|---|---|---|---|---|---|
390
+ | | mean | ≤0.5 s | worst | mean | ≤0.5 s | worst |
391
+ | **lyric-align** | 0.50 s | **14/20** | 3.58 s | **0.94 s** | **26/33** | **6.36 s** |
392
+ | Vilm | 0.50 s | 10/20 | **2.06 s** | 1.46 s | 13/33 | 8.13 s |
393
+
394
+ Without repeats the means are identical to three decimals and we each take one
395
+ column: **their tail is 1.5 s shorter than ours**, our body has four more lines
396
+ inside half a second. With a four-times-repeated hook we are ahead two to one.
397
+
398
+ That second gap is the matcher, not the pipeline. Running *our* ASR output
399
+ through *their* matching layer scores 14/33 inside 0.5 s against our 26/33
400
+ (mean 1.55 s against 0.94 s) — their matcher is a single global Needleman-Wunsch
401
+ over the whole song's characters, and identical repetitions carry identical
402
+ similarity, which is the same result [we measured for a global
403
+ matcher](#known-limits) before finding theirs.
404
+
405
+ Two things this settles. **Character-level matching is not a differentiator** —
406
+ Vilm compares characters too, and also reports weak matches rather than forcing
407
+ them. What actually differs is smaller: a script-aware threshold instead of a
408
+ fixed 0.48, and locality instead of global optimality. And **their start
409
+ refinement is a genuinely better idea than ours**, gated on two independent
410
+ alignments agreeing; it needs a second aligner, which for us means torch, which
411
+ is the dependency this project exists to avoid.
412
+
413
+ ### Coming from stable-ts?
414
+
415
+ [stable-ts](https://github.com/jianfch/stable-ts) was archived on 2026-05-30, its
416
+ last commit being "Add note about paused development". The version `pip` installs
417
+ is older than that: 2.19.1, from **2025-08**, which predates its own final
418
+ alignment work (committed 2025-10, never released).
419
+
420
+ It did this job well, and this is not a claim to have beaten it. It is also not
421
+ a claim to have lost — the head-to-head splits, and every gap in it is smaller
422
+ than the ±0.5 s the ground truth was marked to:
423
+
424
+ | | matched | mean | median | ≤0.5 s | ≤1.0 s | first line |
425
+ |---|---|---|---|---|---|---|
426
+ | **lyric-align** | 19/20 | 0.50 s | **0.36 s** | **14/20** | 18/20 | **+0.28 s** |
427
+ | stable-ts `align()` | 20/20 | **0.43 s** | 0.43 s | 12/20 | **19/20** | −1.64 s |
428
+
429
+ They take the mean and the count; we take the median and the ≤0.5 s bucket. On a
430
+ ground truth eyeballed to half a second, a 0.07 s difference is not a result in
431
+ either direction.
432
+
433
+ The count is a contract, not accuracy. A forced aligner always emits, so
434
+ stable-ts places all 20 — and the twentieth is that first line, 1.64 s early,
435
+ sitting in the intro. `lyric-align` places 19 and says so about the one where the
436
+ ASR collapsed. What actually differs is everything around the accuracy:
437
+
438
+ | | stable-ts `align()` | lyric-align |
439
+ |---|---|---|
440
+ | status | archived 2026-05 | maintained |
441
+ | install | `torch` + `torchaudio` + `openai-whisper`, unconditionally | **nothing** for the core; `ctranslate2` (via `faster-whisper`) only if you want it to transcribe |
442
+ | matching | word-level forced alignment | character-level fuzzy anchor — no whitespace assumption |
443
+ | threshold | — | script-aware: 0.25 for CJK, 0.50 for alphabetic |
444
+ | a line it cannot place | always given a time | reported as unmatched |
445
+ | output | SRT, VTT, ASS, TSV, JSON | LRC, eLRC, SRT, VTT, ASS, TTML, JSON, Audacity labels |
446
+
447
+ ```python
448
+ # stable-ts
449
+ import stable_whisper
450
+ model = stable_whisper.load_model("medium")
451
+ result = model.align(audio, "\n".join(lines), language="ja", original_split=True)
452
+ result.to_srt_vtt("out.srt")
453
+ ```
454
+
455
+ ```bash
456
+ # lyric-align — format inferred from the extension
457
+ lyric-align audio.wav lyrics.txt --language ja -o out.srt
458
+ ```
459
+
460
+ The last row is the one to understand before switching. A forced aligner emits a
461
+ time for every line, so when it fails it fails *silently* — a line drifts into
462
+ the intro and nothing tells you. `lyric-align` leaves that line empty instead.
463
+ On error-prone sung ASR that is the point, but if you need a fully populated
464
+ timeline anyway, `--interpolate` fills the gaps and keeps `matched: false` on
465
+ them so you can still tell which ones were guessed.
466
+
467
+ ### Match threshold is script-aware
468
+
469
+ A line is accepted when its character similarity clears a threshold, and the
470
+ right floor depends on how many characters the language has to choose from. So
471
+ the default is picked from the lyrics themselves (`--threshold` overrides it):
472
+
473
+ | script | default | why |
474
+ |---|---|---|
475
+ | Japanese / Chinese | 0.25 | true matches against error-prone sung ASR drop as low as 0.26 |
476
+ | alphabetic | 0.50 | two *unrelated* English sentences already score 0.28–0.34 |
477
+
478
+ Using the CJK floor on English silently invents matches — measured on the hymn
479
+ above, "Through many dangers, toils and snares" was placed on the line
480
+ "We've no less days to sing God's praise" (similarity 0.34).
481
+
482
+ ## Known limits
483
+
484
+ - **Heavily repeated refrains can land on the wrong repetition.** A hook line
485
+ sung four times is four identical strings; if the ASR segments the repeats
486
+ unevenly, the forward scan can consume the neighbouring one. On the track
487
+ above, one 4×-repeated hook line produced errors of +6.4 s, −3.8 s, −4.5 s and
488
+ +5.7 s while every non-repeated line stayed within ~0.5 s. Check hook sections
489
+ by hand, or align verses and hooks as separate passes.
490
+
491
+ Replacing the forward scan with a globally optimal monotone assignment does
492
+ *not* fix this, and measured worse. Identical repetitions carry identical
493
+ similarity, so the global optimum just places more lines — and the extra ones
494
+ land on the wrong cycle:
495
+
496
+ | matcher | lines placed | mean \|err\| | within 0.5 s | worst |
497
+ |---|---|---|---|---|
498
+ | forward scan (shipped) | 70/80 | **0.94 s** | **26/33** | **6.4 s** |
499
+ | global optimum | 80/80 | 1.61 s | 22/33 | 10.6 s |
500
+ | global optimum + diagonal-drift penalty | 80/80 | 1.14 s | 25/33 | 10.6 s |
501
+
502
+ Telling repetitions apart needs a timing prior, not a better search over
503
+ similarity. Meanwhile the forward window is doing real work: it stops a line
504
+ from reaching a distant segment that happens to clear the threshold.
505
+
506
+ A timing prior was then tried, and also measured worse. Scoring candidates by
507
+ `similarity − λ·|start − predicted|`, where `predicted` is the last placement
508
+ plus the running median gap:
509
+
510
+ | λ | lines placed | mean \|err\| | within 0.5 s | worst |
511
+ |---|---|---|---|---|
512
+ | 0 (shipped) | 33/33 | **0.94 s** | **26/33** | **6.4 s** |
513
+ | 0.1 | 33/33 | 1.67 s | 21/33 | 10.6 s |
514
+ | 0.3 | 33/33 | 2.05 s | 19/33 | 10.6 s |
515
+ | 0.5 | 8/33 | — | 7/33 | — |
516
+
517
+ The prior predicts from the aligner's own previous placements, so it cannot
518
+ correct a bad one — it anchors on it and drags the next lines along, which is
519
+ why the worst case grows rather than shrinks. Songs also do not run at one
520
+ pace: a median gap mispredicts hardest across a section boundary, which is
521
+ exactly where repeated hooks sit.
522
+
523
+ Restricting the prior to breaking near-ties (candidates within ε similarity,
524
+ never overturning a clear winner) is the only variant that does not hurt, and
525
+ it does not clearly help either: ε=0.02 moved one line into the ≤0.5 s bucket
526
+ (26→27) and the mean by 0.07 s; ε=0.05 left the buckets alone and cut the
527
+ worst case to 5.7 s; ε=0.10 collapsed back to the harmful regime. The second
528
+ track was unchanged at every ε. A 0.07 s shift is below the 1 s resolution of
529
+ that track's ground truth, so there is no measurement here to ship on — and
530
+ the useful ε sits directly beside a harmful one. Left out.
531
+
532
+ A prior that would actually work has to come from a signal independent of the
533
+ aligner's output — audio-side section detection, say — which is a different
534
+ tool with a much heavier dependency than a stdlib core.
535
+ - **Correcting one placement tends to break the next one.** The first track's
536
+ worst case (3.6 s) has a fully diagnosed cause: `SequenceMatcher.ratio()`
537
+ divides by *both* strings' lengths, so a short segment matching only the
538
+ second half of a two-line unit outscores the longer segment that actually
539
+ starts it (0.615 vs 0.304; concatenating both gives 0.644, and the right
540
+ answer wins). Three independent fixes follow from that, and a fourth from how
541
+ a global character aligner gets sub-segment resolution. All four fix the
542
+ outlier. All four cost more elsewhere than they return:
543
+
544
+ | candidate selection | 過ぎたるもの mean / worst / ≤0.5 s | 黒砂 matched / mean / ≤0.5 s |
545
+ |---|---|---|
546
+ | forward scan (shipped) | **0.50 s** / 3.6 s / **14/20** | **33/33** / **0.94 s** / **26/33** |
547
+ | span up to 2 segments | 0.34 s / **0.7 s** / 15/20 | 29/33 / 2.13 s / 23/33 |
548
+ | score the unit's opening, not the whole unit | 0.34 s / **0.7 s** / 15/20 | 31/33 / 1.22 s / 23/33 |
549
+ | veto candidates matching only the unit's tail | 0.33 s / **0.7 s** / 14/20 | 32/33 / 1.74 s / 19/33 |
550
+ | ↑ but only on lines that never repeat | 0.34 s / **0.7 s** / 15/20 | 32/33 / 1.00 s / 25/33 |
551
+
552
+ The mechanism is the scan itself. `idx` advances to just past whatever was
553
+ chosen, so *every* neighbour is downstream of *every* decision. Gains and
554
+ losses arrive in adjacent pairs: the last row above fixes 2.30 s → 0.02 s at
555
+ 2:20 on the second track and breaks 0.32 s → 3.70 s at 2:26, six seconds
556
+ later. Across both tracks it nets to two lines fixed, one broken, one turned
557
+ into a gap, on 53 measured lines — which is not an improvement, it is noise.
558
+ Local accuracy does not compose in a greedy monotone scan, and that is why
559
+ four unrelated interventions all land on roughly the same total.
560
+
561
+ The fourth is worth naming separately because it is what Vilm does differently
562
+ ([above](#against-vilm-the-one-other-maintained-tool-here)). Taking each line's start from the word
563
+ its first character lands on — the sub-segment resolution a global character
564
+ aligner buys — measured *worse on both tracks* (mean 0.50 → 0.79 s and
565
+ 0.94 → 1.26 s; ≤0.5 s 14/20 → 9/20 and 26/33 → 20/33). Placements are already
566
+ late (signed mean +0.46 s and +0.21 s), and refining into the segment can only
567
+ add lateness. A sung phrase begins at its breath and attack, before the first
568
+ word the ASR is willing to timestamp, so the segment boundary is the better
569
+ estimate of onset.
570
+ - **A varying unit size places more lines, and some of them badly.** `pairing`
571
+ is a rounded average, so it is wrong for part of any track: at 76 lines over 64
572
+ segments the true ratio is 1.19, and a fixed 1 leaves 27 lines unplaced while a
573
+ fixed 2 straddles boundaries. Choosing `(lines, segment)` jointly at each step
574
+ fixes the placement count and looks nearly free on the shipped metric:
575
+
576
+ | unit size | lines placed | mean \|err\| | within 0.5 s | worst | 2nd lines outside their GT window |
577
+ |---|---|---|---|---|---|
578
+ | fixed, from the ASR (shipped) | 49/76 | **0.31 s** | **15/20** | **0.8 s** | **0 of 16** |
579
+ | variable, k ≤ 2 | 66/76 | 0.42 s | 14/20 | 2.0 s | 2 of 18 |
580
+ | variable, k ≤ 2, only if it wins by 0.1 | 66/76 | 0.32 s | **15/20** | **0.8 s** | 1 of 18 (by 13.3 s) |
581
+
582
+ The last column is the point. The other columns score only the *first* line of
583
+ each two-line ground-truth pair, which is where the extra placements do *not*
584
+ land — so on the shipped metric the third row is 17 free placements. Checking
585
+ the second lines, which have a known window to fall inside, shows what was
586
+ bought: the last verse line scores 0.000 against the hook segment on its own
587
+ and 0.286 once the following hook line is absorbed into the same unit, clearing
588
+ the 0.25 threshold, so it is placed 13.3 s late inside the hook — and the hook
589
+ line that had been correct is displaced with it. A unit picked to maximise
590
+ similarity will straddle a section boundary, and such a unit needs only its
591
+ tail to match; fixed pairing=1 cannot do this because a one-line unit has no
592
+ tail. Of the two extra placements that can be checked, one is right and one is
593
+ 13.3 s wrong.
594
+
595
+ On a track where the ASR merges two lines consistently the same move fails
596
+ from the other side: deviating downward orphans the remaining line onto the
597
+ next segment and shifts every later unit's phase, taking within-0.5 s from
598
+ 26/33 to 18/33 and landing the 4×-repeated hook a repetition early. Allowing
599
+ only *upward* deviation appears to fix that, but only because pairing=2 with
600
+ k ≤ 2 leaves upward no room — permitting k ≤ 3 breaks the same track again
601
+ (26/33 → 13/33).
602
+
603
+ This also disposes of the reason for trying it. The four attempts above all
604
+ changed which segment a unit selects, so the plan here was to change how much
605
+ a unit *consumes* and dodge that coupling. Consumption sets how fast the
606
+ segment cursor advances relative to the line cursor, so it moves `idx` as
607
+ well — one step removed, same result.
608
+ - **Slow, sustained singing is much harder than rap** — hymns, ballads and
609
+ school songs stretch vowels until the ASR stops producing usable segments.
610
+ Reach for `--no-vad` first (see the one-minute example); dense, consonant-rich
611
+ delivery is the sweet spot. This is an ASR limit, not an anchoring one.
612
+ - **A quiet or lo-fi recording can defeat the ASR entirely.** On a −33 dBFS
613
+ amateur recording of an unaccompanied Japanese art song, Whisper returned zero
614
+ segments, and returned `音楽` ("music") or a row of repeated single characters
615
+ once its silence thresholds were relaxed — it classified the singing as music
616
+ rather than speech. Loudness-normalizing to −16 LUFS did not help. When the
617
+ transcription is empty there is nothing to anchor to; check for segments before
618
+ blaming the alignment.
619
+
620
+ The nearest match, [stable-ts](https://github.com/jianfch/stable-ts), was
621
+ **archived in 2026-05** — see [Coming from stable-ts?](#coming-from-stable-ts)
622
+ for what carries over and what does not.
623
+
624
+ ## License
625
+
626
+ MIT, for the code, tests and documentation. The song lyrics quoted in the test
627
+ fixtures are not covered by it — see [NOTICE](NOTICE).