Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be67e01943 | ||
|
|
b5850042be | ||
|
|
55e8297672 | ||
|
|
a7622c2d01 | ||
|
|
c489dd5742 | ||
|
|
f1b967b487 | ||
|
|
e141cc5773 | ||
|
|
92948b2ea7 | ||
|
|
c8c3d941ca | ||
|
|
7b8fce5b6e | ||
|
|
cb10400a21 | ||
|
|
98e4f9532f | ||
|
|
77bebcf9e1 | ||
|
|
986f26b8ed | ||
|
|
d557ac6386 | ||
|
|
5023ea69d3 | ||
|
|
0599f6b665 | ||
|
|
2debfb171c | ||
|
|
ac904cb09f | ||
|
|
8e89b08571 | ||
|
|
6a98ea0f21 | ||
|
|
903e83dbcc | ||
|
|
a4e0b45953 | ||
|
|
ffd662fb38 | ||
|
|
43f0554796 | ||
|
|
ebb7fc4433 | ||
|
|
cf1c7410ab | ||
|
|
5bc4855264 | ||
|
|
a650c2f7ec | ||
|
|
283da7e1a9 | ||
|
|
292fa25d5e | ||
|
|
0eaab6aeed | ||
|
|
7a48942f1c |
@@ -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/).
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Run the CLI inside the container instead of the web UI:
|
||||
|
||||
```sh
|
||||
@@ -130,6 +119,52 @@ songtrace make --title "..." --artist "..." --lyrics lyrics.txt \
|
||||
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
|
||||
`align`, `pitch`, `notes`, `grid`, `package`.
|
||||
|
||||
In the web UI this is what selecting a queue entry does: its title, artist,
|
||||
lyrics and the rest come back up in the form, so you can adjust them and hit
|
||||
REBUILD SONG. Only the stages your edit actually invalidates are rerun —
|
||||
fixing a line of lyrics realigns without re-downloading or re-separating. NEW
|
||||
SONG INSTEAD clears the form to start a fresh song.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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: 111 KiB |
+6
-1
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+290
-76
@@ -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;
|
||||
@@ -304,6 +307,7 @@ button { cursor: pointer; }
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.primary:hover { background: #BE7BF9; }
|
||||
.primary:disabled { background: var(--control); color: var(--text-muted); cursor: not-allowed; }
|
||||
|
||||
#form-error { margin: 12px 0 0; min-height: 1.2em; font-size: 0.85rem; color: var(--fail); }
|
||||
|
||||
@@ -316,20 +320,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); }
|
||||
|
||||
@@ -360,6 +372,7 @@ button { cursor: pointer; }
|
||||
|
||||
.form-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.keep-note { margin: -8px 0 0; font-size: 0.78rem; color: var(--aqua); }
|
||||
#form-lock { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
|
||||
#review-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||
#review-lineage { margin: -10px 0 14px; }
|
||||
.lineage {
|
||||
@@ -464,6 +477,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,32 +504,37 @@ 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>
|
||||
<button type="button" id="cancel-rebuild" hidden>NEW SONG INSTEAD</button>
|
||||
<p id="form-lock" hidden></p>
|
||||
</div>
|
||||
</form>
|
||||
<p id="form-error" role="alert"></p>
|
||||
@@ -522,7 +545,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>
|
||||
@@ -553,16 +576,16 @@ const stageTrack = document.getElementById("stage-track");
|
||||
const reviewActions = document.getElementById("review-actions");
|
||||
const reviewLineage = document.getElementById("review-lineage");
|
||||
const cancelRebuild = document.getElementById("cancel-rebuild");
|
||||
const formLock = document.getElementById("form-lock");
|
||||
const formHeading = document.querySelector("#new-song h2");
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
let rebuildOf = null;
|
||||
let formDirty = false;
|
||||
let JOBS = [];
|
||||
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" },
|
||||
@@ -620,6 +643,10 @@ for (const input of form.querySelectorAll('input[type="file"]')) {
|
||||
});
|
||||
}
|
||||
|
||||
// selecting another queue entry reloads the form, so track whether there is
|
||||
// anything of the operator's in there worth warning about first
|
||||
form.addEventListener("input", () => { formDirty = true; });
|
||||
|
||||
cancelRebuild.addEventListener("click", exitRebuild);
|
||||
|
||||
function jobCard(job) {
|
||||
@@ -754,19 +781,27 @@ async function refreshJobs() {
|
||||
const resp = await fetch("/api/jobs");
|
||||
const jobs = await resp.json();
|
||||
JOBS = jobs;
|
||||
// deliberately not via selectJob: the auto-selection on load fills the review
|
||||
// panel but leaves the form blank, so landing on the page is still the way to
|
||||
// start a new song. Clicking a queue entry is what loads its inputs.
|
||||
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
|
||||
renderJobs(jobs);
|
||||
renderNowBar(jobs);
|
||||
if (selectedId) refreshDetail();
|
||||
else clearDetail();
|
||||
schedulePoll(jobs);
|
||||
}
|
||||
|
||||
async function selectJob(id) {
|
||||
// the form already holds this job; reloading it would drop edits in progress
|
||||
const reload = id !== rebuildOf;
|
||||
if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;
|
||||
selectedId = id;
|
||||
for (const card of jobsDiv.querySelectorAll(".job")) {
|
||||
if (card.dataset.id === id) card.setAttribute("aria-current", "true");
|
||||
else card.removeAttribute("aria-current");
|
||||
}
|
||||
if (reload) await loadInputs(id, JOBS.find(j => j.id === id)?.status);
|
||||
refreshDetail();
|
||||
}
|
||||
|
||||
@@ -777,44 +812,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,25 +1016,57 @@ function renderOutputPath(job) {
|
||||
outputPathDiv.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
function setFormMode(inp) {
|
||||
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";
|
||||
}
|
||||
|
||||
// a rebuild is refused with 409 until the job settles, so the form says so
|
||||
// rather than letting the submission fail
|
||||
function setSubmitLock(status) {
|
||||
const locked = rebuildOf !== null && (status === "queued" || status === "running");
|
||||
submitButton.disabled = locked;
|
||||
formLock.textContent = locked ? "editable once this run finishes" : "";
|
||||
formLock.hidden = !locked;
|
||||
}
|
||||
|
||||
function setFormMode(inp, status) {
|
||||
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
|
||||
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
|
||||
cancelRebuild.hidden = !rebuildOf;
|
||||
setSubmitLock(status);
|
||||
for (const [field, key] of [["video", "video_filename"], ["cover", "cover_filename"]]) {
|
||||
const note = form.querySelector(`[data-keep="${field}"]`);
|
||||
const kept = rebuildOf && inp ? inp[key] : null;
|
||||
note.textContent = kept ? `keeping ${kept} — attach a file to replace it` : "";
|
||||
note.hidden = !kept;
|
||||
}
|
||||
// the advanced fields carry into a rebuild whether or not the section is open,
|
||||
// so a job that has any must not hide them behind a closed summary
|
||||
form.querySelector("details").open = !!inp && (!!inp.cover_filename ||
|
||||
["genre", "year", "language", "bpm"].some(n => form.elements[n].value));
|
||||
}
|
||||
|
||||
function exitRebuild() {
|
||||
rebuildOf = null;
|
||||
form.reset();
|
||||
setFormMode(null);
|
||||
formDirty = false;
|
||||
setFormMode(null, null);
|
||||
}
|
||||
|
||||
async function startRebuild(id) {
|
||||
// selecting a queue entry brings its inputs back up, so a song can be adjusted
|
||||
// and rebuilt without being retyped
|
||||
async function loadInputs(id, status) {
|
||||
const resp = await fetch(`/api/jobs/${id}/input`);
|
||||
if (!resp.ok) return;
|
||||
const inp = await resp.json();
|
||||
@@ -903,9 +1077,9 @@ async function startRebuild(id) {
|
||||
if (el && value !== null) el.value = value;
|
||||
}
|
||||
rebuildOf = id;
|
||||
setFormMode(inp);
|
||||
formDirty = false;
|
||||
setFormMode(inp, status);
|
||||
formError.textContent = "";
|
||||
form.elements.title.focus();
|
||||
}
|
||||
|
||||
async function rerunJob(id) {
|
||||
@@ -920,21 +1094,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;
|
||||
// the form is holding a job that no longer exists; a rebuild would 404
|
||||
if (rebuildOf === id) exitRebuild();
|
||||
refreshJobs();
|
||||
}
|
||||
|
||||
const ACTION_LABELS = {
|
||||
rerun: "RERUN",
|
||||
"rebuild-full": "REBUILD FROM SCRATCH",
|
||||
delete: "DELETE",
|
||||
};
|
||||
|
||||
const ACTION_HANDLERS = {
|
||||
rerun: rerunJob,
|
||||
"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 = ["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 +1182,8 @@ async function refreshDetail() {
|
||||
reviewTitle.replaceChildren(`${job.title} `, artist);
|
||||
reviewError.hidden = !job.error;
|
||||
reviewError.textContent = job.error ?? "";
|
||||
renderWarnings(job);
|
||||
if (rebuildOf === job.id) setSubmitLock(job.status);
|
||||
renderLyricsReview(job);
|
||||
renderOutputPath(job);
|
||||
renderLineage(job);
|
||||
renderActions(job);
|
||||
|
||||
+111
-6
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
+435
-8
@@ -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():
|
||||
@@ -525,12 +596,66 @@ def _rebuild_flaws(html):
|
||||
if '"failed"' not in actions or '"done"' not in actions:
|
||||
flaws.append("the action row does not key off the job status")
|
||||
|
||||
start = _fn_body(html, "startRebuild")
|
||||
if "/input" not in start or "form.elements[" not in start:
|
||||
load = _fn_body(html, "loadInputs")
|
||||
if "/input" not in load or "form.elements[" not in load:
|
||||
flaws.append("rebuild mode does not prefill the form from the job's input")
|
||||
|
||||
select = _fn_body(html, "selectJob")
|
||||
if "loadInputs(" not in select:
|
||||
flaws.append("selecting a queue entry does not bring its inputs back up")
|
||||
if "formDirty" not in select or "confirm(" not in select:
|
||||
flaws.append("selecting a queue entry discards unsaved edits without asking")
|
||||
|
||||
if "if (rebuildOf === id) exitRebuild();" not in _fn_body(html, "deleteJob"):
|
||||
flaws.append("deleting the loaded job leaves the form pointed at it")
|
||||
|
||||
mode = _fn_body(html, "setFormMode")
|
||||
if 'querySelector("details").open' not in mode:
|
||||
flaws.append("prefilled genre/year/language/bpm stay hidden behind the "
|
||||
"closed Advanced summary, so they rebuild unseen")
|
||||
if "submitButton.disabled" not in _fn_body(html, "setSubmitLock"):
|
||||
flaws.append("the form lets you submit a rebuild the API will answer 409")
|
||||
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 +684,25 @@ 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",
|
||||
'["rebuild-full", "delete"]', '["delete"]'),
|
||||
(_rebuild_flaws, "prefill: selecting a queue entry no longer loads its inputs",
|
||||
" if (reload) await loadInputs(id, JOBS.find(j => j.id === id)?.status);\n", ""),
|
||||
(_rebuild_flaws, "prefill: unsaved edits discarded without asking",
|
||||
' if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;\n',
|
||||
""),
|
||||
(_rebuild_flaws, "prefill: deleting the loaded job leaves the form pointed at it",
|
||||
" if (rebuildOf === id) exitRebuild();\n", ""),
|
||||
(_rebuild_flaws, "prefill: advanced fields stay hidden behind a closed summary",
|
||||
'form.querySelector("details").open =', "const unusedAdvancedOpen ="),
|
||||
(_rebuild_flaws, "prefill: an in-progress job can be submitted for rebuild anyway",
|
||||
"submitButton.disabled = locked;", ""),
|
||||
(_empty_state_flaws, "empty state: clearDetail no longer wired into refreshJobs",
|
||||
"if (selectedId) refreshDetail();\n else clearDetail();",
|
||||
"if (selectedId) refreshDetail();"),
|
||||
]
|
||||
|
||||
|
||||
@@ -776,7 +920,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 +933,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 +961,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 +1047,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 +1148,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 +1225,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
|
||||
|
||||
Reference in New Issue
Block a user