|
21 | 21 | JOB_ID_RE, |
22 | 22 | JOBS_DIR, |
23 | 23 | LYRICS_LOOKUP_BUDGET_SEC, |
| 24 | + LYRICS_OFFSET_MAX_SEC, |
24 | 25 | MAX_PENDING_UPLOAD_JOBS, |
25 | 26 | MAX_PENDING_URL_JOBS, |
26 | 27 | STEM_NAMES, |
|
49 | 50 | from app.pipeline.download import InvalidYouTubeURL, fetch_audio_tags, validate_youtube_url |
50 | 51 | from app.pipeline.errors import classify_failure |
51 | 52 | from app.pipeline.identify import can_identify_title, identify_and_find_band |
| 53 | +from app.pipeline.lyrics_align import detect_offset |
52 | 54 | from app.pipeline.lyrics_lookup import ( |
| 55 | + _TEXT_MAX_CHARS, |
53 | 56 | build_query, |
54 | 57 | candidates_path, |
55 | 58 | copy_lyrics, |
|
59 | 62 | lyrics_settled, |
60 | 63 | read_candidates, |
61 | 64 | read_lyrics, |
| 65 | + set_lyrics_offset, |
62 | 66 | ) |
63 | 67 | from app.pipeline.runner import _pipeline_lock |
64 | 68 | from app.pipeline.vocal_split import split_vocals |
@@ -1058,6 +1062,134 @@ def get_lyrics(job_id: str) -> Response: |
1058 | 1062 | raise HTTPException(status_code=404, detail="no lyrics") |
1059 | 1063 |
|
1060 | 1064 |
|
| 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 | + |
1061 | 1193 | # Upper bound on an edited grid. A 20-minute track at 300 BPM is ~6000 beats; |
1062 | 1194 | # 20000 leaves generous headroom while refusing a payload crafted to exhaust |
1063 | 1195 | # memory or disk. |
|
0 commit comments