Skip to content

Commit 7dce0ff

Browse files
author
Thales
committed
feat: check exact-length lyrics against the vocals, and align them by hand
- LRCLIB's copy of a track's own length was trusted as the track's timing and never checked. Green Day "Basket Case" (the video) sings 16 s after that copy's first line. Exact-length LRCLIB lyrics are now checked against the vocals too and moved when the gap is clearly a second or more; a transcription and a file's own lyrics are never moved (#716). - The Lyrics tab gets Align: start the lyrics (or a chosen line) at the playhead, nudge earlier or later with the offset shown, auto-detect from the vocals (saying so when it cannot tell), and reset. The offset is kept beside the lyrics (offset_sec in lyrics.json, or the browser's store), never rewritten into them, and survives a re-split. New endpoints POST /api/jobs/{id}/lyrics/offset and .../lyrics/align (#717). - Acceptance check L4 runs auto-detect on the real Basket Case video.
1 parent e758753 commit 7dce0ff

17 files changed

Lines changed: 1489 additions & 28 deletions

‎app/api/jobs.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
JOB_ID_RE,
2222
JOBS_DIR,
2323
LYRICS_LOOKUP_BUDGET_SEC,
24+
LYRICS_OFFSET_MAX_SEC,
2425
MAX_PENDING_UPLOAD_JOBS,
2526
MAX_PENDING_URL_JOBS,
2627
STEM_NAMES,
@@ -49,7 +50,9 @@
4950
from app.pipeline.download import InvalidYouTubeURL, fetch_audio_tags, validate_youtube_url
5051
from app.pipeline.errors import classify_failure
5152
from app.pipeline.identify import can_identify_title, identify_and_find_band
53+
from app.pipeline.lyrics_align import detect_offset
5254
from app.pipeline.lyrics_lookup import (
55+
_TEXT_MAX_CHARS,
5356
build_query,
5457
candidates_path,
5558
copy_lyrics,
@@ -59,6 +62,7 @@
5962
lyrics_settled,
6063
read_candidates,
6164
read_lyrics,
65+
set_lyrics_offset,
6266
)
6367
from app.pipeline.runner import _pipeline_lock
6468
from app.pipeline.vocal_split import split_vocals
@@ -1058,6 +1062,134 @@ def get_lyrics(job_id: str) -> Response:
10581062
raise HTTPException(status_code=404, detail="no lyrics")
10591063

10601064

1065+
class LyricsOffsetBody(BaseModel):
1066+
"""How many seconds later than their own timing the lyrics are shown:
1067+
negative for earlier, 0 for their own timing again."""
1068+
1069+
offset_sec: float = Field(
1070+
ge=-LYRICS_OFFSET_MAX_SEC, le=LYRICS_OFFSET_MAX_SEC, allow_inf_nan=False
1071+
)
1072+
1073+
1074+
class LyricsAlignBody(BaseModel):
1075+
"""Synced lyrics kept only in the browser (found by the tab's own
1076+
LRCLIB lookup), to estimate against the track's vocals without saving
1077+
anything. Omitted for the lyrics the server keeps (lyrics.json)."""
1078+
1079+
synced: str | None = Field(default=None, max_length=_TEXT_MAX_CHARS)
1080+
1081+
1082+
def _lyrics_job(job_id: str) -> tuple[Job, Path]:
1083+
"""The job and its directory, for a lyrics edit: 404 for a malformed or
1084+
unknown id, or one whose lyrics.json would resolve outside JOBS_DIR."""
1085+
if not JOB_ID_RE.match(job_id):
1086+
raise HTTPException(status_code=404, detail="job not found")
1087+
job = registry_get(job_id)
1088+
if job is None:
1089+
raise HTTPException(status_code=404, detail="job not found")
1090+
job_dir = _job_dir(job_id)
1091+
if not lyrics_path(job_dir).resolve().is_relative_to(JOBS_DIR.resolve()):
1092+
raise HTTPException(status_code=404, detail="no lyrics")
1093+
return job, job_dir
1094+
1095+
1096+
def _synced_lyrics(job_dir: Path) -> dict:
1097+
"""Blocking: the job's lyrics.json when it has synced lyrics. 404 when it
1098+
has none, 409 when they are text only and have no timing to move."""
1099+
entry = read_lyrics(job_dir) if lyrics_path(job_dir).is_file() else None
1100+
if entry is None:
1101+
raise HTTPException(status_code=404, detail="no lyrics")
1102+
if not entry["synced"]:
1103+
raise HTTPException(status_code=409, detail="lyrics are not synced")
1104+
return entry
1105+
1106+
1107+
# A lyrics edit's body: an offset, or at most _TEXT_MAX_CHARS of LRC, which
1108+
# as UTF-8 runs to four bytes a character.
1109+
_LYRICS_BODY_MAX_BYTES = 4 * _TEXT_MAX_CHARS + 1024
1110+
1111+
1112+
async def _lyrics_body(request: Request, model: type[BaseModel], empty_ok: bool = False):
1113+
"""A lyrics edit's JSON body as ``model``: None for no body at all when
1114+
``empty_ok``. 413 past _LYRICS_BODY_MAX_BYTES, else 422 for anything that
1115+
is not the model, with NaN and Infinity refused while parsing (see
1116+
_reject_non_finite) and nothing sent back of what was submitted."""
1117+
declared = request.headers.get("content-length", "")
1118+
if declared.isdigit() and int(declared) > _LYRICS_BODY_MAX_BYTES:
1119+
raise HTTPException(status_code=413, detail="body too large")
1120+
raw = await request.body()
1121+
if len(raw) > _LYRICS_BODY_MAX_BYTES:
1122+
raise HTTPException(status_code=413, detail="body too large")
1123+
if not raw.strip() and empty_ok:
1124+
return None
1125+
try:
1126+
data = json.loads(raw, parse_constant=_reject_non_finite)
1127+
except (ValueError, UnicodeDecodeError) as exc:
1128+
raise HTTPException(status_code=422, detail="invalid JSON body") from exc
1129+
if not isinstance(data, dict):
1130+
raise HTTPException(status_code=422, detail="expected a JSON object")
1131+
try:
1132+
return model(**data)
1133+
except Exception as exc:
1134+
raise HTTPException(status_code=422, detail="invalid lyrics edit") from exc
1135+
1136+
1137+
@router.post("/{job_id}/lyrics/offset")
1138+
async def set_lyrics_offset_route(job_id: str, request: Request) -> dict:
1139+
"""Keep the user's alignment of the track's synced lyrics (the Lyrics
1140+
tab's Align panel): {"offset_sec": seconds later, negative for earlier,
1141+
0 for their own timing}. Kept beside the synced text in lyrics.json, which
1142+
is never rewritten, and applied by the page. Answers the offset kept."""
1143+
job, job_dir = _lyrics_job(job_id)
1144+
body = await _lyrics_body(request, LyricsOffsetBody)
1145+
await asyncio.to_thread(_synced_lyrics, job_dir)
1146+
entry = await asyncio.to_thread(set_lyrics_offset, job, job_dir, body.offset_sec)
1147+
if entry is None:
1148+
raise HTTPException(status_code=500, detail="could not save lyrics")
1149+
return {"offset_sec": entry["offset_sec"]}
1150+
1151+
1152+
@router.post("/{job_id}/lyrics/align")
1153+
async def align_lyrics_route(job_id: str, request: Request) -> dict:
1154+
"""Estimate from the vocals stem how much later the lyrics are sung on
1155+
this track than their stamps say, whatever timing they were saved with
1156+
(the Align panel's Auto-detect): {"confident": bool, "offset_sec":
1157+
seconds or null}.
1158+
1159+
Without a body, for the lyrics the server keeps: a confident estimate is
1160+
kept as their offset, as POST .../lyrics/offset would, and one that is
1161+
not leaves them as they are. With {"synced": ...}, lyrics the browser
1162+
keeps: only the estimate is answered, and nothing is saved.
1163+
1164+
Outside _pipeline_lock, like GET .../vocal-envelope: at most one streamed
1165+
read of one stem, and none when its envelope is already kept."""
1166+
job, job_dir = _lyrics_job(job_id)
1167+
body = await _lyrics_body(request, LyricsAlignBody, empty_ok=True)
1168+
if job.status != "done":
1169+
raise HTTPException(status_code=409, detail="job not ready")
1170+
stems_dir = (job_dir / "stems").resolve()
1171+
if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
1172+
raise HTTPException(status_code=404, detail="job not found")
1173+
kept_here = body is None or body.synced is None
1174+
if kept_here:
1175+
synced = (await asyncio.to_thread(_synced_lyrics, job_dir))["synced"]
1176+
else:
1177+
synced = body.synced
1178+
try:
1179+
shift = await asyncio.to_thread(detect_offset, synced, stems_dir)
1180+
except Exception:
1181+
logger.exception("lyrics alignment failed for %s", job_id)
1182+
raise HTTPException(status_code=500, detail="could not align lyrics") from None
1183+
if shift is None or abs(shift) > LYRICS_OFFSET_MAX_SEC:
1184+
return {"confident": False, "offset_sec": None}
1185+
if kept_here:
1186+
entry = await asyncio.to_thread(set_lyrics_offset, job, job_dir, shift)
1187+
if entry is None:
1188+
raise HTTPException(status_code=500, detail="could not save lyrics")
1189+
shift = entry["offset_sec"]
1190+
return {"confident": True, "offset_sec": shift}
1191+
1192+
10611193
# Upper bound on an edited grid. A 20-minute track at 300 BPM is ~6000 beats;
10621194
# 20000 leaves generous headroom while refusing a payload crafted to exhaust
10631195
# memory or disk.

‎app/core/config.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,15 @@ def _musicbrainz_user_agent() -> str:
537537
LYRICS_ALIGN_MIN_Z = 4.5
538538
LYRICS_ALIGN_MIN_RATIO = 1.15
539539
LYRICS_ALIGN_MIN_HITS = 0.6
540+
# LRCLIB's version the track's own length ("timing": "exact") is checked the
541+
# same way, and moved only when the voice clearly starts at least this much
542+
# later or earlier: a copy of the right length can still be timed to another
543+
# cut (Green Day "Basket Case": the video's lines start 16 s after the copy's).
544+
LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC = 1.0
545+
# How far the Lyrics tab's Align panel may move a track's lyrics, either way
546+
# ("offset_sec" in lyrics.json, POST /api/jobs/{id}/lyrics/offset): a long
547+
# intro or a medley's later verse, and no further.
548+
LYRICS_OFFSET_MAX_SEC = 600
540549
# Lyrics that lost their letters outside ASCII, mended from a reference that
541550
# has them (app/pipeline/lyrics_repair.py). A copy with no intact twin on
542551
# LRCLIB is checked against the audio only when it has at least this many

‎app/pipeline/lyrics_align.py‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Moving LRCLIB's timing onto this track, for lyrics from a version of
2-
another length (lyrics_lookup.py saves those with "timing": "unverified").
2+
another length (lyrics_lookup.py saves those with "timing": "unverified"),
3+
and for a version of the same length whose lines the vocals show start at
4+
least LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC away: a copy the track's length can
5+
still be timed to another cut, and its length alone proved nothing.
36
47
A version of a song a few seconds longer or shorter than the track is most
58
often the same recording with more or less lead-in: a video's intro, a
@@ -32,6 +35,7 @@
3235
from numpy.lib.stride_tricks import sliding_window_view
3336

3437
from app.core.config import (
38+
LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC,
3539
LYRICS_ALIGN_MAX_OFFSET_SEC,
3640
LYRICS_ALIGN_MIN_HITS,
3741
LYRICS_ALIGN_MIN_LINES,
@@ -203,24 +207,49 @@ def _envelope(stems_dir: Path) -> tuple[float, list[int]] | None:
203207
return vocal_envelope(vocals, _ENVELOPE_HOP_SEC)
204208

205209

210+
def detect_offset(synced: str, stems_dir: Path) -> float | None:
211+
"""How many seconds later ``synced``'s lines are sung on this track, from
212+
the vocals stem in ``stems_dir``, whatever timing they were saved with:
213+
the Lyrics tab's Auto-detect. None when the stem does not show it clearly,
214+
or there is no stem. Blocking: may read the whole stem."""
215+
envelope = _envelope(stems_dir)
216+
if envelope is None:
217+
return None
218+
hop, db = envelope
219+
return estimate_offset(line_starts(synced), onset_strength(db), hop)
220+
221+
206222
def align_lyrics(job: Job, job_dir: Path) -> str | None:
207-
"""Move unverified lyrics' timing onto this track, when the vocals stem
208-
shows clearly by how much. Returns the timing the job's lyrics.json is
209-
left with, or None when it has none to align. Never raises."""
223+
"""Move LRCLIB lyrics' timing onto this track, when the vocals stem shows
224+
clearly by how much: a version of another length ("unverified") by any
225+
amount, one of the track's length ("exact") only by at least
226+
LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC. A transcription or the file's own
227+
lyrics are the track's timing already. Returns the timing the job's
228+
lyrics.json is left with, or None when it has none to align. Never
229+
raises."""
210230
# Imported here: lyrics_lookup imports this module.
211231
from app.pipeline.lyrics_lookup import read_lyrics, write_lyrics
212232

213233
try:
214234
entry = read_lyrics(job_dir)
215-
if entry is None or entry.get("timing") != "unverified" or not entry["synced"]:
235+
if entry is None or not entry["synced"]:
216236
return entry.get("timing") if entry else None
237+
timing = entry.get("timing")
238+
# Aligned by hand in the Lyrics tab: the user's timing, not to guess at.
239+
if "offset_sec" in entry:
240+
return timing
241+
checked = timing == "unverified" or (timing == "exact" and entry["source"] == "lrclib")
242+
if not checked:
243+
return timing
217244
envelope = _envelope(job_dir / "stems")
218245
if envelope is None:
219-
return "unverified"
246+
return timing
220247
hop, db = envelope
221248
shift = estimate_offset(line_starts(entry["synced"]), onset_strength(db), hop)
222249
if shift is None:
223-
return "unverified"
250+
return timing
251+
if timing == "exact" and abs(shift) < LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC:
252+
return timing
224253
logger.info("[%s] lyrics timing moved by %+.2fs", job.id, shift)
225254
write_lyrics(
226255
job,

‎app/pipeline/lyrics_lookup.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
LYRICS_LOOKUP_RETRIES,
8585
LYRICS_LOOKUP_RETRY_SEC,
8686
LYRICS_NOT_FOUND_RETRY_SEC,
87+
LYRICS_OFFSET_MAX_SEC,
8788
LYRICS_OTHERS_MAX,
8889
LYRICS_SAME_RECORDING_SEC,
8990
TIMEOUT_LYRICS_LOOKUP,
@@ -1035,6 +1036,16 @@ def _clean_version(value: Any) -> dict[str, Any] | None:
10351036
return version
10361037

10371038

1039+
def clean_offset(value: Any) -> float | None:
1040+
"""A lyrics offset in seconds, to the hundredth, or None when ``value``
1041+
is not a finite number within +-LYRICS_OFFSET_MAX_SEC."""
1042+
if isinstance(value, bool) or not isinstance(value, (int, float)):
1043+
return None
1044+
if not math.isfinite(value) or abs(value) > LYRICS_OFFSET_MAX_SEC:
1045+
return None
1046+
return round(float(value), 2) + 0.0 # + 0.0: never -0.0
1047+
1048+
10381049
def clean_lyrics(value: Any) -> dict[str, Any] | None:
10391050
"""lyrics.json as read back from disk, or None when it is not one. A
10401051
damaged or hand-edited file must not put anything malformed in front of
@@ -1053,6 +1064,12 @@ def clean_lyrics(value: Any) -> dict[str, Any] | None:
10531064
# Only on lyrics whose lost letters were given back (lyrics_repair.py).
10541065
if value.get("repaired") in LYRICS_REPAIRS:
10551066
entry["repaired"] = value["repaired"]
1067+
# Only once the user has aligned them in the Lyrics tab: seconds later
1068+
# (earlier when negative) than the synced text says, applied by the page.
1069+
# A value out of bounds is dropped rather than the whole file.
1070+
offset = clean_offset(value.get("offset_sec"))
1071+
if offset is not None:
1072+
entry["offset_sec"] = offset
10561073
others = value.get("others")
10571074
cleaned = [_clean_version(o) for o in (others if isinstance(others, list) else [])]
10581075
entry["others"] = [o for o in cleaned if o is not None][:LYRICS_OTHERS_MAX]
@@ -1160,9 +1177,31 @@ def keep_answer(job: Job, job_dir: Path, answer: LookupAnswer) -> bool:
11601177
return True
11611178

11621179

1180+
# One edit of lyrics.json at a time from the Lyrics tab: each reads the file,
1181+
# changes the offset and writes it back whole.
1182+
_OFFSET_LOCK = threading.Lock()
1183+
1184+
1185+
def set_lyrics_offset(job: Job, job_dir: Path, offset: float) -> dict[str, Any] | None:
1186+
"""Keep ``offset`` seconds as the user's alignment of the job's synced
1187+
lyrics (0 is their own timing again) and return the entry as written.
1188+
None when the job has no synced lyrics or they could not be written. The
1189+
synced text itself is never rewritten, so their own timing stays there."""
1190+
cleaned = clean_offset(offset)
1191+
if cleaned is None:
1192+
return None
1193+
with _OFFSET_LOCK:
1194+
entry = read_lyrics(job_dir)
1195+
if entry is None or not entry["synced"]:
1196+
return None
1197+
entry = {**entry, "offset_sec": cleaned}
1198+
return entry if write_lyrics(job, job_dir, entry) else None
1199+
1200+
11631201
def copy_lyrics(src_dir: Path, dest_dir: Path) -> bool:
11641202
"""Copy a job's lyrics.json and candidates file to another job of the
1165-
same recording (a re-split). True when lyrics.json was copied."""
1203+
same recording (a re-split), with any alignment the user gave the lyrics
1204+
(offset_sec). True when lyrics.json was copied."""
11661205
copied = False
11671206
for path in (lyrics_path(src_dir), candidates_path(src_dir)):
11681207
if not path.is_file():

‎static/css/daw.css‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3922,12 +3922,53 @@ input, textarea { font-family: inherit; }
39223922
.lyrics-match { display: flex; flex-direction: column; min-width: 0; }
39233923
.lyrics-match-title { font-size: 13px; font-weight: 600; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39243924
.lyrics-match-meta { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3925-
.lyrics-tools { display: flex; gap: 12px; }
3925+
.lyrics-tools { display: flex; flex-wrap: wrap; gap: 4px 12px; }
39263926
.lyrics-link {
39273927
padding: 0; background: none; border: none; cursor: pointer;
39283928
color: var(--accent); font-size: 11px; font-weight: 600;
39293929
}
39303930
.lyrics-link:hover { text-decoration: underline; }
3931+
/* Align, for lyrics with no timing to move: still focusable, so its tooltip
3932+
says why (lyrics.js). */
3933+
.lyrics-link[aria-disabled="true"] { color: var(--muted); cursor: default; }
3934+
.lyrics-link[aria-disabled="true"]:hover { text-decoration: none; }
3935+
3936+
/* The Align panel: under the tools row, the sidebar's width, every control a
3937+
button so the keys reach them all. The nudges sit either side of the
3938+
offset they change. */
3939+
.lyrics-align {
3940+
display: flex; flex-direction: column; gap: 6px;
3941+
padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
3942+
}
3943+
.lyrics-align-hint { margin: 0; font-size: 10.5px; line-height: 1.4; color: var(--muted); }
3944+
.lyrics-align-target {
3945+
margin: 0; font-size: 11px; color: var(--fg-2);
3946+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3947+
}
3948+
.lyrics-align-target:empty { display: none; }
3949+
.lyrics-align-btn {
3950+
min-height: 26px; padding: 0 8px; border-radius: 6px;
3951+
background: var(--panel-2); border: 1px solid var(--border-strong);
3952+
color: var(--fg-2); font-family: inherit; font-size: 11px; font-weight: 600;
3953+
cursor: pointer; transition: background var(--t-fast), color var(--t-fast);
3954+
}
3955+
.lyrics-align-btn:hover { background: var(--panel-3); color: var(--fg); }
3956+
.lyrics-align-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
3957+
.lyrics-align-btn[aria-busy="true"] { cursor: progress; opacity: 0.6; }
3958+
.lyrics-align-start { width: 100%; color: var(--accent); border-color: var(--accent); }
3959+
.lyrics-align-nudges { display: flex; align-items: center; gap: 4px; }
3960+
.lyrics-align-nudge { flex: 0 0 auto; padding: 0 6px; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
3961+
.lyrics-align-offset {
3962+
flex: 1 1 auto; min-width: 0; text-align: center;
3963+
font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--fg);
3964+
font-variant-numeric: tabular-nums; white-space: nowrap;
3965+
}
3966+
.lyrics-align-actions { display: flex; gap: 6px; }
3967+
.lyrics-align-actions .lyrics-align-btn { flex: 1 1 0; min-width: 0; }
3968+
.lyrics-align-message { margin: 0; font-size: 11px; line-height: 1.4; color: var(--fg-2); }
3969+
.lyrics-align-message:empty { display: none; }
3970+
.lyrics-align-message.muted, .lyrics-align-message.loading { color: var(--muted); }
3971+
.lyrics-align-message.error { color: var(--danger); }
39313972
/* display: flex would otherwise outrank the hidden attribute that folds it. */
39323973
.lyrics-version-list[hidden] { display: none; }
39333974
.lyrics-version-list { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; }
@@ -3963,6 +4004,9 @@ input, textarea { font-family: inherit; }
39634004
/* The line after it, a step brighter than the rest, so the next words can be
39644005
read before they are due. */
39654006
.lyrics-line.next { color: var(--fg-2); }
4007+
/* While the Align panel is open, the line last clicked, which Start lyrics
4008+
here moves instead of the first. */
4009+
.lyrics-body.aligning .lyrics-line.picked { outline: 1px dashed var(--border-strong); outline-offset: -1px; }
39664010
/* The karaoke wipe, one word at a time: sung words take the accent, and the
39674011
word being sung is filled left to right up to --fill, set every frame by
39684012
lyrics.js. Per word rather than across the whole line, so a line that wraps

0 commit comments

Comments
 (0)