Compare commits

...
Author SHA1 Message Date
branderson 55e8297672 acquire: install yt-dlp's YouTube extras and retry across player clients
yt-dlp declares `dependencies = []` and puts everything in extras, so the
bare `yt-dlp` requirement installed it with no yt-dlp-ejs and no JavaScript
runtime. Without those it cannot solve YouTube's player challenges and falls
back to media URLs that YouTube rejects with HTTP 403, which is the failure
seen when adding a song. Require `yt-dlp[default,curl-cffi,deno]` instead;
the deno extra ships the JS runtime as a wheel, so the image needs no extra
apt packages.

Around that, make the stage survive the next time YouTube changes something:
the download is retried against each player client in `ytdlp_clients`
(default `["default", "tv"]`, the latter not needing a PO token) before
giving up, yt-dlp's retry counts are raised, and its own log lines are routed
into the job log so a PO-token demand can be told apart from a rate limit.
The final error names the running yt-dlp version, since a stale one is the
usual cause.

Cookies, TLS impersonation and a bgutil PO token provider URL are now config
fields, so an operator can work around a block without a code change.
`_classify_downloads` skips .part/.ytdl scratch files, which retries make far
more likely to be lying around.
2026-09-25 18:24:40 -07:00
branderson a7622c2d01 Merge pull request 'Add rebuild-from-scratch, delete, and cross-restart persistence for songs' (#5) from song-rebuild-persistence into main
Reviewed-on: #5
2026-07-28 03:25:48 -07:00
branderson c489dd5742 docs: regenerate webui.png 2026-07-28 03:26:08 -07:00
branderson f1b967b487 Merge remote-tracking branch 'origin/main' into song-rebuild-persistence
# Conflicts:
#	src/songtrace/web.py
#	tests/test_web.py
2026-07-28 03:24:04 -07:00
branderson e141cc5773 Fix DELETE slug-collision race and widen _discover_jobs error handling
- DELETE now checks under the shared lock whether another job with the
  same slug is queued/running before removing the job entry, so
  deleting a done parent can no longer rmtree a rebuild's active
  work/output directories.
- _discover_jobs now wraps the iterdir/stat/read_warnings calls in the
  same try/except as the JSON parse, so one corrupt or torn sidecar
  file (e.g. from a killed mid-write) no longer crashes the whole app
  at boot.
- Fixed test_delete_tolerates_a_work_dir_that_is_already_gone, which
  had become vacuous since _write_sidecar now eagerly creates the work
  dir; it now removes the dir before asserting DELETE tolerates its
  absence.
- Added a regression test covering the DELETE slug-collision race.
2026-07-28 03:06:09 -07:00
branderson 92948b2ea7 webui: add rebuild-from-scratch and delete actions 2026-07-28 02:52:43 -07:00
branderson c8c3d941ca web: persist song input as a sidecar and reload jobs on startup 2026-07-28 02:38:55 -07:00
branderson 7b8fce5b6e web: add delete endpoint 2026-07-28 02:34:09 -07:00
branderson cb10400a21 web: add force-rebuild-from-scratch endpoint 2026-07-28 02:31:23 -07:00
branderson 98e4f9532f pipeline: extract read_warnings for reuse by startup discovery 2026-07-28 02:28:27 -07:00
branderson 77bebcf9e1 Merge pull request 'Webui: open video source, restructure lyrics form, and add a visual per-line review' (#4) from worktree-webui-lyrics-review into main
Reviewed-on: #4
2026-07-28 01:42:56 -07:00
branderson 986f26b8ed Fix note bars hidden under alternating word-lane tint
.review-lane spans the full timeline box height (top:0; bottom:0) to
group each word with its note visually, but as a positioned element it
painted after the unpositioned .review-roll SVG, so the lane's
background covered the note bars for every other word. Give .review-roll
position: relative so DOM order (lanes, then notes) governs paint order
instead.
2026-07-28 01:43:04 -07:00
branderson d557ac6386 Use real Greensleeves melody in webui screenshot fixture
Replace arbitrary demo pitches with the actual melody transcribed from
Gilbert DeBenedetti's public-domain "Level 4, Flowing" arrangement (E
minor, 6/8), converted to UltraStar semitones, with one note per word
across both demo lines.
2026-07-28 01:35:44 -07:00
branderson 5023ea69d3 review: lay out lyrics words along a shared time axis with notes aligned beneath
The review panel used to show a full line of packed text with one
undifferentiated strip of notes below it, so it was impossible to tell
which notes belonged to which word. The forced-alignment stage already
produces per-word timestamps in alignment.json, but they were never
surfaced past the pipeline.

/api/jobs/{id}/review now includes each line's per-word timing, and the
frontend spaces words out by their real sung timing (karaoke-style)
instead of normal text flow, with the note strip sharing that same time
axis so each note lands directly under its word. Alternating lane
backgrounds make the word/note pairing unambiguous. Dropped lines and
jobs without alignment data keep the old plain-text rendering.
2026-07-28 01:22:48 -07:00
branderson 0599f6b665 docs: regenerate webui.png with the new lyrics review panel 2026-07-28 00:48:42 -07:00
branderson 2debfb171c transcribe: scope missing_spans to the unmatched audio itself, not the whole vocal section
Also fix a secondary ripple in build_notes(): a skipped row (inside a
missing span) no longer advances prev_line/prev_end, so it can't corrupt
line-initial spacing or break timing for its own line's later words.
2026-07-28 00:48:19 -07:00
branderson ac904cb09f screenshot: select the new Greensleeves fixture so it actually appears in the regenerated screenshot 2026-07-28 00:48:19 -07:00
branderson 8e89b08571 screenshot fixture: add songs with dropped lines, missing span, low-confidence notes 2026-07-28 00:48:08 -07:00
branderson 6a98ea0f21 fix: clear lyrics-review cache on job status change
renderLyricsReview was caching its job ID only in the done path, allowing
stale data to persist when switching between done/running/failed jobs.
Now clears the cache in the failed and not-done branches so a previously-
viewed done job always re-fetches when re-selected.

Regression test: test_lyrics_review_cache_clears_on_job_status_change
verifies both branches clear the cache key before rendering placeholders.
2026-07-27 23:36:56 -07:00
branderson 903e83dbcc webui: replace flat warnings list with per-line SVG piano-roll review 2026-07-27 23:24:39 -07:00
branderson a4e0b45953 web: add GET /api/jobs/{id}/review for per-line note data 2026-07-27 23:18:26 -07:00
branderson ffd662fb38 notes: skip notes in unmatched spans, warn on dropped lines and missing lyrics 2026-07-27 23:13:39 -07:00
branderson 43f0554796 align: remove unused RefLyrics import 2026-07-27 23:11:30 -07:00
branderson ebb7fc4433 align: exclude never-sung lines from proportional distribution, record align_meta.json
- Added classify_lyrics_match import to wire lyric classification into align stage
- Changed _assign_lines_to_sections signature to accept line_indices list instead of ref
- Modified _run to classify lyrics match, filter dropped lines, and write align_meta.json
- Sort segments by start time before processing for correct matching of non-chronological transcripts
- Added tests for dropped line detection and missing span flagging
2026-07-27 23:06:02 -07:00
branderson cf1c7410ab transcribe: classify reference lines as matched/dropped, flag unmatched singing 2026-07-27 22:58:58 -07:00
branderson 5bc4855264 webui: move video source out of Advanced, give lyrics a tall column 2026-07-27 22:51:36 -07:00
branderson a650c2f7ec Move webui image to top 2026-07-27 19:59:46 -07:00
branderson 283da7e1a9 Fix yass link 2026-07-27 19:54:36 -07:00
branderson 292fa25d5e Fix Melody Mania link 2026-07-27 19:51:53 -07:00
branderson 0eaab6aeed Remove karaoke maker reference 2026-07-27 19:49:30 -07:00
branderson 7a48942f1c Merge pull request 'Rename project from karaoke-maker to songtrace' (#3) from songtrace-rename into main
Reviewed-on: branderson/karaoke-maker#3
2026-07-27 18:42:57 -07:00
20 changed files with 1342 additions and 147 deletions
+48 -19
View File
@@ -4,8 +4,10 @@ Turn a song and its lyrics into a playable UltraStar chart.
A CLI and minimal web UI that turns a song — title, artist, lyrics you supply,
and a YouTube URL or local video file — into a playable
[UltraStar](https://usdx.eu/format/)-format song folder for
[Melody Mania](https://melodymania.net/), a commercial UltraStar Deluxe fork.
[UltraStar](https://usdx.eu/format/)-format song folder. Also compatible with
[Melody Mania](https://melodymania.org/).
![Web UI](docs/images/webui.png)
The pipeline:
@@ -29,7 +31,7 @@ output.
**The output is a best-effort draft**, not a finished song. Every run ends by
printing the low-confidence regions it found (e.g.
`3 low-confidence notes near 0:42–0:47`) — open the result in an UltraStar
editor such as [Yass](http://www.yass-hq.de/) or
editor such as [Yass](https://www.yass-along.com/) or
[USDX](https://usdx.eu/) and touch those spots up before playing.
## Configuration
@@ -40,19 +42,6 @@ Config is TOML. Resolution order:
2. the `SONGTRACE_CONFIG` environment variable
3. `~/.config/songtrace/config.toml`
If you used this project under its former name, move your existing config once:
```sh
pip uninstall karaoke-maker # removes the old console script
mv ~/.config/karaoke-maker ~/.config/songtrace
```
The Docker deployment needs no config migration — the image sets
`SONGTRACE_CONFIG` itself and the config is bind-mounted to a fixed path.
Run `docker compose down` before upgrading (or `docker compose up -d
--remove-orphans`) since the service key changed and Compose won't replace
the old container in place.
Copy the example and fill in your Whisper server:
```sh
@@ -62,7 +51,9 @@ $EDITOR config.toml # set whisper_url at minimum
See `config.example.toml` for every available field (`whisper_url`,
`whisper_api_key`, `whisper_model`, `whisper_api`, `output_dir`, `work_dir`,
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`).
`creator`, `demucs_model`, `crepe_model`, `device`, `bind_host`, `port`,
`ytdlp_clients`, `ytdlp_cookies`, `ytdlp_impersonate`,
`ytdlp_pot_provider_url`).
`whisper_api` selects the wire format: `"openai"` (default) speaks the
OpenAI-compatible `POST {whisper_url}/audio/transcriptions` API; `"asr-webservice"`
@@ -83,8 +74,6 @@ The web UI is then at `http://localhost:8000`. Downloaded model weights
(Demucs, wav2vec2, ...) are cached under `./data` on the host so they survive
container recreation.
![Web UI](docs/images/webui.png)
Run the CLI inside the container instead of the web UI:
```sh
@@ -130,6 +119,46 @@ songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
`align`, `pitch`, `notes`, `grid`, `package`.
## YouTube download failures
`HTTP Error 403: Forbidden` from the acquire stage means YouTube served the
page but refused the media URL. In rough order of likelihood:
1. **yt-dlp is stale.** YouTube changes its defences every few weeks and the
version baked into the image does not. Hotfix a running container, then
rebuild so it sticks:
```sh
docker compose exec songtrace pip install -U 'yt-dlp[default,curl-cffi,deno]'
docker compose restart songtrace
docker compose build --pull songtrace # permanent
```
2. **Missing JS runtime.** yt-dlp needs `yt-dlp-ejs` plus a JavaScript engine
to solve YouTube's player challenges; without them it silently falls back to
formats that 403. The `[default,curl-cffi,deno]` extras in `pyproject.toml`
cover this. Check with
`docker compose exec songtrace yt-dlp -v --simulate <url>` — the debug
header must show a `JS runtimes:` line that is not `none`.
3. **The IP is rate-limited or flagged.** Datacenter and VPN ranges get
throttled hardest. Try from another network before changing config.
4. **A PO token is required.** If the log says so explicitly, run
[bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
as a sidecar, install its plugin into the image, and point
`ytdlp_pot_provider_url` at it.
`ytdlp_clients` retries the download against each listed player client in
turn, so a client that YouTube has broken is skipped automatically.
`ytdlp_cookies` (a Netscape cookie jar, for age-gated videos) and
`ytdlp_impersonate` (a browser TLS fingerprint, e.g. `"chrome"`) are the other
two levers. Do not use a cookie jar from an account you care about — YouTube
bans for automated access.
Failed downloads leave the work dir intact, so re-running the job resumes
rather than starting over.
## Regenerating the web UI screenshot
`scripts/screenshot_webui.py` seeds the real app with a fixed set of jobs
+7
View File
@@ -11,3 +11,10 @@ creator = "brad"
# device = "cuda"
bind_host = "0.0.0.0"
# port = 8000
# YouTube downloads. Each player client is tried in order until one works;
# "default" means whatever yt-dlp currently picks for itself.
# ytdlp_clients = ["default", "tv"]
# ytdlp_cookies = "/config/youtube-cookies.txt" # Netscape cookie jar
# ytdlp_impersonate = "chrome" # TLS fingerprint to present
# ytdlp_pot_provider_url = "http://bgutil-provider:4416"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+6 -1
View File
@@ -5,7 +5,12 @@ description = "Generate UltraStar/Melody Mania karaoke songs from a video and re
requires-python = ">=3.11"
license = "AGPL-3.0-or-later"
dependencies = [
"yt-dlp",
# yt-dlp declares no runtime deps of its own; the extras are what make
# YouTube work. "default" pulls yt-dlp-ejs (JS challenge solving) and the
# requests/urllib3 handlers, "deno" ships the JS runtime that executes it,
# and "curl-cffi" provides the --impersonate targets. Without them YouTube
# extraction degrades to HTTP 403 on the media URLs.
"yt-dlp[default,curl-cffi,deno]",
"demucs",
"torch",
"torchaudio",
+97 -5
View File
@@ -6,6 +6,7 @@ with a fixed set of jobs covering every status, then screenshots it.
One-time setup: pip install -e '.[dev]' && playwright install chromium
Run: python scripts/screenshot_webui.py
"""
import json
import tempfile
import threading
import time
@@ -16,12 +17,36 @@ import uvicorn
from playwright.sync_api import sync_playwright
from songtrace.config import Config
from songtrace.pipeline import PipelineError, PipelineResult
from songtrace.pipeline import PipelineError, PipelineResult, song_slug
from songtrace.web import create_app
REPO_ROOT = Path(__file__).resolve().parent.parent
OUT_IMAGE = REPO_ROOT / "docs" / "images" / "webui.png"
STAGES = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
DEFAULT_LYRICS = "[verse]\n...\n[chorus]\n...\n"
# public domain (16th century English folk song, author unknown)
GREENSLEEVES_LYRICS = """\
Alas, my love, you do me wrong
To cast me off discourteously
For I have loved you well and long
Delighting in your company
Greensleeves was all my joy
Greensleeves was my delight
Greensleeves was my heart of gold
And who but my lady Greensleeves
I have been ready at your hand
To grant whatever you would crave
I have both waged life and land
Your love and good will for to have
Greensleeves was all my joy
Greensleeves was my delight
Greensleeves was my heart of gold
And who but my lady Greensleeves
"""
# order matters: the app has one worker thread, so each job runs in submission
# order. "running" must be submitted after the done/failed ones complete and
@@ -33,12 +58,58 @@ SONGS = [
("Pink Moon", "Nick Drake", "done",
{"warnings": ["2 low-confidence notes near 0:38–0:41",
"1 low-confidence note near 1:22–1:24"]}),
("Landslide", "Fleetwood Mac", "done",
{"warnings": ["1 low-confidence note near 0:52–0:55"]}),
("Greensleeves", "Traditional", "done",
{"lyrics": GREENSLEEVES_LYRICS,
"warnings": ["1 low-confidence note near 0:45–0:47",
"1 line dropped (not sung) near \"for i have loved\"",
"missing lyrics near 2:10–2:18"],
"review": {
"lines": [
# Pitches are the real Greensleeves melody (E natural minor, as
# arranged by Gilbert DeBenedetti, "Level 4 - Flowing"), converted
# to UltraStar semitones (0 = C4 = MIDI 60): E5=16 F#5=18 G5=19
# A5=21 B4=11 D5=14.
{"index": 0, "text": "Alas, my love, you do me wrong",
"dropped": False,
"notes": [{"start": 10.0, "end": 10.4, "pitch": 16, "low_confidence": False},
{"start": 10.4, "end": 10.6, "pitch": 18, "low_confidence": False},
{"start": 10.6, "end": 11.2, "pitch": 19, "low_confidence": False},
{"start": 11.2, "end": 11.5, "pitch": 21, "low_confidence": False},
{"start": 11.5, "end": 11.7, "pitch": 19, "low_confidence": False},
{"start": 11.7, "end": 11.9, "pitch": 18, "low_confidence": False},
{"start": 11.9, "end": 12.9, "pitch": 14, "low_confidence": False}],
"words": [{"text": "Alas,", "start": 10.0, "end": 10.4},
{"text": "my", "start": 10.4, "end": 10.6},
{"text": "love,", "start": 10.6, "end": 11.2},
{"text": "you", "start": 11.2, "end": 11.5},
{"text": "do", "start": 11.5, "end": 11.7},
{"text": "me", "start": 11.7, "end": 11.9},
{"text": "wrong", "start": 11.9, "end": 12.9}]},
{"index": 1, "text": "To cast me off discourteously",
"dropped": False,
"notes": [{"start": 44.0, "end": 44.3, "pitch": 11, "low_confidence": False},
{"start": 44.3, "end": 44.6, "pitch": 16, "low_confidence": False},
{"start": 44.6, "end": 44.8, "pitch": 18, "low_confidence": False},
{"start": 44.8, "end": 45.0, "pitch": 19, "low_confidence": False},
{"start": 45.0, "end": 45.4, "pitch": 18, "low_confidence": True}],
"words": [{"text": "To", "start": 44.0, "end": 44.3},
{"text": "cast", "start": 44.3, "end": 44.6},
{"text": "me", "start": 44.6, "end": 44.8},
{"text": "off", "start": 44.8, "end": 45.0},
{"text": "discourteously", "start": 45.0, "end": 45.4}]},
{"index": 2, "text": "For I have loved you well and long",
"dropped": True, "notes": [], "words": []},
],
"missing_spans": [{"start": 130.0, "end": 138.0}],
}}),
("Such Great Heights", "The Postal Service", "failed",
{"error": "yt-dlp: unable to download video data: HTTP Error 403: Forbidden"}),
("Time Escaping", "Big Thief", "running", {}),
("Vampire in the Corner", "Magdalena Bay", "queued", {}),
]
SELECTED = "Pink Moon"
SELECTED = "Greensleeves"
running_event = threading.Event()
@@ -51,6 +122,27 @@ def make_runner():
if kind == "done":
for s in STAGES:
log(f"[{s}] running")
if "review" in extra:
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
notes = [
{**n, "duration": n["end"] - n["start"], "text": "~",
"line_index": line["index"]}
for line in extra["review"]["lines"] for n in line["notes"]
]
(work / "notes_sec.json").write_text(json.dumps({
"notes": notes, "breaks": [], "warnings": extra["warnings"]}))
(work / "align_meta.json").write_text(json.dumps({
"dropped_lines": [line["index"] for line in extra["review"]["lines"]
if line["dropped"]],
"missing_spans": [[s["start"], s["end"]]
for s in extra["review"]["missing_spans"]],
}))
(work / "alignment.json").write_text(json.dumps([
{"word": w["text"], "start": w["start"], "end": w["end"],
"score": 0.9, "line_index": line["index"]}
for line in extra["review"]["lines"] for w in line.get("words", [])
]))
out = cfg.output_dir / f"{inp.artist} - {inp.title}"
out.mkdir(parents=True, exist_ok=True)
return PipelineResult(output_dir=out, warnings=extra["warnings"])
@@ -76,11 +168,11 @@ def start_server(tmp_path: Path) -> str:
def seed_jobs(base_url: str) -> None:
lyrics = "[verse]\n...\n[chorus]\n...\n"
with httpx.Client(timeout=10) as client:
for title, artist, kind, _ in SONGS:
for title, artist, kind, extra in SONGS:
resp = client.post(f"{base_url}/api/jobs", data={
"title": title, "artist": artist, "lyrics": lyrics,
"title": title, "artist": artist,
"lyrics": extra.get("lyrics", DEFAULT_LYRICS),
"youtube_url": "https://youtu.be/00000000000"})
resp.raise_for_status()
job_id = resp.json()["id"]
+8 -2
View File
@@ -1,6 +1,6 @@
import os
import tomllib
from dataclasses import dataclass, fields
from dataclasses import dataclass, field, fields
from pathlib import Path
@@ -18,9 +18,15 @@ class Config:
device: str = "cuda"
bind_host: str = "127.0.0.1"
port: int = 8000
# YouTube player clients to try, in order, until one yields a download.
# "default" means yt-dlp's own choice; see its extractor-args docs.
ytdlp_clients: list[str] = field(default_factory=lambda: ["default", "tv"])
ytdlp_cookies: Path | None = None
ytdlp_impersonate: str | None = None
ytdlp_pot_provider_url: str | None = None
_PATH_FIELDS = {"output_dir", "work_dir"}
_PATH_FIELDS = {"output_dir", "work_dir", "ytdlp_cookies"}
def load_config(path: Path | None = None) -> Config:
+40 -9
View File
@@ -23,6 +23,7 @@ class TimedNote:
pitch: int # UltraStar semitone (0 = C4 = MIDI 60)
text: str # display text incl. leading space / "~"
low_confidence: bool
line_index: int
def hz_to_midi_float(hz: float) -> float:
@@ -78,21 +79,31 @@ def syllabify(word: str, language: str | None) -> list[str]:
return parts or [word]
def build_notes(rows: list[dict], times, f0, conf, language: str | None
def build_notes(rows: list[dict], times, f0, conf, language: str | None,
dropped_lines=(), missing_spans=(), ref_lines=None
) -> tuple[list[TimedNote], list[float], list[str]]:
notes: list[TimedNote] = []
breaks: list[float] = []
prev_line, prev_end = None, 0.0
prev_line, prev_end, last_seen_line = None, 0.0, None
for row in rows:
if prev_line is not None and row["line_index"] > prev_line:
# last_seen_line tracks every row (skipped or not) so a run of
# skipped rows can't trigger a duplicate break on the next row of
# the same still-unseen line; prev_line/prev_end only advance on
# rows that actually emit notes, so a skipped row can't corrupt
# line_initial or break timing for its own line's later words.
if last_seen_line is not None and row["line_index"] > last_seen_line:
breaks.append((prev_end + row["start"]) / 2)
last_seen_line = row["line_index"]
line_initial = row["line_index"] != prev_line
if any(a <= row["start"] < b for a, b in missing_spans):
continue
prev_line, prev_end = row["line_index"], row["end"]
segs = segment_span(times, f0, conf, row["start"], row["end"])
if not segs:
pitch = notes[-1].pitch if notes else 0
notes.append(_note(row["start"], row["end"] - row["start"], pitch,
_text(row["word"], 0, line_initial), True))
_text(row["word"], 0, line_initial), True,
row["line_index"]))
continue
low = row["score"] < LOW_SCORE
sylls = syllabify(row["word"], language)
@@ -100,14 +111,16 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None
segs = _subdivide(segs, len(sylls))
for i, (s, e, midi) in enumerate(segs):
text = _text(sylls[i], i, line_initial) if i < len(sylls) else "~"
notes.append(_note(s, e - s, round(midi) - 60, text, low))
notes.append(_note(s, e - s, round(midi) - 60, text, low,
row["line_index"]))
_deoverlap(notes)
return notes, breaks, _warnings(notes)
return notes, breaks, _warnings(notes, dropped_lines, missing_spans, ref_lines or [])
def _note(start: float, duration: float, pitch: int, text: str,
low: bool) -> TimedNote:
return TimedNote(float(start), float(duration), int(pitch), text, low)
low: bool, line_index: int) -> TimedNote:
return TimedNote(float(start), float(duration), int(pitch), text, low,
int(line_index))
def _deoverlap(notes: list[TimedNote]) -> None:
@@ -153,7 +166,8 @@ def _fmt(t: float) -> str:
return f"{int(t // 60)}:{int(t % 60):02d}"
def _warnings(notes: list[TimedNote]) -> list[str]:
def _warnings(notes: list[TimedNote], dropped_lines=(), missing_spans=(),
ref_lines=()) -> list[str]:
warnings, run = [], []
for n in list(notes) + [None]:
if n is not None and n.low_confidence:
@@ -164,4 +178,21 @@ def _warnings(notes: list[TimedNote]) -> list[str]:
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
run = []
for group in _consecutive_runs(dropped_lines):
n = len(group)
text = " ".join(ref_lines[group[0]]) if group[0] < len(ref_lines) else ""
warnings.append(
f"{n} line{'' if n == 1 else 's'} dropped (not sung) near \"{text}\"")
for a, b in missing_spans:
warnings.append(f"missing lyrics near {_fmt(a)}–{_fmt(b)}")
return warnings
def _consecutive_runs(indices) -> list[list[int]]:
runs = []
for i in sorted(indices):
if runs and i == runs[-1][-1] + 1:
runs[-1].append(i)
else:
runs.append([i])
return runs
+8 -5
View File
@@ -45,6 +45,12 @@ def song_slug(inp: SongInput) -> str:
name = f"{inp.artist} - {inp.title}"
return name.replace("/", "").replace("\0", "")
def read_warnings(work: Path) -> list[str]:
notes_file = work / "notes_sec.json"
if not notes_file.exists():
return []
return json.loads(notes_file.read_text()).get("warnings", [])
def _real_stages() -> list[Stage]:
from . import stages
return stages.all_stages()
@@ -69,8 +75,5 @@ def run_pipeline(inp: SongInput, cfg: Config, log=print,
continue
log(f"[{stage.name}] running")
stage.run(ctx)
warnings = []
notes_file = work / "notes_sec.json"
if notes_file.exists():
warnings = json.loads(notes_file.read_text()).get("warnings", [])
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp), warnings=warnings)
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp),
warnings=read_warnings(work))
+62 -7
View File
@@ -2,14 +2,18 @@ import json
import shutil
from pathlib import Path
from ..config import Config
from ..media import ffmpeg
from ..pipeline import Ctx, PipelineError, Stage
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"}
# yt-dlp's in-progress scratch files, which a failed attempt leaves behind.
PARTIAL_EXTENSIONS = {".part", ".ytdl", ".temp"}
def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
files = list(dest.glob("source_video.*"))
files = [p for p in dest.glob("source_video.*")
if p.suffix.lower() not in PARTIAL_EXTENSIONS]
thumbs = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS]
videos = [p for p in files if p.suffix.lower() not in IMAGE_EXTENSIONS]
if not videos:
@@ -18,17 +22,67 @@ def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
return video, thumbs[0] if thumbs else None
def download_youtube(url: str, dest: Path) -> tuple[Path, Path | None]:
import yt_dlp
class _YdlLog:
"""Routes yt-dlp's own messages into the job log, minus progress noise."""
def __init__(self, log):
self._log = log
def debug(self, msg: str) -> None:
if not msg.startswith("[debug] "):
self._log(msg)
def warning(self, msg: str) -> None:
self._log(msg)
error = warning
def _ydl_opts(cfg: Config, dest: Path, client: str, log) -> dict:
extractor_args: dict[str, dict[str, list[str]]] = {}
if client != "default":
extractor_args["youtube"] = {"player_client": [client]}
if cfg.ytdlp_pot_provider_url:
extractor_args["youtubepot-bgutilhttp"] = {
"base_url": [cfg.ytdlp_pot_provider_url]}
opts = {
"outtmpl": str(dest / "source_video.%(ext)s"),
"format": "bestvideo*+bestaudio/best",
"writethumbnail": True,
"noplaylist": True,
"noprogress": True,
"logger": _YdlLog(log),
"retries": 10,
"fragment_retries": 10,
"extractor_retries": 5,
"extractor_args": extractor_args,
}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.extract_info(url, download=True)
return _classify_downloads(dest)
if cfg.ytdlp_cookies:
opts["cookiefile"] = str(cfg.ytdlp_cookies)
if cfg.ytdlp_impersonate:
from yt_dlp.networking.impersonate import ImpersonateTarget
opts["impersonate"] = ImpersonateTarget.from_str(cfg.ytdlp_impersonate)
return opts
def download_youtube(url: str, dest: Path, cfg: Config,
log=print) -> tuple[Path, Path | None]:
import yt_dlp
clients = list(cfg.ytdlp_clients) or ["default"]
last: Exception | None = None
for client in clients:
try:
with yt_dlp.YoutubeDL(_ydl_opts(cfg, dest, client, log)) as ydl:
ydl.extract_info(url, download=True)
return _classify_downloads(dest)
except Exception as e:
last = e
log(f"yt-dlp player_client={client} failed: {e}")
raise PipelineError(
f"yt-dlp {yt_dlp.version.__version__} could not download {url} after "
f"trying player_client={', '.join(clients)}. Last error: {last}. "
"YouTube blocks change often: upgrade yt-dlp, then see the yt-dlp "
"options in config.toml (cookies, impersonation, PO token provider).")
def _run(ctx: Ctx) -> None:
@@ -36,7 +90,8 @@ def _run(ctx: Ctx) -> None:
raise PipelineError("provide exactly one of youtube_url / video_path")
thumb = None
if ctx.inp.youtube_url:
video, thumb = download_youtube(ctx.inp.youtube_url, ctx.work)
video, thumb = download_youtube(ctx.inp.youtube_url, ctx.work,
ctx.cfg, ctx.log)
else:
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
shutil.copyfile(ctx.inp.video_path, video)
+15 -10
View File
@@ -1,9 +1,9 @@
import json
from pathlib import Path
from ..lyrics import RefLyrics, alignment_token, normalize_lyrics
from ..lyrics import alignment_token, normalize_lyrics
from ..pipeline import Ctx, PipelineError, Stage
from .transcribe import load_transcript, lyrics_similarity, vocal_sections
from .transcribe import classify_lyrics_match, load_transcript, lyrics_similarity, vocal_sections
SIMILARITY_THRESHOLD = 0.4
@@ -42,16 +42,15 @@ def force_align_section(vocals_path: Path, start: float, end: float,
sum(t.score for t in s) / len(s)) for s in spans]
def _assign_lines_to_sections(ref: RefLyrics, sections) -> list[list[int]]:
"""Split lyric lines across vocal sections proportionally to section duration."""
def _assign_lines_to_sections(line_indices: list[int], sections) -> list[list[int]]:
"""Split matched lyric line indices across vocal sections proportionally to section duration."""
total = sum(b - a for a, b in sections) or 1.0
n_lines = len(ref.lines)
n = len(line_indices)
groups, elapsed, start = [], 0.0, 0
for i, (a, b) in enumerate(sections):
elapsed += b - a
stop = n_lines if i == len(sections) - 1 else min(
n_lines, round(n_lines * elapsed / total))
groups.append(list(range(start, max(stop, start))))
stop = n if i == len(sections) - 1 else min(n, round(n * elapsed / total))
groups.append(line_indices[start:max(stop, start)])
start = max(stop, start)
return groups
@@ -99,7 +98,7 @@ def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str
def _run(ctx: Ctx) -> None:
ref = normalize_lyrics(ctx.inp.lyrics_text)
segments = load_transcript(ctx)
segments = sorted(load_transcript(ctx), key=lambda s: s.start)
sim = lyrics_similarity(segments, ref)
if sim < SIMILARITY_THRESHOLD:
raise PipelineError(
@@ -107,15 +106,21 @@ def _run(ctx: Ctx) -> None:
"wrong lyrics or wrong recording?")
# normalised so an inverted span can never collapse a section onto one instant
sections = [(min(a, b), max(a, b)) for a, b in vocal_sections(segments)]
dropped_lines, missing_spans = classify_lyrics_match(segments, ref)
matched = [i for i in range(len(ref.lines)) if i not in set(dropped_lines)]
vocals = ctx.work / "vocals.wav"
rows = []
for sec, line_idxs in zip(sections, _assign_lines_to_sections(ref, sections)):
for sec, line_idxs in zip(sections, _assign_lines_to_sections(matched, sections)):
words = [(w, li) for li in line_idxs for w in ref.lines[li]]
if words:
rows.extend(_align_section(vocals, sec, words, ctx.inp.language,
ctx.cfg.device))
_enforce_monotonic(rows)
(ctx.work / "alignment.json").write_text(json.dumps(rows))
(ctx.work / "align_meta.json").write_text(json.dumps({
"dropped_lines": dropped_lines,
"missing_spans": [list(s) for s in missing_spans],
}))
def align_stage() -> Stage:
+9 -1
View File
@@ -3,6 +3,7 @@ from dataclasses import asdict
import numpy as np
from ..lyrics import normalize_lyrics
from ..notes import build_notes
from ..pipeline import Ctx, Stage
@@ -10,8 +11,15 @@ from ..pipeline import Ctx, Stage
def _run(ctx: Ctx) -> None:
rows = json.loads((ctx.work / "alignment.json").read_text())
data = np.load(ctx.work / "pitch.npz")
meta_path = ctx.work / "align_meta.json"
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
dropped_lines = meta.get("dropped_lines", [])
missing_spans = [tuple(s) for s in meta.get("missing_spans", [])]
ref_lines = normalize_lyrics(ctx.inp.lyrics_text).lines
notes, breaks, warnings = build_notes(
rows, data["times"], data["f0"], data["conf"], ctx.inp.language)
rows, data["times"], data["f0"], data["conf"], ctx.inp.language,
dropped_lines=dropped_lines, missing_spans=missing_spans,
ref_lines=ref_lines)
(ctx.work / "notes_sec.json").write_text(json.dumps({
"notes": [asdict(n) for n in notes],
"breaks": breaks,
+60 -3
View File
@@ -20,10 +20,67 @@ def _tokens(text: str) -> list[str]:
return [t for w in text.split() if (t := alignment_token(w))]
def match_lyrics(segments, ref: RefLyrics):
heard, seg_of = [], []
for si, s in enumerate(segments):
for t in _tokens(s.text):
heard.append(t)
seg_of.append(si)
pairs = [(t, li) for w, li in zip(ref.words, ref.word_line_index)
if (t := alignment_token(w))]
expected = [t for t, _ in pairs]
line_of = [li for _, li in pairs]
matcher = difflib.SequenceMatcher(a=heard, b=expected)
return matcher, seg_of, line_of
def lyrics_similarity(segments, ref: RefLyrics) -> float:
heard = [t for s in segments for t in _tokens(s.text)]
expected = [t for w in ref.words if (t := alignment_token(w))]
return difflib.SequenceMatcher(a=heard, b=expected).ratio()
matcher, _, _ = match_lyrics(segments, ref)
return matcher.ratio()
def classify_lyrics_match(segments, ref: RefLyrics, pad: float = 1.0
) -> tuple[list[int], list[tuple[float, float]]]:
"""Split reference lines into matched vs. dropped, and flag sung spans no
reference line explains.
dropped_lines: reference line indices covered only by 'insert' opcodes
(present in the lyrics, never heard). missing_spans: the padded, merged
bounds of the heard segments behind 'delete' opcodes (heard, but matching
no reference word) — scoped tightly to the unmatched audio itself, not
the whole vocal section it falls in, so matched lines sharing a section
with an ad-lib keep their notes.
"""
matcher, seg_of, line_of = match_lyrics(segments, ref)
covered = [False] * len(line_of)
for tag, _, _, bj1, bj2 in matcher.get_opcodes():
if tag in ("equal", "replace"):
for j in range(bj1, bj2):
covered[j] = True
by_line = {}
for j, li in enumerate(line_of):
by_line.setdefault(li, []).append(covered[j])
dropped_lines = sorted(li for li, cov in by_line.items() if not any(cov))
spans = []
for tag, ai1, ai2, _, _ in matcher.get_opcodes():
if tag != "delete":
continue
sis = [seg_of[i] for i in range(ai1, ai2)]
start = max(0.0, min(segments[si].start for si in sis) - pad)
end = max(segments[si].end for si in sis) + pad
spans.append((start, end))
return dropped_lines, _merge_spans(spans)
def _merge_spans(spans: list[tuple[float, float]]) -> list[tuple[float, float]]:
merged: list[list[float]] = []
for a, b in sorted(spans):
if merged and a <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
return [tuple(s) for s in merged]
def _run(ctx: Ctx) -> None:
+250 -70
View File
@@ -194,6 +194,9 @@ body {
}
#song-form { display: grid; gap: 18px; }
.song-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.song-form-main { display: grid; gap: 18px; align-content: start; }
.song-form-lyrics textarea { height: 100%; flex: 1 1 auto; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span {
@@ -220,7 +223,6 @@ input:not([type="file"]):hover { border-bottom-color: var(--text-muted); }
input:not([type="file"]):focus { border-bottom-color: var(--aqua); }
textarea {
min-height: 8.5rem;
padding: 11px 13px;
line-height: 1.6;
background: var(--panel);
@@ -229,6 +231,7 @@ textarea {
}
textarea:hover { border-color: var(--text-muted); }
textarea:focus { border-color: var(--aqua); }
.song-form-lyrics textarea { min-height: 8.5rem; }
input[type="file"] {
font-size: 0.82rem;
@@ -316,20 +319,28 @@ button { cursor: pointer; }
}
#review h3 span { color: var(--text-muted); font-weight: 400; }
#warnings { display: flex; flex-direction: column; gap: 1px; }
.warn-row {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 10px;
padding: 10px 12px;
#lyrics-review { display: flex; flex-direction: column; gap: 10px; }
.review-line { padding: 8px 12px; background: var(--panel); border: 1px solid var(--border); }
.review-line-text { display: block; font-size: 0.9rem; }
.review-line-dropped { opacity: 0.55; }
.review-line-dropped .review-line-text { text-decoration: line-through; }
.review-line-note { display: block; margin-top: 4px; font-size: 0.72rem; color: var(--text-muted); }
.review-line-timeline { position: relative; margin-top: 4px; }
.review-lane { position: absolute; top: 0; bottom: 0; }
.review-lane-alt { background: var(--panel-raised); }
.review-word-row { position: relative; height: 18px; }
.review-word { position: absolute; top: 0; white-space: nowrap; font-size: 0.9rem; }
.review-roll { position: relative; display: block; width: 100%; height: 20px; margin-top: 4px; }
.review-note { fill: var(--aqua); }
.review-note-low { fill: var(--warn); }
.review-missing {
margin: 0;
padding: 6px 12px;
font-size: 0.78rem;
color: var(--warn);
background: var(--panel);
border: 1px solid var(--border);
border: 1px dashed var(--border);
}
.warn-row + .warn-row { margin-top: 6px; }
.warn-icon { color: var(--warn); }
.warn-count { font-size: 0.85rem; }
.warn-range { justify-self: end; font-family: var(--font-mono); font-size: 0.82rem; color: var(--text-muted); }
.review-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--fail); }
@@ -464,6 +475,11 @@ button { cursor: pointer; }
#stage-track { margin-left: 0; flex-wrap: wrap; }
}
@media (max-width: 720px) {
.song-form-grid { grid-template-columns: 1fr; }
.song-form-lyrics textarea { height: auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@@ -486,29 +502,33 @@ button { cursor: pointer; }
<section id="new-song">
<h2>NEW SONG</h2>
<form id="song-form">
<div class="pair">
<label class="field"><span>Title</span><input name="title" required></label>
<label class="field"><span>Artist</span><input name="artist" required></label>
</div>
<label class="field"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
<details>
<summary>Advanced</summary>
<div class="stack">
<div class="song-form-grid">
<div class="song-form-main">
<div class="pair">
<label class="field"><span>Title</span><input name="title" required></label>
<label class="field"><span>Artist</span><input name="artist" required></label>
</div>
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
<label class="field"><span>Video file</span><input type="file" name="video"></label>
<p class="keep-note" data-keep="video" hidden></p>
<div class="pair">
<label class="field"><span>Genre</span><input name="genre"></label>
<label class="field"><span>Year</span><input name="year" type="number"></label>
</div>
<div class="pair">
<label class="field"><span>Language</span><input name="language"></label>
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
</div>
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
<p class="keep-note" data-keep="cover" hidden></p>
<details>
<summary>Advanced</summary>
<div class="stack">
<div class="pair">
<label class="field"><span>Genre</span><input name="genre"></label>
<label class="field"><span>Year</span><input name="year" type="number"></label>
</div>
<div class="pair">
<label class="field"><span>Language</span><input name="language"></label>
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
</div>
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
<p class="keep-note" data-keep="cover" hidden></p>
</div>
</details>
</div>
</details>
<label class="field song-form-lyrics"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
</div>
<div class="form-actions">
<button type="submit" class="primary">CREATE SONG</button>
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
@@ -522,7 +542,7 @@ button { cursor: pointer; }
<h3 id="review-title"></h3>
<p id="review-lineage" hidden></p>
<p id="review-error" class="review-error" hidden></p>
<div id="warnings"><p class="empty-note">no songs queued yet — nothing to review</p></div>
<div id="lyrics-review"><p class="empty-note">no songs queued yet — nothing to review</p></div>
<div id="output-path"></div>
<div id="review-actions"></div>
<details>
@@ -561,8 +581,6 @@ let selectedId = null;
let STAGES = [];
let pollTimer = null;
const WARN_RE = /^(\d+)\s+low-confidence notes?\s+near\s+(\d+:\d{2})–(\d+:\d{2})/;
const STATE = {
queued: { glyph: "○", cls: "st-queued" },
running: { glyph: "◈", cls: "st-running" },
@@ -758,6 +776,7 @@ async function refreshJobs() {
renderJobs(jobs);
renderNowBar(jobs);
if (selectedId) refreshDetail();
else clearDetail();
schedulePoll(jobs);
}
@@ -777,44 +796,151 @@ function emptyNote(text) {
return p;
}
function renderWarnings(job) {
const box = document.getElementById("warnings");
const SVG_NS = "http://www.w3.org/2000/svg";
function fmtTime(t) {
const s = Math.round(t);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function missingSpanNode(span) {
const p = document.createElement("p");
p.className = "review-missing";
p.textContent = `♪ unmatched singing, ${fmtTime(span.start)}–${fmtTime(span.end)}`;
return p;
}
function pianoRoll(notes, minPitch, span, t0, t1) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "review-roll");
svg.setAttribute("viewBox", "0 0 100 20");
svg.setAttribute("preserveAspectRatio", "none");
const dur = Math.max(t1 - t0, 0.001);
for (const n of notes) {
const rect = document.createElementNS(SVG_NS, "rect");
const x = ((n.start - t0) / dur) * 100;
const w = Math.max(((n.end - n.start) / dur) * 100, 0.5);
const y = 18 - ((n.pitch - minPitch) / span) * 16;
rect.setAttribute("x", x.toFixed(2));
rect.setAttribute("width", w.toFixed(2));
rect.setAttribute("y", y.toFixed(2));
rect.setAttribute("height", "2");
rect.setAttribute("class", n.low_confidence ? "review-note review-note-low" : "review-note");
svg.append(rect);
}
return svg;
}
// Lays words out along a shared time axis (like a karaoke lyric line) so
// each word's notes render directly beneath it instead of as one flat strip.
function lineTimeline(words, notes, minPitch, span) {
const t0 = words[0].start;
const t1 = words[words.length - 1].end;
const dur = Math.max(t1 - t0, 0.001);
const box = document.createElement("div");
box.className = "review-line-timeline";
words.forEach((w, i) => {
const lane = document.createElement("div");
lane.className = "review-lane" + (i % 2 ? " review-lane-alt" : "");
lane.style.left = `${((w.start - t0) / dur) * 100}%`;
lane.style.width = `${Math.max(((w.end - w.start) / dur) * 100, 0.5)}%`;
box.append(lane);
});
const wordsRow = document.createElement("div");
wordsRow.className = "review-word-row";
for (const w of words) {
const label = document.createElement("span");
label.className = "review-word";
label.style.left = `${((w.start - t0) / dur) * 100}%`;
label.textContent = w.text;
wordsRow.append(label);
}
box.append(wordsRow);
if (notes.length) box.append(pianoRoll(notes, minPitch, span, t0, t1));
return box;
}
function lineNode(line, minPitch, span) {
const row = document.createElement("div");
row.className = "review-line" + (line.dropped ? " review-line-dropped" : "");
if (line.dropped) {
const text = document.createElement("span");
text.className = "review-line-text";
text.textContent = line.text;
row.append(text);
const note = document.createElement("span");
note.className = "review-line-note";
note.textContent = "not sung — dropped";
row.append(note);
return row;
}
if (line.words.length) {
row.append(lineTimeline(line.words, line.notes, minPitch, span));
return row;
}
const text = document.createElement("span");
text.className = "review-line-text";
text.textContent = line.text;
row.append(text);
if (line.notes.length) {
const t0 = line.notes[0].start;
const t1 = Math.max(...line.notes.map(n => n.end));
row.append(pianoRoll(line.notes, minPitch, span, t0, t1));
}
return row;
}
function buildLyricsReview(data) {
const allNotes = data.lines.flatMap(l => l.notes);
const pitches = allNotes.map(n => n.pitch);
const minPitch = pitches.length ? Math.min(...pitches) : 0;
const maxPitch = pitches.length ? Math.max(...pitches) : 0;
const span = Math.max(1, maxPitch - minPitch);
const spans = [...data.missing_spans].sort((a, b) => a.start - b.start);
const nodes = [];
let spanIdx = 0;
for (const line of data.lines) {
const lineStart = line.notes.length ? Math.min(...line.notes.map(n => n.start)) : null;
while (spanIdx < spans.length && lineStart !== null && spans[spanIdx].start < lineStart) {
nodes.push(missingSpanNode(spans[spanIdx]));
spanIdx++;
}
nodes.push(lineNode(line, minPitch, span));
}
while (spanIdx < spans.length) {
nodes.push(missingSpanNode(spans[spanIdx]));
spanIdx++;
}
return nodes;
}
async function renderLyricsReview(job) {
const box = document.getElementById("lyrics-review");
if (job.status === "failed") {
delete box.dataset.jobId;
box.replaceChildren(emptyNote("job failed — nothing to review"));
return;
}
if (job.status !== "done") {
delete box.dataset.jobId;
box.replaceChildren(emptyNote("waiting for this song to finish…"));
return;
}
if (!job.warnings.length) {
box.replaceChildren(emptyNote("no low-confidence regions — nothing to review"));
if (box.dataset.jobId === job.id) return;
const resp = await fetch(`/api/jobs/${job.id}/review`);
if (job.id !== selectedId) return;
if (!resp.ok) {
box.replaceChildren(emptyNote("no review data available"));
return;
}
const rows = job.warnings.map(w => {
const m = WARN_RE.exec(w);
const row = document.createElement("div");
row.className = "warn-row";
if (m) {
row.setAttribute("data-count", m[1]);
row.setAttribute("data-start", m[2]);
row.setAttribute("data-end", m[3]);
const icon = document.createElement("span");
icon.className = "warn-icon";
icon.textContent = "⚠";
const count = document.createElement("span");
count.className = "warn-count";
count.textContent = `${m[1]} note${m[1] === "1" ? "" : "s"}`;
const range = document.createElement("span");
range.className = "warn-range";
range.textContent = `${m[2]}–${m[3]}`;
row.append(icon, count, range);
} else {
row.textContent = w;
}
return row;
});
box.replaceChildren(...rows);
const data = await resp.json();
if (job.id !== selectedId) return;
box.dataset.jobId = job.id;
if (!data.lines.length) {
box.replaceChildren(emptyNote("no lyrics to review"));
return;
}
box.replaceChildren(...buildLyricsReview(data));
}
async function copyPath(path, button) {
@@ -874,6 +1000,21 @@ function renderOutputPath(job) {
outputPathDiv.replaceChildren(wrap);
}
function clearDetail() {
reviewTitle.replaceChildren();
reviewError.hidden = true;
reviewError.textContent = "";
document.getElementById("warnings").replaceChildren(
emptyNote("no songs queued yet — nothing to review"));
outputPathDiv.replaceChildren();
reviewActions.replaceChildren();
delete reviewActions.dataset.jobId;
reviewLineage.replaceChildren();
reviewLineage.hidden = true;
delete reviewLineage.dataset.jobId;
detail.textContent = "no songs queued yet — nothing to log";
}
function setFormMode(inp) {
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
@@ -920,21 +1061,60 @@ async function rerunJob(id) {
refreshJobs();
}
async function rebuildFullJob(id) {
formError.textContent = "";
const resp = await fetch(`/api/jobs/${id}/rebuild-full`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return;
}
selectedId = (await resp.json()).id;
refreshJobs();
}
async function deleteJob(id) {
if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;
formError.textContent = "";
const resp = await fetch(`/api/jobs/${id}`, { method: "DELETE" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return;
}
if (selectedId === id) selectedId = null;
refreshJobs();
}
const ACTION_LABELS = {
rerun: "RERUN",
edit: "EDIT & REBUILD",
"rebuild-full": "REBUILD FROM SCRATCH",
delete: "DELETE",
};
const ACTION_HANDLERS = {
rerun: rerunJob,
edit: startRebuild,
"rebuild-full": rebuildFullJob,
delete: deleteJob,
};
function actionButton(action, jobId) {
const button = document.createElement("button");
button.type = "button";
button.className = "action";
button.dataset.action = action;
button.textContent = action === "rerun" ? "RERUN" : "EDIT & REBUILD";
button.addEventListener("click",
() => action === "rerun" ? rerunJob(jobId) : startRebuild(jobId));
button.textContent = ACTION_LABELS[action];
button.addEventListener("click", () => ACTION_HANDLERS[action](jobId));
return button;
}
function renderActions(job) {
// no controls mid-run: both endpoints answer 409 until the job settles
const wanted = job.status === "failed" ? ["rerun", "edit"]
: job.status === "done" ? ["edit"] : [];
// no controls mid-run: every endpoint answers 409 until the job settles
const doneActions = ["edit", "rebuild-full", "delete"];
const wanted = job.status === "failed" ? ["rerun", ...doneActions]
: job.status === "done" ? doneActions : [];
const shown = [...reviewActions.children].map(b => b.dataset.action);
// rebuilding an unchanged row would detach a focused action on every poll
if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;
@@ -969,7 +1149,7 @@ async function refreshDetail() {
reviewTitle.replaceChildren(`${job.title} `, artist);
reviewError.hidden = !job.error;
reviewError.textContent = job.error ?? "";
renderWarnings(job);
renderLyricsReview(job);
renderOutputPath(job);
renderLineage(job);
renderActions(job);
+111 -6
View File
@@ -1,3 +1,4 @@
import json
import math
import queue
import re
@@ -5,7 +6,7 @@ import shutil
import threading
import time
import uuid
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -15,8 +16,9 @@ from fastapi.staticfiles import StaticFiles
from . import rebuild as rebuild_policy
from .config import Config
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
song_slug)
from .lyrics import normalize_lyrics
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, read_warnings,
run_pipeline, song_slug)
STATIC = Path(__file__).parent / "static"
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
@@ -35,6 +37,7 @@ class Job:
error: str | None = None
inp: SongInput | None = None
rebuilt_from: str | None = None
force_stage: str | None = None
created_at: float = field(default_factory=time.time)
started_at: float | None = None
finished_at: float | None = None
@@ -91,6 +94,35 @@ def _cleanup_uploads(cfg: Config, job_id: str, video_path: Path | None) -> None:
pass
def _discover_jobs(cfg: Config) -> list[Job]:
discovered = []
if not cfg.work_dir.exists():
return discovered
for sidecar in cfg.work_dir.glob("*/input.json"):
try:
data = json.loads(sidecar.read_text())
for key in ("video_path", "cover_path"):
if data.get(key) is not None:
data[key] = Path(data[key])
inp = SongInput(**data)
slug = song_slug(inp)
out = cfg.output_dir / slug
done = out.exists() and any(out.iterdir())
mtime = sidecar.stat().st_mtime
warnings = read_warnings(sidecar.parent) if done else []
except (OSError, ValueError, TypeError):
continue
discovered.append(Job(
id=uuid.uuid4().hex, title=inp.title, artist=inp.artist, inp=inp,
status="done" if done else "failed",
output_dir=str(out) if done else None,
warnings=warnings,
error=None if done else "interrupted before completion",
created_at=mtime, finished_at=mtime))
discovered.sort(key=lambda j: j.created_at)
return discovered
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=STATIC), name="static")
@@ -99,8 +131,15 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
lock = threading.Lock()
todo: queue.Queue[str] = queue.Queue()
for job in _discover_jobs(cfg):
jobs[job.id] = job
order.append(job.id)
def _prepare_rebuild(job: Job, log) -> str | None:
"""Relocate cached work and report the stage this rebuild restarts from."""
if job.force_stage:
log(f"[rebuild] restarting from {job.force_stage} (forced rebuild from scratch)")
return job.force_stage
if not job.rebuilt_from:
return None
with lock:
@@ -128,6 +167,15 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
shutil.rmtree(old, ignore_errors=True)
log(f"[rebuild] removed superseded {old.name}")
def _write_sidecar(job: Job) -> None:
work = cfg.work_dir / song_slug(job.inp)
work.mkdir(parents=True, exist_ok=True)
data = asdict(job.inp)
for key in ("video_path", "cover_path"):
if data[key] is not None:
data[key] = str(data[key])
(work / "input.json").write_text(json.dumps(data))
def worker():
while True:
job_id = todo.get()
@@ -144,6 +192,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
try:
force_from = _prepare_rebuild(job, log)
_write_sidecar(job)
result = runner(job.inp, cfg, log=log, force_from=force_from)
with lock:
job.output_dir = str(result.output_dir)
@@ -164,10 +213,10 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
threading.Thread(target=worker, daemon=True).start()
def _enqueue(job_id: str, inp: SongInput,
rebuilt_from: str | None = None) -> dict:
def _enqueue(job_id: str, inp: SongInput, rebuilt_from: str | None = None,
force_stage: str | None = None) -> dict:
job = Job(id=job_id, title=inp.title, artist=inp.artist, inp=inp,
rebuilt_from=rebuilt_from)
rebuilt_from=rebuilt_from, force_stage=force_stage)
with lock:
jobs[job_id] = job
order.append(job_id)
@@ -216,6 +265,12 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
parent = _finished_job(job_id)
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id)
@app.post("/api/jobs/{job_id}/rebuild-full", status_code=201)
def rebuild_full(job_id: str):
parent = _finished_job(job_id)
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id,
force_stage=STAGE_ORDER[0])
def _require_source(parent_inp: SongInput, inp: SongInput,
force_from: str | None) -> None:
"""Refuse a rebuild that will rerun acquire against an upload we no longer have."""
@@ -303,6 +358,56 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
"video_filename": inp.video_path.name if inp.video_path else None,
"cover_filename": inp.cover_path.name if inp.cover_path else None}
@app.delete("/api/jobs/{job_id}", status_code=204)
def delete_job(job_id: str):
with lock:
job = jobs.get(job_id)
if job is None:
raise HTTPException(404)
if job.status in ("queued", "running"):
raise HTTPException(409, "job is still in progress")
slug = song_slug(job.inp)
if any(other.status in ("queued", "running") and song_slug(other.inp) == slug
for other_id, other in jobs.items() if other_id != job_id):
raise HTTPException(409, "another job for this song is in progress")
jobs.pop(job_id, None)
if job_id in order:
order.remove(job_id)
shutil.rmtree(cfg.work_dir / slug, ignore_errors=True)
shutil.rmtree(cfg.output_dir / slug, ignore_errors=True)
@app.get("/api/jobs/{job_id}/review")
def get_review(job_id: str):
job = _finished_job(job_id)
if job.status != "done":
raise HTTPException(404)
work = cfg.work_dir / song_slug(job.inp)
notes_file = work / "notes_sec.json"
if not notes_file.exists():
raise HTTPException(404)
notes_data = json.loads(notes_file.read_text())
meta_file = work / "align_meta.json"
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
dropped = set(meta.get("dropped_lines", []))
missing_spans = [{"start": s, "end": e}
for s, e in meta.get("missing_spans", [])]
align_file = work / "alignment.json"
align_rows = json.loads(align_file.read_text()) if align_file.exists() else []
words_by_line: dict[int, list] = {}
for r in align_rows:
words_by_line.setdefault(r["line_index"], []).append(
{"text": r["word"], "start": r["start"], "end": r["end"]})
ref = normalize_lyrics(job.inp.lyrics_text)
by_line: dict[int, list] = {}
for n in notes_data["notes"]:
by_line.setdefault(n["line_index"], []).append({
"start": n["start"], "end": n["start"] + n["duration"],
"pitch": n["pitch"], "low_confidence": n["low_confidence"]})
lines = [{"index": i, "text": " ".join(words), "dropped": i in dropped,
"notes": by_line.get(i, []), "words": words_by_line.get(i, [])}
for i, words in enumerate(ref.lines)]
return {"lines": lines, "missing_spans": missing_spans}
return app
+93 -1
View File
@@ -24,7 +24,7 @@ def test_local_video(tmp_path, monkeypatch):
assert any("audio.wav" in str(a[-1]) for a in calls)
def test_youtube(tmp_path, monkeypatch):
def fake_dl(url, dest):
def fake_dl(url, dest, cfg, log):
video = dest / "source_video.webm"
video.write_bytes(b"v")
thumb = dest / "source_video.jpg"
@@ -83,3 +83,95 @@ def test_classify_picks_largest_video_when_multiple(tmp_path):
(tmp_path / "source_video.mp4").write_bytes(b"much larger contents here")
video, _ = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.mp4"
def test_classify_ignores_partials_left_by_a_failed_attempt(tmp_path):
(tmp_path / "source_video.webm").write_bytes(b"v")
(tmp_path / "source_video.f137.mp4.part").write_bytes(b"a much bigger partial")
(tmp_path / "source_video.f251.webm.ytdl").write_bytes(b"resume state")
video, _ = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.webm"
def test_ydl_opts_default_client_adds_no_player_client(tmp_path):
cfg = Config(whisper_url="http://x/v1")
opts = acquire._ydl_opts(cfg, tmp_path, "default", print)
assert "youtube" not in opts["extractor_args"]
assert opts["retries"] and opts["fragment_retries"] and opts["extractor_retries"]
def test_ydl_opts_named_client_and_pot_provider(tmp_path):
cfg = Config(whisper_url="http://x/v1",
ytdlp_pot_provider_url="http://pot:4416",
ytdlp_cookies=tmp_path / "cookies.txt")
opts = acquire._ydl_opts(cfg, tmp_path, "tv", print)
assert opts["extractor_args"]["youtube"] == {"player_client": ["tv"]}
assert opts["extractor_args"]["youtubepot-bgutilhttp"] == {
"base_url": ["http://pot:4416"]}
assert opts["cookiefile"] == str(tmp_path / "cookies.txt")
def _fake_yt_dlp(monkeypatch, behaviour):
"""Install a stub yt_dlp whose download calls `behaviour(opts)`."""
import sys
import types
seen = []
class FakeYoutubeDL:
def __init__(self, opts):
self.opts = opts
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def extract_info(self, url, download):
seen.append(self.opts["extractor_args"].get("youtube"))
behaviour(self.opts)
mod = types.ModuleType("yt_dlp")
mod.YoutubeDL = FakeYoutubeDL
mod.version = types.SimpleNamespace(__version__="2026.08.19")
monkeypatch.setitem(sys.modules, "yt_dlp", mod)
return seen
def test_download_falls_through_to_the_next_client(tmp_path, monkeypatch):
def behaviour(opts):
client = opts["extractor_args"].get("youtube")
if client is None:
raise RuntimeError("HTTP Error 403: Forbidden")
(tmp_path / "source_video.webm").write_bytes(b"v")
seen = _fake_yt_dlp(monkeypatch, behaviour)
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
logged = []
video, thumb = acquire.download_youtube("https://yt/x", tmp_path, cfg,
logged.append)
assert video.name == "source_video.webm"
assert thumb is None
assert seen == [None, {"player_client": ["tv"]}]
assert any("403" in m for m in logged)
def test_download_reports_version_and_clients_when_all_fail(tmp_path, monkeypatch):
import pytest
from songtrace.pipeline import PipelineError
def behaviour(opts):
raise RuntimeError("HTTP Error 403: Forbidden")
_fake_yt_dlp(monkeypatch, behaviour)
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=["default", "tv"])
with pytest.raises(PipelineError) as e:
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert "2026.08.19" in str(e.value)
assert "default, tv" in str(e.value)
assert "403" in str(e.value)
def test_download_with_no_configured_clients_still_tries_default(tmp_path, monkeypatch):
seen = _fake_yt_dlp(
monkeypatch, lambda opts: (tmp_path / "source_video.mp4").write_bytes(b"v"))
cfg = Config(whisper_url="http://x/v1", ytdlp_clients=[])
acquire.download_youtube("https://yt/x", tmp_path, cfg, lambda m: None)
assert seen == [None]
def test_ydl_log_drops_debug_lines():
logged = []
log = acquire._YdlLog(logged.append)
log.debug("[debug] internal detail")
log.debug("[youtube] Extracting URL")
log.warning("nsig extraction failed")
log.error("HTTP Error 403: Forbidden")
assert logged == ["[youtube] Extracting URL", "nsig extraction failed",
"HTTP Error 403: Forbidden"]
+23
View File
@@ -137,3 +137,26 @@ def test_words_split_across_sections(tmp_path, monkeypatch):
check_invariants(rows, LYRICS)
assert rows[3]["end"] <= 5.0
assert rows[4]["start"] >= 29.0
def test_align_drops_lines_never_sung(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven eight\nnine ten eleven twelve"
transcript = [{"start": 1.0, "end": 4.0,
"text": "it's one two three four five six seven eight"}]
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
assert {r["line_index"] for r in rows} == {0, 1}
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == [2]
assert meta["missing_spans"] == []
def test_align_flags_missing_span_for_unmatched_singing(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven eight"
transcript = [
{"start": 1.0, "end": 4.0, "text": "it's one two three four five six seven eight"},
{"start": 20.0, "end": 23.0, "text": "completely unrelated ad lib bridge"},
]
run_align(tmp_path, monkeypatch, lyrics, transcript)
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == []
assert meta["missing_spans"] == [[19.0, 24.0]]
+52
View File
@@ -295,3 +295,55 @@ def test_notes_stage_writes_json(tmp_path):
def test_notes_stage_registered():
assert "notes" in [s.name for s in all_stages()]
def test_build_notes_skips_notes_within_missing_span():
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "skip", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(4.5, 6.0)])
assert [n.text for n in notes] == ["keep"]
def test_warnings_report_dropped_lines():
times, f0, conf = flat_pitch()
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
ref_lines = [["keep"], ["the", "outro"]]
_, _, warnings = build_notes(rows, times, f0, conf, "English",
dropped_lines=[1], ref_lines=ref_lines)
assert warnings == ['1 line dropped (not sung) near "the outro"']
def test_warnings_report_missing_spans():
times, f0, conf = flat_pitch()
rows = [{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
_, _, warnings = build_notes(rows, times, f0, conf, "English",
missing_spans=[(81.0, 89.5)])
assert warnings == ["missing lyrics near 1:21–1:29"]
def test_build_notes_skipped_row_mid_line_does_not_corrupt_spacing_or_breaks():
# "gone" falls inside a missing span and is skipped; "away" is the same
# line's next word and must still get its leading space, and the break
# before the next line must use the last *emitted* word's end (3.4, from
# "away"), not the skipped row's end (2.4).
times, f0, conf = flat_pitch()
rows = [
{"word": "keep", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "gone", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 0},
{"word": "away", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 0},
{"word": "next", "start": 5.0, "end": 5.4, "score": 0.9, "line_index": 1},
]
notes, breaks, _ = build_notes(rows, times, f0, conf, "English",
missing_spans=[(1.9, 2.5)])
assert [n.text for n in notes] == ["keep", " away", "next"]
assert breaks == [(3.4 + 5.0) / 2]
def test_notes_carry_line_index():
times, f0, conf = flat_pitch()
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert notes[0].line_index == 3
+8 -1
View File
@@ -1,7 +1,7 @@
import json
from pathlib import Path
from songtrace.config import Config
from songtrace.pipeline import SongInput, Stage, run_pipeline, song_slug
from songtrace.pipeline import SongInput, Stage, read_warnings, run_pipeline, song_slug
def make_cfg(tmp_path):
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
@@ -46,3 +46,10 @@ def test_warnings_surfaced(tmp_path):
st = Stage(name="n", outputs=lambda ctx: [ctx.work / "n.done"], run=run)
result = run_pipeline(inp, cfg, log=lambda m: None, stages=[st])
assert result.warnings == ["w1"]
def test_read_warnings_is_empty_without_a_notes_file(tmp_path):
assert read_warnings(tmp_path) == []
def test_read_warnings_reads_the_notes_file(tmp_path):
(tmp_path / "notes_sec.json").write_text(json.dumps({"warnings": ["w1", "w2"]}))
assert read_warnings(tmp_path) == ["w1", "w2"]
+39 -1
View File
@@ -1,6 +1,6 @@
from songtrace.lyrics import normalize_lyrics
from songtrace.whisper_client import TranscriptSegment
from songtrace.stages.transcribe import lyrics_similarity, vocal_sections
from songtrace.stages.transcribe import classify_lyrics_match, lyrics_similarity, vocal_sections
def seg(s, e, t=""):
return TranscriptSegment(start=s, end=e, text=t)
@@ -26,3 +26,41 @@ def test_similarity_low_for_wrong_lyrics():
ref = normalize_lyrics("completely different words here entirely")
segs = [seg(0, 2, "it's one two three four five six seven eight")]
assert lyrics_similarity(segs, ref) < 0.4
def test_classify_lyrics_match_flags_extra_reference_line():
ref = normalize_lyrics("it's one two three\nfour five six seven eight\nnine ten eleven")
segs = [seg(1, 4, "it's one two three four five six seven eight")]
dropped, missing = classify_lyrics_match(segs, ref)
assert dropped == [2]
assert missing == []
def test_classify_lyrics_match_flags_unmatched_singing():
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
segs = [seg(1, 4, "it's one two three four five six seven eight"),
seg(20, 23, "completely unrelated ad lib bridge")]
dropped, missing = classify_lyrics_match(segs, ref)
assert dropped == []
assert missing == [(19.0, 24.0)]
def test_classify_lyrics_match_keeps_partially_matched_lines():
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
# line 1 heard with one word garbled ("five" -> "fife"): still a replace/equal
# match, so the line stays matched rather than dropped
segs = [seg(1, 4, "it's one two three four fife six seven eight")]
dropped, _ = classify_lyrics_match(segs, ref)
assert dropped == []
def test_classify_lyrics_match_scopes_missing_span_to_adlib_not_whole_section():
# matched line and an unmatched ad-lib close enough together that
# vocal_sections would merge them into one section; missing_spans must
# stay tight around the ad-lib so the matched line keeps its notes.
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
segs = [seg(1, 4, "it's one two three four five six seven eight"),
seg(6, 8, "completely unrelated ad lib")]
sections = vocal_sections(segs)
assert sections == [(0.0, 9.0)] # confirms the two segments do merge
dropped, missing = classify_lyrics_match(segs, ref)
assert dropped == []
assert missing == [(5.0, 9.0)]
for a, b in missing:
assert not (a <= 1.0 < b) # matched line's start (1.0) untouched
+406 -6
View File
@@ -1,5 +1,7 @@
import io
import json
import re
import shutil
import threading
import time
@@ -359,6 +361,32 @@ def test_index_has_required_hooks():
assert f'name="{name}"' in html, name
def test_video_source_fields_are_outside_advanced_details():
html = _index_html()
form_start = html.index('id="song-form"')
details_start = html.index("<details>", form_start)
before = html[form_start:details_start]
assert 'name="youtube_url"' in before
assert 'name="video"' in before
after = html[details_start:]
for name in ("genre", "year", "language", "bpm", "cover"):
assert f'name="{name}"' in after
def test_song_form_lyrics_uses_a_two_column_grid():
css = _page_css(_index_html())
assert re.search(r"\.song-form-grid\s*\{[^}]*grid-template-columns:\s*1fr 1fr",
css, re.S)
def test_song_form_grid_stacks_on_narrow_screens():
css = _page_css(_index_html())
assert re.search(
r"@media \(max-width: 720px\)\s*\{[^}]*\.song-form-grid\s*\{[^}]*"
r"grid-template-columns:\s*1fr\s*;",
css, re.S)
def test_index_makes_no_external_requests():
import re as _re
html = _index_html()
@@ -383,10 +411,53 @@ def test_index_respects_reduced_motion():
assert "prefers-reduced-motion" in _index_html()
def test_index_parses_warnings_with_endash():
def test_index_has_lyrics_review_hook():
html = _index_html()
assert "–" in html, "warning parser must handle the en-dash separator"
assert "data-start" in html and "data-end" in html
assert 'id="lyrics-review"' in html
assert 'id="warnings"' not in html
def test_index_review_uses_svg_not_canvas():
html = _index_html()
assert "createElementNS" in html
assert "<canvas" not in html
def test_index_review_fetches_review_endpoint():
html = _index_html()
assert "/review" in html
def test_index_lyrics_review_uses_endash_for_time_ranges():
html = _index_html()
body = _fn_body(html, "missingSpanNode")
assert "–" in body
def test_index_review_lays_out_words_in_time():
"""Words must be positioned by their sung timing (percentage-based), not
packed as plain flow text, so notes can line up under their own word."""
html = _index_html()
body = _fn_body(html, "lineTimeline")
assert "review-word" in body
assert "style.left" in body
def test_lyrics_review_cache_clears_on_job_status_change():
"""Regression: switching between done/failed/running jobs must clear the
cache so a previously-viewed done job re-fetches when re-selected."""
html = _index_html()
body = _fn_body(html, "renderLyricsReview")
# The "failed" branch must clear the cache before rendering the placeholder
assert re.search(
r'if\s*\(\s*job\.status\s*===\s*"failed"\s*\)\s*\{[^}]*'
r'delete\s+box\.dataset\.jobId',
body, re.S), "failed branch must delete cache key"
# The "not done" (queued/running) branch must clear the cache before rendering
assert re.search(
r'if\s*\(\s*job\.status\s*!==\s*"done"\s*\)\s*\{[^}]*'
r'delete\s+box\.dataset\.jobId',
body, re.S), "not done branch must delete cache key"
def test_index_has_copy_path_control():
@@ -531,6 +602,44 @@ def _rebuild_flaws(html):
return flaws
def _rebuild_full_and_delete_flaws(html):
flaws = []
actions = _fn_body(html, "renderActions")
for action in ('"rebuild-full"', '"delete"'):
if action not in actions:
flaws.append(f"{action} is not offered from renderActions")
rf = _fn_body(html, "rebuildFullJob")
if "/rebuild-full" not in rf or 'method: "POST"' not in rf:
flaws.append("rebuildFullJob does not POST to /rebuild-full")
d = _fn_body(html, "deleteJob")
confirm_idx = d.find("confirm(")
fetch_idx = d.find("fetch(")
if confirm_idx == -1:
flaws.append("deleteJob does not confirm before deleting")
elif fetch_idx != -1 and confirm_idx > fetch_idx:
flaws.append("deleteJob fetches before confirming")
if 'method: "DELETE"' not in d:
flaws.append("deleteJob does not send a DELETE request")
return flaws
def test_rebuild_full_and_delete_controls_are_wired_up():
assert _rebuild_full_and_delete_flaws(_index_html()) == []
def _empty_state_flaws(html):
refresh = _fn_body(html, "refreshJobs")
if "clearDetail()" not in refresh:
return ["refreshJobs never resets the panel when no job is selected"]
return []
def test_review_panel_resets_when_no_job_is_selected():
assert _empty_state_flaws(_index_html()) == []
# each mutation is a plausible regression; the checker above must reject all of them
MUTATIONS = [
(_focus_flaws, "queue: card moved unconditionally",
@@ -559,6 +668,14 @@ MUTATIONS = [
(_rebuild_flaws, "rebuild: action row rebuilt on every poll",
"if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;",
""),
(_rebuild_full_and_delete_flaws, "delete: confirmation dialog removed",
'if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;',
""),
(_rebuild_full_and_delete_flaws, "rebuild-full: no longer offered on a done job",
'["edit", "rebuild-full", "delete"]', '["edit", "delete"]'),
(_empty_state_flaws, "empty state: clearDetail no longer wired into refreshJobs",
"if (selectedId) refreshDetail();\n else clearDetail();",
"if (selectedId) refreshDetail();"),
]
@@ -776,7 +893,7 @@ def test_rebuild_renames_the_work_dir_on_a_retitle(tmp_path):
seen = []
client, cfg = make_client(tmp_path, capture_runner_factory(seen))
parent = make_done_job(client)
(cfg.work_dir / "A - T").mkdir(parents=True)
(cfg.work_dir / "A - T").mkdir(parents=True, exist_ok=True)
(cfg.work_dir / "A - T" / "audio.wav").write_bytes(b"x")
child = client.post(f"/api/jobs/{parent}/rebuild",
data=rebuild_form(title="T2")).json()["id"]
@@ -789,7 +906,7 @@ def test_rebuild_renames_the_work_dir_on_a_retitle(tmp_path):
def test_rebuild_fails_the_job_on_a_work_dir_collision(tmp_path):
client, cfg = make_client(tmp_path, ok_runner)
parent = make_done_job(client)
(cfg.work_dir / "A - T").mkdir(parents=True)
(cfg.work_dir / "A - T").mkdir(parents=True, exist_ok=True)
(cfg.work_dir / "A - T2").mkdir(parents=True)
child = client.post(f"/api/jobs/{parent}/rebuild",
data=rebuild_form(title="T2")).json()["id"]
@@ -817,7 +934,7 @@ def test_rebuild_inherits_the_parents_source(tmp_path):
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
parent = resp.json()["id"]
wait_status(client, parent, "done")
(cfg.work_dir / "A - T").mkdir(parents=True)
(cfg.work_dir / "A - T").mkdir(parents=True, exist_ok=True)
for name in ("meta.json", "audio.wav"):
(cfg.work_dir / "A - T" / name).write_bytes(b"x")
child = client.post(f"/api/jobs/{parent}/rebuild",
@@ -903,6 +1020,59 @@ def test_rebuild_of_an_unknown_job_is_404(tmp_path):
assert client.post("/api/jobs/nope/rebuild", data=rebuild_form()).status_code == 404
def test_rebuild_full_forces_every_stage_from_the_first(tmp_path):
seen = []
client, _ = make_client(tmp_path, capture_runner_factory(seen))
parent = make_done_job(client)
child = client.post(f"/api/jobs/{parent}/rebuild-full").json()["id"]
wait_status(client, child, "done")
assert seen[1]["force_from"] == STAGE_ORDER[0]
assert seen[1]["inp"] == seen[0]["inp"]
def test_rebuild_full_creates_a_linked_job(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
parent = make_done_job(client)
resp = client.post(f"/api/jobs/{parent}/rebuild-full")
assert resp.status_code == 201
child = resp.json()["id"]
assert child != parent
job = wait_status(client, child, "done")
assert job["rebuilt_from"] == parent
def test_rebuild_full_logs_that_it_is_a_forced_rebuild(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
parent = make_done_job(client)
child = client.post(f"/api/jobs/{parent}/rebuild-full").json()["id"]
job = wait_status(client, child, "done")
rebuild_lines = [l for l in job["log"] if l.startswith("[rebuild]")]
assert len(rebuild_lines) == 1
assert STAGE_ORDER[0] in rebuild_lines[0]
def test_rebuild_full_of_an_unknown_job_is_404(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.post("/api/jobs/nope/rebuild-full").status_code == 404
def test_rebuild_full_of_a_running_job_is_409(tmp_path):
release = threading.Event()
def blocking(inp, cfg, log=print, force_from=None, stages=None):
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, blocking)
parent = client.post("/api/jobs", data=rebuild_form()).json()["id"]
try:
wait_status(client, parent, "running")
assert client.post(f"/api/jobs/{parent}/rebuild-full").status_code == 409
finally:
release.set()
wait_status(client, parent, "done")
def slug_runner(inp, cfg, log=print, force_from=None, stages=None):
out = cfg.output_dir / song_slug(inp)
out.mkdir(parents=True, exist_ok=True)
@@ -951,6 +1121,76 @@ def test_rebuild_of_a_failed_parent_removes_nothing(tmp_path):
assert (cfg.output_dir / "A - T" / "keep.txt").exists()
def test_delete_removes_work_and_output_and_the_job(tmp_path):
client, cfg = make_client(tmp_path, slug_runner)
job_id = make_done_job(client)
(cfg.work_dir / "A - T").mkdir(parents=True, exist_ok=True)
(cfg.work_dir / "A - T" / "audio.wav").write_bytes(b"x")
assert (cfg.output_dir / "A - T").exists()
resp = client.delete(f"/api/jobs/{job_id}")
assert resp.status_code == 204
assert not (cfg.work_dir / "A - T").exists()
assert not (cfg.output_dir / "A - T").exists()
assert client.get(f"/api/jobs/{job_id}").status_code == 404
assert job_id not in [j["id"] for j in client.get("/api/jobs").json()]
def test_delete_tolerates_a_work_dir_that_is_already_gone(tmp_path):
client, cfg = make_client(tmp_path, slug_runner)
job_id = make_done_job(client)
shutil.rmtree(cfg.work_dir / "A - T")
resp = client.delete(f"/api/jobs/{job_id}")
assert resp.status_code == 204
def test_delete_of_an_unknown_job_is_404(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.delete("/api/jobs/nope").status_code == 404
def test_delete_of_a_running_job_is_409(tmp_path):
release = threading.Event()
def blocking(inp, cfg, log=print, force_from=None, stages=None):
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, blocking)
parent = client.post("/api/jobs", data=rebuild_form()).json()["id"]
try:
wait_status(client, parent, "running")
assert client.delete(f"/api/jobs/{parent}").status_code == 409
finally:
release.set()
wait_status(client, parent, "done")
assert client.get(f"/api/jobs/{parent}").status_code == 200
def test_delete_of_a_job_with_an_active_rebuild_is_409(tmp_path):
"""A done job's slug must not be deleted out from under a rebuild in flight."""
release = threading.Event()
calls = []
def runner(inp, cfg, log=print, force_from=None, stages=None):
calls.append(1)
if len(calls) > 1:
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, runner)
parent = make_done_job(client)
child = client.post(f"/api/jobs/{parent}/rebuild-full").json()["id"]
try:
wait_status(client, child, "running")
assert client.delete(f"/api/jobs/{parent}").status_code == 409
finally:
release.set()
wait_status(client, child, "done")
assert client.get(f"/api/jobs/{parent}").status_code == 200
def test_a_plain_job_never_removes_anything(tmp_path):
client, cfg = make_client(tmp_path, slug_runner)
make_done_job(client)
@@ -958,3 +1198,163 @@ def test_a_plain_job_never_removes_anything(tmp_path):
stray.mkdir(parents=True)
make_done_job(client, title="T2")
assert stray.exists()
def test_discover_jobs_ignores_a_work_dir_without_a_sidecar(tmp_path):
from songtrace.web import _discover_jobs
cfg = Config(whisper_url="u", work_dir=tmp_path / "work", output_dir=tmp_path / "out")
(cfg.work_dir / "A - T").mkdir(parents=True)
assert _discover_jobs(cfg) == []
def test_discover_jobs_reconstructs_a_completed_song(tmp_path):
from songtrace.web import _discover_jobs
cfg = Config(whisper_url="u", work_dir=tmp_path / "work", output_dir=tmp_path / "out")
client = TestClient(create_app(cfg, runner=slug_runner))
make_done_job(client)
discovered = _discover_jobs(cfg)
assert len(discovered) == 1
job = discovered[0]
assert job.title == "T" and job.artist == "A" and job.status == "done"
assert job.output_dir == str(cfg.output_dir / "A - T")
assert job.error is None
def test_discover_jobs_reconstructs_a_song_whose_output_is_missing_as_failed(tmp_path):
from songtrace.web import _discover_jobs
cfg = Config(whisper_url="u", work_dir=tmp_path / "work", output_dir=tmp_path / "out")
client = TestClient(create_app(cfg, runner=slug_runner))
make_done_job(client)
shutil.rmtree(cfg.output_dir / "A - T")
discovered = _discover_jobs(cfg)
assert len(discovered) == 1
assert discovered[0].status == "failed"
assert discovered[0].error == "interrupted before completion"
assert discovered[0].output_dir is None
def test_discovered_jobs_are_visible_from_a_fresh_app(tmp_path):
cfg = Config(whisper_url="u", work_dir=tmp_path / "work", output_dir=tmp_path / "out")
client1 = TestClient(create_app(cfg, runner=slug_runner))
make_done_job(client1)
client2 = TestClient(create_app(cfg, runner=slug_runner))
jobs = client2.get("/api/jobs").json()
assert len(jobs) == 1
assert jobs[0]["title"] == "T" and jobs[0]["status"] == "done"
def review_runner_factory(notes, dropped_lines=None, missing_spans=None, warnings=None,
words=None):
def runner(inp, cfg, log=print, force_from=None, stages=None):
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
(work / "notes_sec.json").write_text(json.dumps({
"notes": notes, "breaks": [], "warnings": warnings or []}))
if dropped_lines is not None or missing_spans is not None:
(work / "align_meta.json").write_text(json.dumps({
"dropped_lines": dropped_lines or [],
"missing_spans": missing_spans or []}))
if words is not None:
(work / "alignment.json").write_text(json.dumps(words))
out = cfg.output_dir / song_slug(inp)
out.mkdir(parents=True, exist_ok=True)
return PipelineResult(output_dir=out, warnings=warnings or [])
return runner
def test_review_endpoint_groups_notes_by_line(tmp_path):
notes = [
{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
"low_confidence": False, "line_index": 0},
{"start": 5.0, "duration": 0.3, "pitch": 2, "text": "there",
"low_confidence": True, "line_index": 1},
]
client, _ = make_client(tmp_path, review_runner_factory(
notes, dropped_lines=[2], missing_spans=[[8.0, 9.5]]))
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "hi\nthere\ngone",
"youtube_url": "http://y"})
job_id = resp.json()["id"]
wait_status(client, job_id, "done")
body = client.get(f"/api/jobs/{job_id}/review").json()
assert body["lines"] == [
{"index": 0, "text": "hi", "dropped": False,
"notes": [{"start": 1.0, "end": 1.4, "pitch": 0, "low_confidence": False}],
"words": []},
{"index": 1, "text": "there", "dropped": False,
"notes": [{"start": 5.0, "end": 5.3, "pitch": 2, "low_confidence": True}],
"words": []},
{"index": 2, "text": "gone", "dropped": True, "notes": [], "words": []},
]
assert body["missing_spans"] == [{"start": 8.0, "end": 9.5}]
def test_review_endpoint_includes_per_word_timing(tmp_path):
notes = [
{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
"low_confidence": False, "line_index": 0},
{"start": 5.0, "duration": 0.3, "pitch": 2, "text": "there",
"low_confidence": True, "line_index": 1},
]
words = [
{"word": "hi", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "there", "start": 5.0, "end": 5.3, "score": 0.9, "line_index": 1},
]
client, _ = make_client(tmp_path, review_runner_factory(
notes, dropped_lines=[2], missing_spans=[[8.0, 9.5]], words=words))
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "hi\nthere\ngone",
"youtube_url": "http://y"})
job_id = resp.json()["id"]
wait_status(client, job_id, "done")
body = client.get(f"/api/jobs/{job_id}/review").json()
assert body["lines"][0]["words"] == [{"text": "hi", "start": 1.0, "end": 1.4}]
assert body["lines"][1]["words"] == [{"text": "there", "start": 5.0, "end": 5.3}]
assert body["lines"][2]["dropped"] is True
assert body["lines"][2]["words"] == []
def test_review_of_a_failed_job_is_404(tmp_path):
def bad_runner(*a, **kw):
raise PipelineError("boom")
client, _ = make_client(tmp_path, bad_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
job_id = resp.json()["id"]
wait_status(client, job_id, "failed")
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404
def test_review_of_a_running_job_is_409(tmp_path):
release = threading.Event()
def blocking(inp, cfg, log=print, force_from=None, stages=None):
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, blocking)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
job_id = resp.json()["id"]
try:
wait_status(client, job_id, "running")
assert client.get(f"/api/jobs/{job_id}/review").status_code == 409
finally:
release.set()
wait_status(client, job_id, "done")
def test_review_of_an_unknown_job_is_404(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.get("/api/jobs/nope/review").status_code == 404
def test_review_missing_when_pipeline_never_wrote_notes(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404