Compare commits

...
32 Commits
Author SHA1 Message Date
branderson 3d36216769 fix: working clipboard on http, stale detail race, queued rail, idle label, empty-state copy
Final whole-branch review fix wave (5 items):
- copyPath: fall back to execCommand("copy") via an off-screen textarea,
  then to selecting the path text, since navigator.clipboard is undefined
  outside secure contexts (i.e. this app's normal http:// deployment)
- refreshDetail: snapshot selectedId before fetching and discard the
  response if selection changed while in flight, so a slow response can no
  longer permanently mis-pair the review panel with the wrong job now that
  polling stops when idle
- .st-queued: give the sidebar rail a colour that clears 3:1 against --panel
- now-bar: #now-label reflects running/idle state instead of always reading
  "NOW RUNNING"
- empty-state copy: distinguish a failed job from "nothing to review", fix
  the queue-empty messages to not ask the user to select something that
  doesn't exist, and normalise voice to lowercase/unpunctuated
2026-07-26 21:09:56 -07:00
branderson df5eff8c20 fix: pin the focus guards with mutation tests, keep #copy-path focusable, state not by colour alone 2026-07-26 20:27:23 -07:00
branderson 439f6f75b8 feat: live stage track, form polish, and idle-aware polling 2026-07-26 20:01:04 -07:00
branderson 9e01f0b204 feat: surface low-confidence warnings as first-class review rows 2026-07-26 19:45:03 -07:00
branderson cf48324f17 fix: strengthen xss guard, 3:1 field borders, clean disclosure names, centre main 2026-07-26 19:31:42 -07:00
branderson 8675d5594b feat: themed spotify-style shell for the web ui 2026-07-26 19:09:36 -07:00
branderson ca6006023d feat: vendor chakra petch display font and serve static assets 2026-07-26 18:50:41 -07:00
branderson 69810f584d feat: expose stage order and track cached stages in the web api 2026-07-26 18:44:46 -07:00
branderson 232f0354fc feat: record job timestamps in the web api 2026-07-26 18:41:02 -07:00
branderson d2ad3d1c90 fix: honest package-stage caching, safe cover fallback, unified language codes, upload cleanup
- package stage: cache off a package.done sentinel written only after every
  sub-step succeeds, instead of the txt/mp3 files that land first. A partial
  failure (e.g. ffprobe/PIL error after txt+mp3 are written) no longer looks
  cached, so a retry actually finishes the folder instead of being skipped.
- package stage: a corrupt/non-image cover file now degrades to the existing
  no-cover path (drop #COVER header) instead of raising UnidentifiedImageError.
- consolidate the three independent language-code tables (lyrics._LANG_MAP,
  notes._PYPHEN, whisper_client._ISO) into languages.LANGUAGES so a language
  known to one call site can't be silently mishandled by another (e.g.
  Japanese reaching Whisper correctly but syllabifying with English rules).
- web.py: remove a job's uploads/<job_id> dir once it reaches a terminal
  state; safe because acquire copies the upload into the per-song work dir
  and package reads cover_path directly, both inside the synchronous
  runner() call that precedes cleanup.
2026-07-25 21:59:45 -07:00
branderson ce57680a6a feat: docker packaging and readme
python:3.11-slim base relies on pip torch wheels bundling CUDA runtime libs,
with the host NVIDIA driver passed through via the nvidia container runtime
(compose GPU reservation); TORCH_HOME and XDG_CACHE_HOME point into the
mounted /data volume so Demucs/wav2vec2 weights survive container
recreation.

Docker was not installed in the implementation sandbox, so the image was
not built or run and remains untested. PyYAML was also unavailable, so
docker-compose.yml was not parse-checked programmatically — only hand
reviewed.
2026-07-25 21:38:24 -07:00
branderson 465d420b11 fix: harden web UI against XSS, stream uploads, and fix worker races 2026-07-25 21:24:43 -07:00
branderson 76075baf93 feat: minimal web ui with job queue 2026-07-25 21:15:34 -07:00
branderson 19d8ed6d7f fix: report bad lyrics file and content-dependent grid failures cleanly
- cli.py: guard --lyrics read against missing/unreadable/undecodable
  files, matching the file's existing error-reporting conventions
- stages/grid.py: route ValueError from scale_bpm/to_ultrastar through
  PipelineError with a user-facing message; use bpm_override is not None
  instead of truthiness so an explicit 0 override isn't silently ignored
2026-07-25 21:08:41 -07:00
branderson e093d8afd4 feat: cli make command and opt-in e2e test 2026-07-25 21:02:12 -07:00
branderson a349d697e4 feat: package stage producing final song folder 2026-07-25 20:56:34 -07:00
branderson 0f09e6702b fix: guard unusable bpm and empty notes, pin line-break tie ordering 2026-07-25 20:51:19 -07:00
branderson ba78587a54 feat: beat grid and ultrastar txt assembly 2026-07-25 20:42:08 -07:00
branderson b3a89b2f37 fix: guarantee non-overlapping notes with strictly increasing starts 2026-07-25 20:26:57 -07:00
branderson 7f1911642b feat: note generation from alignment + pitch 2026-07-25 20:08:55 -07:00
branderson bdde0af312 feat: torchcrepe pitch tracking stage 2026-07-25 20:00:24 -07:00
branderson f75686b736 fix: correct vocal section merging for out-of-order segments 2026-07-25 19:55:22 -07:00
branderson 19990d6883 feat: forced-alignment stage with lyrics sanity check 2026-07-25 19:46:32 -07:00
branderson c9d633eaf6 feat: whisper client and transcribe stage with section/similarity helpers 2026-07-25 19:38:51 -07:00
branderson ef1ac6e59c feat: demucs separation stage 2026-07-25 19:34:42 -07:00
branderson e5f1a17e0f fix: deterministic video/thumbnail classification in acquire stage 2026-07-25 19:31:34 -07:00
branderson d9dbfbfffa feat: acquire stage (yt-dlp / local file) and ffmpeg helpers 2026-07-25 19:26:48 -07:00
branderson 988970676a feat: pipeline framework with stage caching and force-from 2026-07-25 19:23:15 -07:00
branderson f3bd496461 fix: alignment_token whitespace consistency and typographic apostrophes 2026-07-25 19:21:07 -07:00
branderson ca42a6bad8 feat: reference lyrics normalization 2026-07-25 19:16:41 -07:00
branderson 2ad77775ef feat: config loading from toml 2026-07-25 19:14:15 -07:00
branderson d24d44582e feat: ultrastar txt parser/serializer with fixture round-trip 2026-07-25 19:09:49 -07:00
45 changed files with 4193 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
# torch pip wheels bundle their own CUDA runtime libs; only the host NVIDIA
# driver is needed (exposed via the nvidia container runtime), so a plain
# Python base is sufficient — no CUDA base image required.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
COPY src ./src
RUN pip install --no-cache-dir .
ENV KARAOKE_MAKER_CONFIG=/config/config.toml \
TORCH_HOME=/data/models \
XDG_CACHE_HOME=/data/models/cache
EXPOSE 8000
CMD ["karaoke-maker", "serve"]
+106
View File
@@ -0,0 +1,106 @@
# karaoke-maker
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.
The pipeline:
1. **acquire** — download audio/video via `yt-dlp` (or use a local video file)
2. **separate** — split vocals/instrumental with Demucs
3. **transcribe** — send the vocal stem to your own intranet OpenAI-compatible
Whisper server, as a sanity check that the supplied lyrics match the audio
and to locate vocal sections
4. **align** — forced-align the *supplied* reference lyrics against the vocal
stem with wav2vec2 (torchaudio's MMS_FA) to get word timings
5. **pitch** — track pitch on the vocal stem with torchcrepe
6. **notes** — turn word timings + pitch into UltraStar notes
7. **grid** — quantise notes to a beat grid
8. **package** — write the final song folder (`.txt` + `.mp3` + video + cover)
The shipped `.mp3` is the full original mix (vocals included) — the
separated stems are used for analysis only and are not included in the
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
[USDX](https://usdx.eu/) and touch those spots up before playing.
## Configuration
Config is TOML. Resolution order:
1. `--config <path>` passed explicitly on the command line
2. the `KARAOKE_MAKER_CONFIG` environment variable
3. `~/.config/karaoke-maker/config.toml`
Copy the example and fill in your Whisper server:
```sh
cp config.example.toml config.toml
$EDITOR config.toml # set whisper_url at minimum
```
See `config.example.toml` for every available field (`whisper_url`,
`whisper_api_key`, `whisper_model`, `output_dir`, `work_dir`, `creator`,
`demucs_model`, `crepe_model`, `device`, `bind_host`, `port`).
## Docker
```sh
cp config.example.toml config.toml
$EDITOR config.toml
docker compose up -d
```
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
docker compose run --rm karaoke-maker karaoke-maker make \
--title "Song Title" --artist "Artist Name" \
--lyrics /data/lyrics.txt --youtube https://youtu.be/...
```
GPU passthrough requires the [NVIDIA Container
Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/)
installed on the host; `docker-compose.yml` requests one GPU via the
`nvidia` device driver.
## Bare-metal
```sh
pip install -e .
karaoke-maker make --help
```
```
karaoke-maker make --title TITLE --artist ARTIST --lyrics LYRICS_FILE \
(--youtube URL | --video PATH) \
[--genre GENRE] [--year YEAR] [--language LANGUAGE] [--cover COVER_IMAGE] \
[--bpm BPM] [--force-from STAGE] [--config CONFIG]
```
Run the web UI instead with `karaoke-maker serve [--config CONFIG]`.
## Re-running a stage
Each stage caches its output under `work_dir`. If a later stage needs
another pass (e.g. you want to retry alignment with different lyrics, or
pitch tracking looks off), re-run with `--force-from <stage>` to redo that
stage and everything after it, reusing the cached output of everything
before it:
```sh
karaoke-maker make --title "..." --artist "..." --lyrics lyrics.txt \
--youtube https://youtu.be/... --force-from align
```
Valid stages, in pipeline order: `acquire`, `separate`, `transcribe`,
`align`, `pitch`, `notes`, `grid`, `package`.
+11
View File
@@ -0,0 +1,11 @@
whisper_url = "http://whisper.intranet:9000/v1"
# whisper_api_key = ""
# whisper_model = "whisper-1"
output_dir = "/data/output"
work_dir = "/data/work"
creator = "brad"
# demucs_model = "htdemucs_ft"
# crepe_model = "full"
# device = "cuda"
bind_host = "0.0.0.0"
# port = 8000
+15
View File
@@ -0,0 +1,15 @@
services:
karaoke-maker:
build: .
ports:
- "8000:8000"
volumes:
- ./config.toml:/config/config.toml:ro
- ./data:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
+3
View File
@@ -34,5 +34,8 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
karaoke_maker = ["static/*", "static/fonts/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+68 -1
View File
@@ -1,16 +1,83 @@
import argparse
import math
import sys
from importlib.metadata import version
from pathlib import Path
from .config import load_config
from .pipeline import STAGE_ORDER, PipelineError, SongInput, run_pipeline
def _positive_float(value: str) -> float:
bpm = float(value)
if not math.isfinite(bpm) or bpm <= 0:
raise argparse.ArgumentTypeError(f"--bpm must be a finite value > 0, got {value!r}")
return bpm
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="karaoke-maker")
parser.add_argument("--version", action="store_true")
parser.add_subparsers(dest="command")
sub = parser.add_subparsers(dest="command")
make = sub.add_parser("make", help="build a karaoke song folder")
make.add_argument("--title", required=True)
make.add_argument("--artist", required=True)
make.add_argument("--lyrics", required=True, type=Path)
make.add_argument("--youtube")
make.add_argument("--video", type=Path)
make.add_argument("--genre")
make.add_argument("--year", type=int)
make.add_argument("--language")
make.add_argument("--cover", type=Path)
make.add_argument("--bpm", type=_positive_float)
make.add_argument("--force-from", choices=STAGE_ORDER)
make.add_argument("--config", type=Path)
serve = sub.add_parser("serve", help="run the web UI")
serve.add_argument("--config", type=Path)
return parser
def _cmd_make(args) -> int:
if bool(args.youtube) == bool(args.video):
print("error: provide exactly one of --youtube / --video", file=sys.stderr)
return 2
try:
lyrics_text = args.lyrics.read_text(encoding="utf-8")
except OSError as e:
print(f"error: could not read --lyrics file: {e}", file=sys.stderr)
return 2
except UnicodeDecodeError as e:
print(f"error: --lyrics file is not valid UTF-8 text: {e}", file=sys.stderr)
return 2
cfg = load_config(args.config)
inp = SongInput(
title=args.title, artist=args.artist,
lyrics_text=lyrics_text,
youtube_url=args.youtube, video_path=args.video,
genre=args.genre, year=args.year, language=args.language,
cover_path=args.cover, bpm_override=args.bpm)
try:
result = run_pipeline(inp, cfg, force_from=args.force_from)
except PipelineError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"done: {result.output_dir}")
for w in result.warnings:
print(f" review: {w}")
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.version:
print(f"karaoke-maker {version('karaoke-maker')}")
return 0
if args.command == "make":
return _cmd_make(args)
if args.command == "serve":
from .web import serve
return serve(load_config(args.config))
build_parser().print_help()
return 0
+36
View File
@@ -0,0 +1,36 @@
import os
import tomllib
from dataclasses import dataclass, fields
from pathlib import Path
@dataclass
class Config:
whisper_url: str
whisper_api_key: str | None = None
whisper_model: str = "whisper-1"
output_dir: Path = Path("output")
work_dir: Path = Path("work")
creator: str = "karaoke-maker"
demucs_model: str = "htdemucs_ft"
crepe_model: str = "full"
device: str = "cuda"
bind_host: str = "127.0.0.1"
port: int = 8000
_PATH_FIELDS = {"output_dir", "work_dir"}
def load_config(path: Path | None = None) -> Config:
if path is None:
env = os.environ.get("KARAOKE_MAKER_CONFIG")
path = Path(env) if env else Path.home() / ".config/karaoke-maker/config.toml"
data = tomllib.loads(Path(path).read_text())
known = {f.name for f in fields(Config)}
unknown = set(data) - known
if unknown:
raise ValueError(f"unknown config keys: {', '.join(sorted(unknown))}")
for key in _PATH_FIELDS & set(data):
data[key] = Path(data[key])
return Config(**data)
+58
View File
@@ -0,0 +1,58 @@
import math
from pathlib import Path
import numpy as np
from .ultrastar import LineBreak, Note, UltraStarSong, format_bpm
def detect_bpm(instrumental_path: Path) -> float:
import librosa
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True)
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
return float(np.atleast_1d(tempo)[0])
def scale_bpm(bpm: float) -> float:
"""Fold a tempo into UltraStar's usual [200, 400) file-BPM range."""
if not math.isfinite(bpm) or bpm <= 0:
raise ValueError(f"cannot scale bpm {bpm}")
while bpm < 200:
bpm *= 2
while bpm >= 400:
bpm /= 2
return bpm
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
headers: list[tuple[str, str]]) -> UltraStarSong:
if not notes:
raise ValueError("no notes to place on the beat grid")
gap_ms = round(notes[0]["start"] * 1000)
spb = 60 / (4 * file_bpm)
def beat(t: float) -> int:
return round((t - gap_ms / 1000) / spb)
# a line break at the same instant as a note belongs before it
events = [(t, 0, None) for t in breaks] + [(n["start"], 1, n) for n in notes]
events.sort(key=lambda e: e[:2])
body: list[Note | LineBreak] = []
# notes.py can only keep notes apart in seconds; quantising re-collides them,
# so the cursor keeps the emitted beats strictly ordered and non-overlapping
cursor = -math.inf
for t, is_note, n in events:
if not is_note:
b = max(beat(t), cursor)
body.append(LineBreak(beat=b))
cursor = b
else:
b = max(beat(t), cursor)
length = max(1, beat(t + n["duration"]) - beat(t))
body.append(Note(kind=":", beat=b, length=length, pitch=n["pitch"],
text=n["text"]))
cursor = b + length
out_headers = list(headers)
out_headers.append(("BPM", format_bpm(file_bpm)))
out_headers.append(("GAP", str(gap_ms)))
return UltraStarSong(headers=out_headers, body=body)
+19
View File
@@ -0,0 +1,19 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Language:
iso: str # whisper / num2words language code
pyphen: str | None # pyphen dictionary code, None if no dictionary exists
LANGUAGES: dict[str, Language] = {
"english": Language("en", "en_US"),
"german": Language("de", "de_DE"),
"spanish": Language("es", "es"),
"french": Language("fr", "fr"),
"italian": Language("it", "it"),
"portuguese": Language("pt", "pt"),
"dutch": Language("nl", "nl_NL"),
"japanese": Language("ja", None),
}
+44
View File
@@ -0,0 +1,44 @@
import re
from dataclasses import dataclass
from num2words import num2words
from .languages import LANGUAGES
SECTION_RE = re.compile(r"^\s*[\[(].*[\])]\s*$")
@dataclass
class RefLyrics:
lines: list[list[str]]
words: list[str]
word_line_index: list[int]
def normalize_lyrics(text: str) -> RefLyrics:
lines = []
for raw in text.splitlines():
if not raw.strip() or SECTION_RE.match(raw):
continue
lines.append(raw.split())
words, idx = [], []
for i, line in enumerate(lines):
words.extend(line)
idx.extend([i] * len(line))
return RefLyrics(lines, words, idx)
def alignment_token(word: str, language: str | None = None) -> str:
entry = LANGUAGES.get((language or "english").lower())
lang = entry.iso if entry else "en"
def expand(m):
try:
return " " + num2words(int(m[0]), lang=lang) + " "
except NotImplementedError:
return " " + num2words(int(m[0])) + " "
word = word.replace("’", "'").replace("ʼ", "'")
word = re.sub(r"\d+", expand, word)
word = word.lower()
word = re.sub(r"[^a-z'À-ɏЀ-ӿ ]", "", word)
return re.sub(r"\s+", "", word)
+23
View File
@@ -0,0 +1,23 @@
import json
import subprocess
from pathlib import Path
from .pipeline import PipelineError
def ffmpeg(*args: str) -> None:
result = subprocess.run(["ffmpeg", "-y", *args], capture_output=True, text=True)
if result.returncode != 0:
raise PipelineError(f"ffmpeg failed: {result.stderr[-2000:]}")
def ffprobe_codec(path: Path) -> tuple[str, str]:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(path)],
capture_output=True, text=True)
if result.returncode != 0:
raise PipelineError(f"ffprobe failed on {path}")
data = json.loads(result.stdout)
video = next((s for s in data["streams"] if s["codec_type"] == "video"), {})
return data["format"]["format_name"], video.get("codec_name", "")
+167
View File
@@ -0,0 +1,167 @@
import math
from dataclasses import dataclass
import numpy as np
import pyphen
from .languages import LANGUAGES
CONF_MASK = 0.5 # pitch frames below this are unvoiced
LOW_SCORE = 0.5 # word alignment score below this -> low_confidence
SPLIT_SEMITONES = 0.8
MIN_SEG_SEC = 0.05
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
HOP_SEC = 0.01 # pitch stage hop
_SYLL_SEP = "\0" # sentinel so literal hyphens survive syllabification
@dataclass
class TimedNote:
start: float
duration: float
pitch: int # UltraStar semitone (0 = C4 = MIDI 60)
text: str # display text incl. leading space / "~"
low_confidence: bool
def hz_to_midi_float(hz: float) -> float:
return 69 + 12 * math.log2(hz / 440.0)
def hz_to_ultrastar(hz: float) -> int:
return round(hz_to_midi_float(hz)) - 60
def segment_span(times, f0, conf, start: float, end: float
) -> list[tuple[float, float, float]]:
"""Voiced pitch segments within [start, end) as (start, end, median_midi)."""
mask = (times >= start) & (times < end) & (conf >= CONF_MASK) & (f0 > 0)
t = times[mask]
if len(t) == 0:
return []
midi = 69 + 12 * np.log2(f0[mask] / 440.0)
segs: list[tuple[float, float, float]] = []
def emit(a: int, b: int) -> None:
if float(t[b - 1]) - float(t[a]) + HOP_SEC >= MIN_SEG_SEC:
segs.append((float(t[a]), float(t[b - 1]) + HOP_SEC,
float(np.median(midi[a:b]))))
seg_start, dev_start = 0, None
for i in range(1, len(t)):
stable_end = i if dev_start is None else dev_start
if abs(midi[i] - np.median(midi[seg_start:stable_end])) >= SPLIT_SEMITONES:
# only split once the deviation has held for MIN_SEG_SEC, so that
# short tracker glitches don't fragment a sustained note
if dev_start is None:
dev_start = i
elif t[i] - t[dev_start] + HOP_SEC >= MIN_SEG_SEC:
emit(seg_start, dev_start)
seg_start, dev_start = dev_start, None
else:
dev_start = None
emit(seg_start, len(t))
return segs
def syllabify(word: str, language: str | None) -> list[str]:
entry = LANGUAGES.get((language or "english").lower())
code = entry.pyphen if entry else "en_US"
if code is None:
return [word]
try:
dic = pyphen.Pyphen(lang=code)
except KeyError:
return [word]
parts = [p for p in dic.inserted(word, hyphen=_SYLL_SEP).split(_SYLL_SEP) if p]
return parts or [word]
def build_notes(rows: list[dict], times, f0, conf, language: str | None
) -> tuple[list[TimedNote], list[float], list[str]]:
notes: list[TimedNote] = []
breaks: list[float] = []
prev_line, prev_end = None, 0.0
for row in rows:
if prev_line is not None and row["line_index"] > prev_line:
breaks.append((prev_end + row["start"]) / 2)
line_initial = row["line_index"] != prev_line
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))
continue
low = row["score"] < LOW_SCORE
sylls = syllabify(row["word"], language)
if len(segs) < len(sylls):
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))
_deoverlap(notes)
return notes, breaks, _warnings(notes)
def _note(start: float, duration: float, pitch: int, text: str,
low: bool) -> TimedNote:
return TimedNote(float(start), float(duration), int(pitch), text, low)
def _deoverlap(notes: list[TimedNote]) -> None:
"""Give every note a positive length without letting notes collide.
Notes are grown to MIN_NOTE_SEC (alignment hands us zero-length words, and
subdividing a short word by syllable makes very short slices) but only into
space the next note isn't using; where there is no space at all the rest of
the song is nudged later.
"""
limits = [math.inf] * len(notes)
for i in range(len(notes) - 2, -1, -1):
limits[i] = (notes[i + 1].start if notes[i + 1].start > notes[i].start
else limits[i + 1])
cursor = -math.inf
for note, limit in zip(notes, limits):
note.start = max(note.start, cursor)
room = limit - note.start
note.duration = (min(max(note.duration, MIN_NOTE_SEC), room)
if room > 0 else MIN_NOTE_SEC)
cursor = note.start + note.duration
def _text(syllable: str, index: int, line_initial: bool) -> str:
return syllable if index or line_initial else " " + syllable
def _subdivide(segs, target: int):
"""Cut segments into equal slices until there is one per syllable."""
parts = [1] * len(segs)
while sum(parts) < target:
i = max(range(len(segs)),
key=lambda j: (segs[j][1] - segs[j][0]) / parts[j])
parts[i] += 1
out = []
for (s, e, midi), n in zip(segs, parts):
step = (e - s) / n
out.extend((s + k * step, s + (k + 1) * step, midi) for k in range(n))
return out
def _fmt(t: float) -> str:
return f"{int(t // 60)}:{int(t % 60):02d}"
def _warnings(notes: list[TimedNote]) -> list[str]:
warnings, run = [], []
for n in list(notes) + [None]:
if n is not None and n.low_confidence:
run.append(n)
elif run:
first, last = run[0], run[-1]
warnings.append(
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
run = []
return warnings
+76
View File
@@ -0,0 +1,76 @@
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .config import Config
class PipelineError(Exception):
pass
@dataclass
class SongInput:
title: str
artist: str
lyrics_text: str
youtube_url: str | None = None
video_path: Path | None = None
genre: str | None = None
year: int | None = None
language: str | None = None
cover_path: Path | None = None # local file or URL already fetched by front-end
bpm_override: float | None = None
@dataclass
class Stage:
name: str
outputs: Callable[["Ctx"], list[Path]]
run: Callable[["Ctx"], None]
@dataclass
class Ctx:
inp: SongInput
cfg: Config
work: Path # per-song work dir (created)
log: Callable[[str], None]
@dataclass
class PipelineResult:
output_dir: Path
warnings: list[str] = field(default_factory=list)
STAGE_ORDER = ["acquire", "separate", "transcribe", "align", "pitch", "notes", "grid", "package"]
def song_slug(inp: SongInput) -> str:
name = f"{inp.artist} - {inp.title}"
return name.replace("/", "").replace("\0", "")
def _real_stages() -> list[Stage]:
from . import stages
return stages.all_stages()
def run_pipeline(inp: SongInput, cfg: Config, log=print,
force_from: str | None = None,
stages: list[Stage] | None = None) -> PipelineResult:
if stages is None:
stages = _real_stages()
names = [s.name for s in stages]
if force_from is not None and force_from not in names:
raise PipelineError(f"unknown stage {force_from!r}, expected one of {names}")
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
ctx = Ctx(inp=inp, cfg=cfg, work=work, log=log)
forcing = False
for stage in stages:
if stage.name == force_from:
forcing = True
if not forcing and all(p.exists() for p in stage.outputs(ctx)):
log(f"[{stage.name}] cached, skipping")
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)
+14
View File
@@ -0,0 +1,14 @@
from ..pipeline import Stage
from .acquire import acquire_stage
from .align import align_stage
from .grid import grid_stage
from .notes import notes_stage
from .package import package_stage
from .pitch import pitch_stage
from .separate import separate_stage
from .transcribe import transcribe_stage
def all_stages() -> list[Stage]:
return [acquire_stage(), separate_stage(), transcribe_stage(), align_stage(),
pitch_stage(), notes_stage(), grid_stage(), package_stage()]
+54
View File
@@ -0,0 +1,54 @@
import json
import shutil
from pathlib import Path
from ..media import ffmpeg
from ..pipeline import Ctx, PipelineError, Stage
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"}
def _classify_downloads(dest: Path) -> tuple[Path, Path | None]:
files = list(dest.glob("source_video.*"))
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:
raise PipelineError("yt-dlp produced no video file")
video = max(videos, key=lambda p: p.stat().st_size)
return video, thumbs[0] if thumbs else None
def download_youtube(url: str, dest: Path) -> tuple[Path, Path | None]:
import yt_dlp
opts = {
"outtmpl": str(dest / "source_video.%(ext)s"),
"format": "bestvideo*+bestaudio/best",
"writethumbnail": True,
"noplaylist": True,
}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.extract_info(url, download=True)
return _classify_downloads(dest)
def _run(ctx: Ctx) -> None:
if bool(ctx.inp.youtube_url) == bool(ctx.inp.video_path):
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)
else:
video = ctx.work / f"source_video{ctx.inp.video_path.suffix}"
shutil.copyfile(ctx.inp.video_path, video)
ffmpeg("-i", str(video), "-vn", "-ac", "2", "-ar", "44100",
str(ctx.work / "audio.wav"))
meta = {"video_file": video.name, "thumbnail": thumb.name if thumb else None}
(ctx.work / "meta.json").write_text(json.dumps(meta))
def acquire_stage() -> Stage:
return Stage(
name="acquire",
outputs=lambda ctx: [ctx.work / "meta.json", ctx.work / "audio.wav"],
run=_run,
)
+122
View File
@@ -0,0 +1,122 @@
import json
from pathlib import Path
from ..lyrics import RefLyrics, alignment_token, normalize_lyrics
from ..pipeline import Ctx, PipelineError, Stage
from .transcribe import load_transcript, lyrics_similarity, vocal_sections
SIMILARITY_THRESHOLD = 0.4
_model_cache = {}
def _get_model(device: str):
if device not in _model_cache:
from torchaudio.pipelines import MMS_FA as bundle
_model_cache[device] = bundle.get_model().to(device)
return _model_cache[device]
def force_align_section(vocals_path: Path, start: float, end: float,
tokens: list[str], device: str) -> list[tuple[float, float, float]]:
import torch
import torchaudio
from torchaudio.pipelines import MMS_FA as bundle
waveform, sr = torchaudio.load(str(vocals_path))
waveform = waveform.mean(0, keepdim=True)
lo, hi = int(start * sr), min(int(end * sr), waveform.shape[1])
waveform = torchaudio.functional.resample(
waveform[:, lo:hi], sr, bundle.sample_rate)
# MMS_FA only models a-z and apostrophe; anything else becomes the star token.
vocab = bundle.get_dict()
tokens = ["".join(c if c in vocab else "*" for c in t) for t in tokens]
with torch.inference_mode():
emission, _ = _get_model(device)(waveform.to(device))
spans = bundle.get_aligner()(emission[0], bundle.get_tokenizer()(tokens))
frame = (hi - lo) / sr / emission.shape[1]
return [(start + s[0].start * frame,
start + s[-1].end * frame,
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."""
total = sum(b - a for a, b in sections) or 1.0
n_lines = len(ref.lines)
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))))
start = max(stop, start)
return groups
def _interpolate(rows: list[dict], lo_bound: float, hi_bound: float) -> None:
"""Give rows left without times evenly spaced spans between their aligned neighbours."""
i = 0
while i < len(rows):
if rows[i]["start"] is not None:
i += 1
continue
j = i
while j < len(rows) and rows[j]["start"] is None:
j += 1
lo = rows[i - 1]["end"] if i else lo_bound
hi = max(rows[j]["start"] if j < len(rows) else hi_bound, lo)
step = (hi - lo) / (j - i)
for k in range(i, j):
rows[k]["start"] = lo + (k - i) * step
rows[k]["end"] = rows[k]["start"] + step
i = j
def _enforce_monotonic(rows: list[dict]) -> None:
prev = 0.0
for r in rows:
r["start"] = max(r["start"], prev)
r["end"] = prev = max(r["end"], r["start"])
def _align_section(vocals: Path, sec: tuple[float, float], words: list[tuple[str, int]],
language: str | None, device: str) -> list[dict]:
tokens = [alignment_token(w, language) for w, _ in words]
alignable = [t for t in tokens if t]
aligned = iter(force_align_section(vocals, sec[0], sec[1], alignable, device)
if alignable else [])
rows, blank = [], (None, None, 0.0)
for (word, line_index), token in zip(words, tokens):
start, end, score = next(aligned, blank) if token else blank
rows.append({"word": word, "start": start, "end": end,
"score": score, "line_index": line_index})
_interpolate(rows, sec[0], sec[1])
return rows
def _run(ctx: Ctx) -> None:
ref = normalize_lyrics(ctx.inp.lyrics_text)
segments = load_transcript(ctx)
sim = lyrics_similarity(segments, ref)
if sim < SIMILARITY_THRESHOLD:
raise PipelineError(
f"lyrics don't match audio (similarity {sim:.2f}) — "
"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)]
vocals = ctx.work / "vocals.wav"
rows = []
for sec, line_idxs in zip(sections, _assign_lines_to_sections(ref, 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))
def align_stage() -> Stage:
return Stage(name="align",
outputs=lambda ctx: [ctx.work / "alignment.json"],
run=_run)
+40
View File
@@ -0,0 +1,40 @@
import json
from pathlib import Path
from .. import grid
from ..pipeline import Ctx, PipelineError, Stage, song_slug
from ..ultrastar import serialize_ultrastar
def _run(ctx: Ctx) -> None:
data = json.loads((ctx.work / "notes_sec.json").read_text())
meta = json.loads((ctx.work / "meta.json").read_text())
if ctx.inp.bpm_override is not None:
bpm = ctx.inp.bpm_override
else:
try:
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
except ValueError:
raise PipelineError("could not detect a usable tempo - is the audio silent?")
slug = song_slug(ctx.inp)
video_ext = Path(meta["video_file"]).suffix
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
if ctx.inp.language:
headers.append(("LANGUAGE", ctx.inp.language))
if ctx.inp.genre:
headers.append(("GENRE", ctx.inp.genre))
if ctx.inp.year:
headers.append(("YEAR", str(ctx.inp.year)))
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
try:
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers)
except ValueError:
raise PipelineError("no notes were generated - the lyrics may not match the audio")
(ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
def grid_stage() -> Stage:
return Stage(name="grid",
outputs=lambda ctx: [ctx.work / "song.txt"],
run=_run)
+25
View File
@@ -0,0 +1,25 @@
import json
from dataclasses import asdict
import numpy as np
from ..notes import build_notes
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")
notes, breaks, warnings = build_notes(
rows, data["times"], data["f0"], data["conf"], ctx.inp.language)
(ctx.work / "notes_sec.json").write_text(json.dumps({
"notes": [asdict(n) for n in notes],
"breaks": breaks,
"warnings": warnings,
}))
def notes_stage() -> Stage:
return Stage(name="notes",
outputs=lambda ctx: [ctx.work / "notes_sec.json"],
run=_run)
+80
View File
@@ -0,0 +1,80 @@
import json
import shutil
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from ..media import ffmpeg, ffprobe_codec
from ..pipeline import Ctx, Stage, song_slug
from ..ultrastar import parse_ultrastar, serialize_ultrastar
GOOD_VIDEO = {("webm", "vp9"), ("webm", "vp8"),
("mov,mp4,m4a,3gp,3g2,mj2", "h264")}
def prepare_cover(src: Path, dest_jpg: Path) -> None:
img = Image.open(src).convert("RGB")
side = min(img.size)
left = (img.width - side) // 2
top = (img.height - side) // 2
img = img.crop((left, top, left + side, top + side))
if side > 800:
img = img.resize((800, 800))
img.save(dest_jpg, "JPEG", quality=90)
def _set_header(txt_path: Path, key: str, value: str | None) -> None:
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
replaced = False
headers = []
for k, v in song.headers:
if k.upper() == key.upper():
replaced = True
if value is not None:
headers.append((key, value))
else:
headers.append((k, v))
if value is not None and not replaced:
headers.append((key, value))
song.headers = headers
txt_path.write_text(serialize_ultrastar(song), encoding="utf-8")
def _run(ctx: Ctx) -> None:
slug = song_slug(ctx.inp)
out = ctx.cfg.output_dir / slug
out.mkdir(parents=True, exist_ok=True)
txt = out / f"{slug}.txt"
shutil.copyfile(ctx.work / "song.txt", txt)
ffmpeg("-i", str(ctx.work / "audio.wav"), "-b:a", "320k",
str(out / f"{slug}.mp3"))
meta = json.loads((ctx.work / "meta.json").read_text())
src_video = ctx.work / meta["video_file"]
fmt, codec = ffprobe_codec(src_video)
if (fmt, codec) in GOOD_VIDEO:
shutil.copyfile(src_video, out / f"{slug}{src_video.suffix}")
else:
ffmpeg("-i", str(src_video), "-c:v", "libx264", "-crf", "20",
"-preset", "medium", "-c:a", "aac", str(out / f"{slug}.mp4"))
_set_header(txt, "VIDEO", f"{slug}.mp4")
cover_src = ctx.inp.cover_path
if cover_src is None and meta.get("thumbnail"):
cover_src = ctx.work / meta["thumbnail"]
cover_ok = False
if cover_src and Path(cover_src).exists():
try:
prepare_cover(Path(cover_src), out / f"{slug}.jpg")
cover_ok = True
except UnidentifiedImageError:
ctx.log("cover file is not a valid image; dropping #COVER header")
else:
ctx.log("no cover available; dropping #COVER header")
if not cover_ok:
_set_header(txt, "COVER", None)
(ctx.work / "package.done").write_text("")
def package_stage() -> Stage:
def outputs(ctx: Ctx):
return [ctx.work / "package.done"]
return Stage(name="package", outputs=outputs, run=_run)
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import numpy as np
from ..pipeline import Ctx, Stage
def track_pitch(vocals_path: Path, model: str, device: str):
import torchaudio
import torchcrepe
waveform, sr = torchaudio.load(str(vocals_path))
waveform = waveform.mean(0, keepdim=True)
waveform = torchaudio.functional.resample(waveform, sr, 16000)
f0, periodicity = torchcrepe.predict(
waveform, 16000, hop_length=160, model=model,
fmin=50.0, fmax=1100.0, device=device,
return_periodicity=True, batch_size=512)
f0 = f0[0].cpu().numpy()
conf = periodicity[0].cpu().numpy()
times = np.arange(len(f0)) * 0.01
return times, f0, conf
def _run(ctx: Ctx) -> None:
times, f0, conf = track_pitch(ctx.work / "vocals.wav",
ctx.cfg.crepe_model, ctx.cfg.device)
np.savez(ctx.work / "pitch.npz", times=times, f0=f0, conf=conf)
def pitch_stage() -> Stage:
return Stage(name="pitch",
outputs=lambda ctx: [ctx.work / "pitch.npz"],
run=_run)
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
import soundfile as sf
from ..pipeline import Ctx, Stage
def separate_audio(audio_path: Path, model: str, device: str):
from demucs.api import Separator
separator = Separator(model=model, device=device)
_, stems = separator.separate_audio_file(audio_path)
return stems
def _run(ctx: Ctx) -> None:
stems = separate_audio(ctx.work / "audio.wav",
ctx.cfg.demucs_model, ctx.cfg.device)
vocals = stems["vocals"]
others = [t for name, t in stems.items() if name != "vocals"]
instrumental = sum(others[1:], start=others[0])
sf.write(ctx.work / "vocals.wav", vocals.cpu().numpy().T, 44100)
sf.write(ctx.work / "instrumental.wav", instrumental.cpu().numpy().T, 44100)
def separate_stage() -> Stage:
return Stage(
name="separate",
outputs=lambda ctx: [ctx.work / "vocals.wav", ctx.work / "instrumental.wav"],
run=_run,
)
+43
View File
@@ -0,0 +1,43 @@
import difflib
import json
from ..lyrics import RefLyrics, alignment_token
from ..pipeline import Ctx, Stage
from ..whisper_client import TranscriptSegment, transcribe
def vocal_sections(segments, min_gap: float = 6.0, pad: float = 1.0):
sections = []
for s in sorted(segments, key=lambda s: s.start):
if sections and s.start - sections[-1][1] < min_gap:
sections[-1][1] = max(sections[-1][1], s.end)
else:
sections.append([s.start, s.end])
return [(max(0.0, a - pad), b + pad) for a, b in sections]
def _tokens(text: str) -> list[str]:
return [t for w in text.split() if (t := alignment_token(w))]
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()
def _run(ctx: Ctx) -> None:
segments = transcribe(ctx.cfg, ctx.work / "vocals.wav", ctx.inp.language)
(ctx.work / "transcript.json").write_text(json.dumps(
[{"start": s.start, "end": s.end, "text": s.text} for s in segments]))
def transcribe_stage() -> Stage:
return Stage(name="transcribe",
outputs=lambda ctx: [ctx.work / "transcript.json"],
run=_run)
def load_transcript(ctx: Ctx) -> list[TranscriptSegment]:
data = json.loads((ctx.work / "transcript.json").read_text())
return [TranscriptSegment(**d) for d in data]
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-LightItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Regular.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Italic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Medium.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-MediumItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-SemiBold.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-SemiBoldItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-Bold.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) ChakraPetch-BoldItalic.ttf: Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+851
View File
@@ -0,0 +1,851 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>karaoke-maker</title>
<style>
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-600.woff2") format("woff2");
font-weight: 600; font-display: swap;
}
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-700.woff2") format("woff2");
font-weight: 700; font-display: swap;
}
:root {
--bg: #0A0A0F;
--panel: #14101F;
--panel-raised: #1E1730;
--border: #2E2440;
--control: #756890; /* interactive field edges: clears 3:1 on every surface */
--text: #F0EDF7;
--text-muted: #9B92AD;
--purple: #A855F7;
--aqua: #22D3EE;
--warn: #FBBF24;
--fail: #FB5E7E;
--done: #C4A5F5;
--font-display: "Chakra Petch", system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
height: 100dvh;
overflow: hidden;
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas: "sidebar main" "now now";
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
}
:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
.eyebrow {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
}
.empty-note { margin: 0; font-size: 0.82rem; color: var(--text-muted); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- sidebar ------------------------------------------------------------ */
#sidebar {
grid-area: sidebar;
overflow-y: auto;
padding: 24px 0 28px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 22px;
}
.brand { padding: 0 18px; }
.wordmark {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
line-height: 0.98;
letter-spacing: 0.2em;
}
.wordmark span { display: block; }
.wordmark .second { color: var(--purple); }
.kana {
margin-top: 9px;
font-size: 0.72rem;
letter-spacing: 0.4em;
color: var(--purple);
}
#sidebar .eyebrow, #jobs .empty-note { padding: 0 18px; }
#jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
.job {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
gap: 1px 10px;
width: 100%;
padding: 10px 16px 11px 15px;
border: 0;
border-left: 3px solid var(--border);
border-radius: 0;
background: var(--panel);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 120ms ease;
}
/* inset so the sidebar's scroll container cannot clip the left edge */
.job:focus-visible { outline-offset: -2px; }
.job:hover { background: var(--panel-raised); }
.job[aria-current="true"] { background: var(--panel-raised); }
.job[aria-current="true"]::after {
content: "";
position: absolute;
inset: 0 0 0 auto;
width: 2px;
background: var(--purple);
}
.job-title {
grid-area: 1 / 1;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.01em;
}
.job-artist { grid-area: 2 / 1; font-size: 0.78rem; color: var(--text-muted); }
.job-title, .job-artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-state {
grid-area: 3 / 1;
margin-top: 4px;
display: flex;
align-items: baseline;
gap: 7px;
font-size: 0.72rem;
color: var(--text-muted);
}
.job-glyph { font-size: 0.8rem; }
.job-elapsed {
grid-area: 3 / 2;
align-self: end;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
}
.st-queued { border-left-color: var(--control); }
.st-queued .job-glyph { color: var(--text-muted); }
.st-running { border-left-color: var(--aqua); box-shadow: inset 7px 0 14px -7px var(--aqua); }
.st-running .job-glyph, .st-running .job-state { color: var(--aqua); }
.st-done { border-left-color: var(--done); }
.st-done .job-glyph { color: var(--done); }
.st-failed { border-left-color: var(--fail); }
.st-failed .job-glyph, .st-failed .job-state { color: var(--fail); }
/* --- main --------------------------------------------------------------- */
#main { grid-area: main; overflow-y: auto; padding: 30px 34px 36px; }
#main > section { max-width: 760px; margin-inline: auto; }
#main h2 {
margin: 0 0 18px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#song-form { display: grid; gap: 18px; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
input, textarea {
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--text);
background: transparent;
border: 0;
border-bottom: 1px solid var(--control);
border-radius: 0;
padding: 7px 2px;
transition: border-color 120ms ease;
}
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);
border: 1px solid var(--control);
resize: vertical;
}
textarea:hover { border-color: var(--text-muted); }
textarea:focus { border-color: var(--aqua); }
input[type="file"] {
font-size: 0.82rem;
color: var(--text-muted);
padding: 10px;
background: var(--panel);
border: 1px dashed var(--control);
}
input[type="file"]:hover { border-color: var(--text-muted); }
.field.dropping input[type="file"] {
color: var(--text);
background: var(--panel-raised);
border-color: var(--aqua);
}
input[type="file"]::file-selector-button {
margin-right: 11px;
padding: 6px 11px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: var(--panel-raised);
border: 1px solid var(--control);
border-radius: 2px;
cursor: pointer;
}
details { border-top: 1px solid var(--border); padding-top: 15px; }
summary {
display: flex;
align-items: center;
gap: 9px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
cursor: pointer;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
/* the second `content` keeps the marker out of the accessible name; browsers
without alt-text support drop it and fall back to the plain marker */
summary::before {
content: "+";
content: "+" / "";
font-family: var(--font-mono);
color: var(--aqua);
}
details[open] > summary::before {
content: "\2212";
content: "\2212" / "";
}
summary:hover { color: var(--text); }
details > .stack { display: grid; gap: 18px; margin-top: 18px; }
button { cursor: pointer; }
.primary {
justify-self: start;
padding: 12px 24px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #12061F;
background: var(--purple);
border: 0;
border-radius: 2px;
transition: background 120ms ease;
}
.primary:hover { background: #BE7BF9; }
#form-error { margin: 12px 0 0; min-height: 1.2em; font-size: 0.85rem; color: var(--fail); }
#review { margin-top: 38px; }
#review h3 {
margin: -8px 0 16px;
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
}
#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;
background: var(--panel);
border: 1px solid 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); }
.output-path {
display: flex;
align-items: center;
gap: 12px;
margin: 14px 0 0;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
}
.output-path code { flex: 1; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 0.82rem; }
#copy-path {
flex: none;
padding: 5px 12px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: var(--panel-raised);
border: 1px solid var(--control);
border-radius: 2px;
}
#copy-path:hover { border-color: var(--text-muted); }
#detail {
margin: 13px 0 0;
padding: 14px;
max-height: 20rem;
overflow-y: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.65;
color: var(--text-muted);
background: var(--panel);
border: 1px solid var(--border);
}
/* --- now bar ------------------------------------------------------------ */
#now-bar {
grid-area: now;
display: flex;
align-items: center;
gap: 18px;
padding: 13px 20px;
background: var(--panel);
border-top: 1px solid var(--border);
}
#now-label {
flex: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.62rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#now-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#now-bar.idle #now-title { color: var(--text-muted); font-weight: 400; font-family: var(--font-body); font-size: 0.82rem; }
#stage-track { display: flex; align-items: center; gap: 15px; margin-left: auto; }
.stage { display: flex; align-items: baseline; gap: 6px; color: var(--text-muted); }
.stage-dot { font-size: 0.72rem; }
.stage-label {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stage-done { color: var(--done); }
.stage-active { color: var(--aqua); }
@keyframes stage-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
.stage-active .stage-dot { color: var(--aqua); animation: stage-pulse 1.6s ease-in-out infinite; }
.stage-counter {
padding-left: 3px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-muted);
}
@media (max-width: 1180px) {
#stage-track { gap: 11px; }
/* clipped rather than display:none so the names stay in the accessibility tree */
.stage:not(.stage-active) .stage-label { position: absolute; clip-path: inset(50%); }
}
/* --- responsive --------------------------------------------------------- */
@media (max-width: 900px) {
body {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas: "sidebar" "main" "now";
}
#sidebar {
max-height: 42vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
#main { padding: 24px 18px 28px; }
.pair { grid-template-columns: 1fr; }
#now-bar { flex-wrap: wrap; gap: 8px 16px; }
#stage-track { margin-left: 0; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<aside id="sidebar">
<div class="brand">
<h1 class="wordmark"><span>KARAOKE</span> <span class="second">MAKER</span></h1>
<div class="kana" aria-hidden="true">カラオケ</div>
</div>
<div>
<h2 class="eyebrow">QUEUE</h2>
<div id="jobs"></div>
</div>
</aside>
<main id="main">
<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">
<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>
<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>
</div>
</details>
<button type="submit" class="primary">CREATE SONG</button>
</form>
<p id="form-error" role="alert"></p>
</section>
<section id="review">
<h2>REVIEW</h2>
<h3 id="review-title"></h3>
<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="output-path"></div>
<details>
<summary>Log</summary>
<pre id="detail">no songs queued yet — nothing to log</pre>
</details>
</section>
</main>
<footer id="now-bar" class="idle">
<span id="now-label">IDLE</span>
<span id="now-title">nothing in progress</span>
<div id="stage-track"></div>
</footer>
<script>
const form = document.getElementById("song-form");
const formError = document.getElementById("form-error");
const jobsDiv = document.getElementById("jobs");
const detail = document.getElementById("detail");
const reviewTitle = document.getElementById("review-title");
const reviewError = document.getElementById("review-error");
const outputPathDiv = document.getElementById("output-path");
const nowBar = document.getElementById("now-bar");
const nowLabel = document.getElementById("now-label");
const nowTitle = document.getElementById("now-title");
const stageTrack = document.getElementById("stage-track");
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" },
done: { glyph: "●", cls: "st-done" },
failed: { glyph: "✕", cls: "st-failed" },
};
function elapsed(job) {
if (!job.started_at) return "";
const end = job.finished_at ?? Date.now() / 1000;
const secs = Math.max(0, Math.round(end - job.started_at));
const m = Math.floor(secs / 60), s = secs % 60;
return m ? `${m}m${String(s).padStart(2, "0")}s` : `${s}s`;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
formError.textContent = "";
const data = new FormData(form);
for (const [name, value] of [...data.entries()]) {
const isFile = value instanceof File;
if (isFile && value.size === 0) data.delete(name);
else if (!isFile && value === "") data.delete(name);
}
const resp = await fetch("/api/jobs", { method: "POST", body: data });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return; // before the reset below, so a rejected submission is never retyped
}
form.reset();
refreshJobs();
});
for (const input of form.querySelectorAll('input[type="file"]')) {
const zone = input.closest(".field");
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dropping");
});
zone.addEventListener("dragleave", (e) => {
if (!zone.contains(e.relatedTarget)) zone.classList.remove("dropping");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dropping");
const [file] = e.dataTransfer.files;
if (!file) return;
// a single-file input needs a one-item FileList, which only DataTransfer can build
const picked = new DataTransfer();
picked.items.add(file);
input.files = picked.files;
});
}
function jobCard(job) {
const card = document.createElement("button");
card.type = "button";
card.dataset.id = job.id;
const title = document.createElement("span");
title.className = "job-title";
const artist = document.createElement("span");
artist.className = "job-artist";
const glyph = document.createElement("span");
glyph.className = "job-glyph";
glyph.setAttribute("aria-hidden", "true");
const status = document.createElement("span");
const stateLine = document.createElement("span");
stateLine.className = "job-state";
stateLine.append(glyph, status);
const time = document.createElement("span");
time.className = "job-elapsed";
card.append(title, artist, stateLine, time);
card.addEventListener("click", () => selectJob(job.id));
paintCard(card, job);
return card;
}
function paintCard(card, job) {
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
const [title, artist, stateLine, time] = card.children;
card.className = `job ${state.cls}`;
if (job.id === selectedId) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
title.textContent = job.title;
artist.textContent = job.artist;
stateLine.children[0].textContent = state.glyph;
stateLine.children[1].textContent = job.stage ? `${job.status} · ${job.stage}` : job.status;
time.textContent = elapsed(job);
}
// patched in place rather than replaced wholesale: detaching a card would blur
// it, so a keyboard user would lose the queue on every poll
function renderJobs(jobs) {
if (!jobs.length) {
jobsDiv.replaceChildren(emptyNote("no songs yet — create one to get started"));
return;
}
const existing = new Map(
[...jobsDiv.querySelectorAll(".job")].map(card => [card.dataset.id, card]));
jobs.forEach((job, i) => {
let card = existing.get(job.id);
if (card) { existing.delete(job.id); paintCard(card, job); }
else card = jobCard(job);
if (jobsDiv.children[i] !== card) jobsDiv.insertBefore(card, jobsDiv.children[i] ?? null);
});
for (const stale of existing.values()) stale.remove();
while (jobsDiv.children.length > jobs.length) jobsDiv.lastElementChild.remove();
}
async function loadStages() {
const resp = await fetch("/api/stages");
STAGES = (await resp.json()).stages;
}
function renderStageTrack(job) {
if (!STAGES.length || !job || job.status === "queued") {
stageTrack.replaceChildren();
return;
}
const idx = job.stage ? STAGES.indexOf(job.stage) : -1;
const dots = STAGES.map((name, i) => {
const done = job.status === "done" || i < idx;
const active = !done && i === idx;
const dot = document.createElement("span");
dot.className = "stage " + (done ? "stage-done" : active ? "stage-active" : "stage-pending");
if (active) dot.setAttribute("aria-current", "step");
const mark = document.createElement("span");
mark.className = "stage-dot";
mark.setAttribute("aria-hidden", "true");
mark.textContent = done ? "●" : active ? "◈" : "○";
const label = document.createElement("span");
label.className = "stage-label";
label.textContent = name;
// colour and glyph alone must never carry state
const word = document.createElement("span");
word.className = "sr-only";
word.textContent = done ? " done" : active ? " running" : " pending";
dot.append(mark, label, word);
return dot;
});
const counter = document.createElement("span");
counter.className = "stage-counter";
// an unrecognised stage name gives idx === -1, which reads as 0 done
counter.textContent = job.status === "done"
? `${STAGES.length}/${STAGES.length}`
: `${Math.max(0, idx + 1)}/${STAGES.length}`;
stageTrack.replaceChildren(...dots, counter);
}
function renderNowBar(jobs) {
const running = jobs.find(j => j.status === "running");
nowBar.classList.toggle("idle", !running);
nowLabel.textContent = running ? "NOW RUNNING" : "IDLE";
nowTitle.textContent = running ? `${running.title} · ${running.artist}` : "nothing in progress";
renderStageTrack(running);
}
function schedulePoll(jobs) {
const busy = jobs.some(j => j.status === "queued" || j.status === "running");
if (busy && pollTimer === null) {
pollTimer = setInterval(refreshJobs, 3000);
} else if (!busy && pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshJobs() {
const resp = await fetch("/api/jobs");
const jobs = await resp.json();
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
renderJobs(jobs);
renderNowBar(jobs);
if (selectedId) refreshDetail();
schedulePoll(jobs);
}
async function selectJob(id) {
selectedId = id;
for (const card of jobsDiv.querySelectorAll(".job")) {
if (card.dataset.id === id) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
}
refreshDetail();
}
function emptyNote(text) {
const p = document.createElement("p");
p.className = "empty-note";
p.textContent = text;
return p;
}
function renderWarnings(job) {
const box = document.getElementById("warnings");
if (job.status === "failed") {
box.replaceChildren(emptyNote("job failed — nothing to review"));
return;
}
if (job.status !== "done") {
box.replaceChildren(emptyNote("waiting for this song to finish…"));
return;
}
if (!job.warnings.length) {
box.replaceChildren(emptyNote("no low-confidence regions — nothing to review"));
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);
}
async function copyPath(path, button) {
let ok = false;
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(path);
ok = true;
} catch {}
}
// navigator.clipboard is undefined outside secure contexts, which is the
// normal case for this intranet tool served over plain http
if (!ok) {
const ta = document.createElement("textarea");
ta.value = path;
ta.style.position = "fixed";
ta.style.left = "-1000px";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { ok = document.execCommand("copy"); } catch { ok = false; }
document.body.removeChild(ta);
}
if (!ok) {
const code = outputPathDiv.querySelector("code");
if (code) {
const range = document.createRange();
range.selectNodeContents(code);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
button.textContent = ok ? "copied" : "copy failed";
setTimeout(() => { button.textContent = "copy"; }, 1500);
}
function renderOutputPath(job) {
const path = job.status === "done" ? job.output_dir ?? null : null;
const shown = outputPathDiv.querySelector("code")?.textContent ?? null;
// rebuilding an unchanged panel would detach a focused #copy-path on every poll
if (path === shown) return;
if (path === null) {
outputPathDiv.replaceChildren();
return;
}
const wrap = document.createElement("div");
wrap.className = "output-path";
const code = document.createElement("code");
code.textContent = path;
const button = document.createElement("button");
button.id = "copy-path";
button.type = "button";
button.textContent = "copy";
button.addEventListener("click", () => copyPath(path, button));
wrap.append(code, button);
outputPathDiv.replaceChildren(wrap);
}
async function refreshDetail() {
const id = selectedId;
const resp = await fetch(`/api/jobs/${id}`);
if (!resp.ok) return;
const job = await resp.json();
if (id !== selectedId) return;
const artist = document.createElement("span");
artist.textContent = job.artist;
reviewTitle.replaceChildren(`${job.title} `, artist);
reviewError.hidden = !job.error;
reviewError.textContent = job.error ?? "";
renderWarnings(job);
renderOutputPath(job);
detail.textContent = job.log.join("\n");
}
loadStages().catch(() => {}).then(refreshJobs);
</script>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
import re
from dataclasses import dataclass
NOTE_RE = re.compile(r"^([:*FRG]) (-?\d+) (\d+) (-?\d+) (.*)$")
@dataclass
class Note:
kind: str
beat: int
length: int
pitch: int
text: str
@dataclass
class LineBreak:
beat: int
@dataclass
class UltraStarSong:
headers: list[tuple[str, str]]
body: list[Note | LineBreak]
def parse_ultrastar(text: str) -> UltraStarSong:
headers, body = [], []
for line in text.splitlines():
line = line.rstrip("\r")
if not line.strip():
continue
if line.startswith("#"):
key, _, value = line[1:].partition(":")
headers.append((key, value))
elif line.startswith("E"):
break
elif line.startswith("-"):
nums = line[1:].split()
body.append(LineBreak(beat=int(nums[0])))
else:
m = NOTE_RE.match(line)
if m:
body.append(Note(m[1], int(m[2]), int(m[3]), int(m[4]), m[5]))
return UltraStarSong(headers, body)
def serialize_ultrastar(song: UltraStarSong) -> str:
out = [f"#{k}:{v}" for k, v in song.headers]
for item in song.body:
if isinstance(item, LineBreak):
out.append(f"- {item.beat}")
else:
out.append(f"{item.kind} {item.beat} {item.length} {item.pitch} {item.text}")
out.append("E")
return "\n".join(out) + "\n"
def get_header(song: UltraStarSong, key: str) -> str | None:
for k, v in song.headers:
if k.upper() == key.upper():
return v
return None
def format_bpm(bpm: float) -> str:
if bpm == int(bpm):
return str(int(bpm))
return f"{bpm:.2f}".replace(".", ",")
def parse_bpm(value: str) -> float:
return float(value.replace(",", "."))
+164
View File
@@ -0,0 +1,164 @@
import math
import queue
import re
import shutil
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .config import Config
from .pipeline import STAGE_ORDER, PipelineError, SongInput, run_pipeline
STATIC = Path(__file__).parent / "static"
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
@dataclass
class Job:
id: str
title: str
artist: str
status: str = "queued"
stage: str | None = None
log: list[str] = field(default_factory=list)
output_dir: str | None = None
warnings: list[str] = field(default_factory=list)
error: str | None = None
inp: SongInput | None = None
created_at: float = field(default_factory=time.time)
started_at: float | None = None
finished_at: float | None = None
def _safe_filename(name: str | None, default: str) -> str:
safe = Path(name).name if name else ""
return safe if safe not in ("", ".", "..") else default
async def _save_upload(upload: UploadFile, dest: Path) -> None:
with dest.open("wb") as f:
await run_in_threadpool(shutil.copyfileobj, upload.file, f)
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=STATIC), name="static")
jobs: dict[str, Job] = {}
order: list[str] = []
lock = threading.Lock()
todo: queue.Queue[str] = queue.Queue()
def worker():
while True:
job_id = todo.get()
with lock:
job = jobs[job_id]
job.status = "running"
job.started_at = time.time()
def log(msg: str, job=job):
with lock:
job.log.append(msg)
if m := STAGE_LINE.match(msg):
job.stage = m[1]
try:
result = runner(job.inp, cfg, log=log)
with lock:
job.status = "done"
job.output_dir = str(result.output_dir)
job.warnings = list(result.warnings)
except BaseException as e:
with lock:
job.status = "failed"
job.error = str(e)
finally:
with lock:
job.finished_at = time.time()
# safe: acquire copies the upload into the per-song work dir
# and package reads cover_path directly, both inside runner()
# above, before this cleanup runs.
shutil.rmtree(cfg.work_dir / "uploads" / job_id, ignore_errors=True)
threading.Thread(target=worker, daemon=True).start()
@app.get("/")
def index():
return FileResponse(STATIC / "index.html")
@app.post("/api/jobs", status_code=201)
async def submit(title: str = Form(...), artist: str = Form(...),
lyrics: str = Form(...), youtube_url: str | None = Form(None),
genre: str | None = Form(None), year: int | None = Form(None),
language: str | None = Form(None), bpm: float | None = Form(None),
video: UploadFile | None = File(None),
cover: UploadFile | None = File(None)):
has_video = bool(video and video.filename)
if bool(youtube_url) == has_video:
raise HTTPException(400, "provide exactly one of youtube_url / video")
if bpm is not None and (not math.isfinite(bpm) or bpm <= 0):
raise HTTPException(400, f"bpm must be a finite value > 0, got {bpm!r}")
job_id = uuid.uuid4().hex
updir = cfg.work_dir / "uploads" / job_id
video_path = cover_path = None
if has_video:
updir.mkdir(parents=True, exist_ok=True)
video_path = updir / _safe_filename(video.filename, "video")
await _save_upload(video, video_path)
if cover and cover.filename:
updir.mkdir(parents=True, exist_ok=True)
cover_path = updir / _safe_filename(cover.filename, "cover")
await _save_upload(cover, cover_path)
inp = SongInput(title=title, artist=artist, lyrics_text=lyrics,
youtube_url=youtube_url, video_path=video_path,
genre=genre, year=year, language=language,
cover_path=cover_path, bpm_override=bpm)
job = Job(id=job_id, title=title, artist=artist, inp=inp)
with lock:
jobs[job_id] = job
order.append(job_id)
todo.put(job_id)
return {"id": job_id}
@app.get("/api/stages")
def list_stages():
return {"stages": list(STAGE_ORDER)}
@app.get("/api/jobs")
def list_jobs():
with lock:
return [{"id": j.id, "title": j.title, "artist": j.artist,
"status": j.status, "stage": j.stage,
"created_at": j.created_at, "started_at": j.started_at,
"finished_at": j.finished_at}
for j in (jobs[i] for i in reversed(order))]
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
with lock:
job = jobs.get(job_id)
if job is None:
raise HTTPException(404)
return {"id": job.id, "title": job.title, "artist": job.artist,
"status": job.status, "stage": job.stage,
"log": job.log[-100:], "output_dir": job.output_dir,
"warnings": list(job.warnings), "error": job.error,
"created_at": job.created_at, "started_at": job.started_at,
"finished_at": job.finished_at}
return app
def serve(cfg: Config) -> int:
import uvicorn
uvicorn.run(create_app(cfg), host=cfg.bind_host, port=cfg.port)
return 0
+39
View File
@@ -0,0 +1,39 @@
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import Config
from .languages import LANGUAGES
@dataclass
class TranscriptSegment:
start: float
end: float
text: str
def transcribe(cfg: Config, audio_path: Path, language: str | None = None,
client: httpx.Client | None = None) -> list[TranscriptSegment]:
headers = {}
if cfg.whisper_api_key:
headers["Authorization"] = f"Bearer {cfg.whisper_api_key}"
data = {"model": cfg.whisper_model, "response_format": "verbose_json"}
if language:
entry = LANGUAGES.get(language.lower())
data["language"] = entry.iso if entry else language
own = client is None
client = client or httpx.Client(timeout=600)
try:
with audio_path.open("rb") as f:
resp = client.post(
f"{cfg.whisper_url.rstrip('/')}/audio/transcriptions",
headers=headers, data=data,
files={"file": (audio_path.name, f, "audio/wav")})
resp.raise_for_status()
return [TranscriptSegment(s["start"], s["end"], s["text"].strip())
for s in resp.json().get("segments", [])]
finally:
if own:
client.close()
+79
View File
@@ -0,0 +1,79 @@
import json
from pathlib import Path
from karaoke_maker.config import Config
from karaoke_maker.pipeline import Ctx, SongInput
from karaoke_maker.stages import acquire
def make_ctx(tmp_path, **kw):
inp = SongInput(title="T", artist="A", lyrics_text="x", **kw)
cfg = Config(whisper_url="http://x/v1", work_dir=tmp_path)
work = tmp_path / "A - T"
work.mkdir()
return Ctx(inp=inp, cfg=cfg, work=work, log=lambda m: None)
def test_local_video(tmp_path, monkeypatch):
src = tmp_path / "in.mp4"
src.write_bytes(b"fake")
calls = []
monkeypatch.setattr(acquire, "ffmpeg", lambda *a: calls.append(a))
ctx = make_ctx(tmp_path, video_path=src)
acquire.acquire_stage().run(ctx)
meta = json.loads((ctx.work / "meta.json").read_text())
assert meta["video_file"] == "source_video.mp4"
assert (ctx.work / "source_video.mp4").read_bytes() == b"fake"
assert any("audio.wav" in str(a[-1]) for a in calls)
def test_youtube(tmp_path, monkeypatch):
def fake_dl(url, dest):
video = dest / "source_video.webm"
video.write_bytes(b"v")
thumb = dest / "source_video.jpg"
thumb.write_bytes(b"t")
return video, thumb
monkeypatch.setattr(acquire, "download_youtube", fake_dl)
monkeypatch.setattr(acquire, "ffmpeg", lambda *a: None)
ctx = make_ctx(tmp_path, youtube_url="https://yt/x")
acquire.acquire_stage().run(ctx)
meta = json.loads((ctx.work / "meta.json").read_text())
assert meta["video_file"] == "source_video.webm"
assert meta["thumbnail"] == "source_video.jpg"
def test_requires_exactly_one_source(tmp_path):
import pytest
from karaoke_maker.pipeline import PipelineError
ctx = make_ctx(tmp_path)
with pytest.raises(PipelineError):
acquire.acquire_stage().run(ctx)
def test_classify_ordinary_webm_and_jpg(tmp_path):
(tmp_path / "source_video.webm").write_bytes(b"v")
(tmp_path / "source_video.jpg").write_bytes(b"t")
video, thumb = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.webm"
assert thumb.name == "source_video.jpg"
def test_classify_unexpected_thumbnail_extension(tmp_path):
(tmp_path / "source_video.webm").write_bytes(b"v")
(tmp_path / "source_video.avif").write_bytes(b"t")
video, thumb = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.webm"
assert thumb.name == "source_video.avif"
def test_classify_no_thumbnail(tmp_path):
(tmp_path / "source_video.mp4").write_bytes(b"v")
video, thumb = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.mp4"
assert thumb is None
def test_classify_no_video_raises(tmp_path):
import pytest
from karaoke_maker.pipeline import PipelineError
(tmp_path / "source_video.jpg").write_bytes(b"t")
with pytest.raises(PipelineError):
acquire._classify_downloads(tmp_path)
def test_classify_picks_largest_video_when_multiple(tmp_path):
(tmp_path / "source_video.f137.mp4").write_bytes(b"small")
(tmp_path / "source_video.mp4").write_bytes(b"much larger contents here")
video, _ = acquire._classify_downloads(tmp_path)
assert video.name == "source_video.mp4"
+139
View File
@@ -0,0 +1,139 @@
import json
import pytest
from karaoke_maker.config import Config
from karaoke_maker.lyrics import normalize_lyrics
from karaoke_maker.pipeline import Ctx, PipelineError, SongInput
from karaoke_maker.stages import align
LYRICS = "it's one two three\nfour five six seven eight"
def make_ctx(tmp_path, lyrics=LYRICS, transcript=None):
work = tmp_path / "w"
work.mkdir(exist_ok=True)
if transcript is None:
transcript = [{"start": 1.0, "end": 4.0,
"text": "it's one two three four five six seven eight"}]
(work / "transcript.json").write_text(json.dumps(transcript))
inp = SongInput(title="T", artist="A", lyrics_text=lyrics)
return Ctx(inp=inp, cfg=Config(whisper_url="u", device="cpu"),
work=work, log=lambda m: None)
def fake_align(vocals_path, start, end, tokens, device):
step = (end - start) / len(tokens)
return [(start + i * step, start + (i + 1) * step, 0.9)
for i in range(len(tokens))]
def run_align(tmp_path, monkeypatch, lyrics=LYRICS, transcript=None):
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
return json.loads((ctx.work / "alignment.json").read_text())
def check_invariants(rows, lyrics):
ref = normalize_lyrics(lyrics)
assert [r["word"] for r in rows] == ref.words
assert [r["line_index"] for r in rows] == ref.word_line_index
prev = 0.0
for r in rows:
assert r["start"] is not None and r["end"] is not None
assert prev <= r["start"] <= r["end"]
prev = r["end"]
def test_alignment_covers_all_words(tmp_path, monkeypatch):
rows = run_align(tmp_path, monkeypatch)
assert [r["word"] for r in rows] == \
["it's", "one", "two", "three", "four", "five", "six", "seven", "eight"]
assert rows[0]["line_index"] == 0 and rows[-1]["line_index"] == 1
starts = [r["start"] for r in rows]
assert starts == sorted(starts)
def test_mismatched_lyrics_fail(tmp_path, monkeypatch):
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics="totally unrelated nonsense words banana")
with pytest.raises(PipelineError, match="similarity"):
align.align_stage().run(ctx)
def test_unalignable_word_interpolated(tmp_path, monkeypatch):
lyrics = "it's one ... three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
dots = next(r for r in rows if r["word"] == "...")
assert dots["score"] == 0.0
prev = next(r for r in rows if r["word"] == "one")
nxt = next(r for r in rows if r["word"] == "three")
assert prev["end"] <= dots["start"] <= dots["end"] <= nxt["start"] + 1e-6
check_invariants(rows, lyrics)
def test_leading_unalignable_word(tmp_path, monkeypatch):
lyrics = "... one two three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
assert rows[0]["word"] == "..." and rows[0]["score"] == 0.0
assert rows[0]["start"] >= 0.0
assert rows[0]["end"] <= rows[1]["start"] + 1e-6
check_invariants(rows, lyrics)
def test_trailing_unalignable_word(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven ..."
rows = run_align(tmp_path, monkeypatch, lyrics)
assert rows[-1]["word"] == "..." and rows[-1]["score"] == 0.0
assert rows[-2]["end"] <= rows[-1]["start"]
check_invariants(rows, lyrics)
def test_consecutive_unalignable_words(tmp_path, monkeypatch):
lyrics = "it's one ... ... three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
dots = [r for r in rows if r["word"] == "..."]
assert len(dots) == 2
assert all(r["score"] == 0.0 for r in dots)
assert dots[0]["end"] <= dots[1]["start"]
check_invariants(rows, lyrics)
def test_all_words_unalignable_in_section(tmp_path, monkeypatch):
lyrics = "... ...\n... ..."
transcript = [{"start": 1.0, "end": 4.0, "text": "..."}]
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
rows = json.loads((ctx.work / "alignment.json").read_text())
check_invariants(rows, lyrics)
assert all(r["score"] == 0.0 for r in rows)
assert rows[-1]["end"] <= 5.0
def test_non_chronological_transcript(tmp_path, monkeypatch):
transcript = [{"start": 30.0, "end": 33.0, "text": "four five six seven eight"},
{"start": 1.0, "end": 4.0, "text": "it's one two three"}]
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
check_invariants(rows, LYRICS)
assert len({r["start"] for r in rows}) == len(rows)
assert rows[3]["end"] <= 5.0
assert rows[4]["start"] >= 29.0
def test_inverted_section_does_not_collapse(tmp_path, monkeypatch):
monkeypatch.setattr(align, "vocal_sections", lambda segments: [(29.0, 5.0)])
rows = run_align(tmp_path, monkeypatch)
check_invariants(rows, LYRICS)
assert len({r["start"] for r in rows}) == len(rows)
assert rows[0]["start"] >= 5.0 and rows[-1]["end"] <= 29.0
def test_words_split_across_sections(tmp_path, monkeypatch):
transcript = [{"start": 1.0, "end": 4.0, "text": "it's one two three"},
{"start": 30.0, "end": 33.0, "text": "four five six seven eight"}]
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
check_invariants(rows, LYRICS)
assert rows[3]["end"] <= 5.0
assert rows[4]["start"] >= 29.0
+116
View File
@@ -0,0 +1,116 @@
import os
import pytest
import karaoke_maker.cli as cli
from karaoke_maker.config import Config
from karaoke_maker.pipeline import PipelineError, PipelineResult
def setup_cfg(monkeypatch, tmp_path):
monkeypatch.setattr(cli, "load_config",
lambda path=None: Config(whisper_url="u"))
def test_make_success(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("la la")
captured = {}
def fake_run(inp, cfg, log=print, force_from=None, stages=None):
captured["inp"] = inp
captured["force_from"] = force_from
return PipelineResult(output_dir=tmp_path / "out", warnings=["w1"])
monkeypatch.setattr(cli, "run_pipeline", fake_run)
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y",
"--force-from", "align"])
assert rc == 0
assert captured["inp"].lyrics_text == "la la"
assert captured["force_from"] == "align"
out = capsys.readouterr().out
assert "w1" in out and str(tmp_path / "out") in out
def test_make_pipeline_error(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("x")
def boom(*a, **kw):
raise PipelineError("lyrics don't match")
monkeypatch.setattr(cli, "run_pipeline", boom)
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y"])
assert rc == 1
assert "lyrics don't match" in capsys.readouterr().err
def test_make_requires_one_source(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("x")
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics)])
assert rc != 0
def test_make_rejects_zero_bpm(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("x")
try:
cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y",
"--bpm", "0"])
assert False, "expected SystemExit"
except SystemExit as e:
assert e.code != 0
assert "--bpm" in capsys.readouterr().err
def test_make_rejects_negative_bpm(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("x")
try:
cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y",
"--bpm", "-120"])
assert False, "expected SystemExit"
except SystemExit as e:
assert e.code != 0
assert "--bpm" in capsys.readouterr().err
def test_make_reports_missing_lyrics_file(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
def boom(*a, **kw):
raise AssertionError("run_pipeline must not run with an unreadable lyrics file")
monkeypatch.setattr(cli, "run_pipeline", boom)
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(tmp_path / "missing.txt"), "--youtube", "http://y"])
assert rc != 0
assert "--lyrics" in capsys.readouterr().err
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file permissions")
def test_make_reports_unreadable_lyrics_file(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_text("x")
lyrics.chmod(0o000)
def boom(*a, **kw):
raise AssertionError("run_pipeline must not run with an unreadable lyrics file")
monkeypatch.setattr(cli, "run_pipeline", boom)
try:
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y"])
finally:
lyrics.chmod(0o644)
assert rc != 0
assert "--lyrics" in capsys.readouterr().err
def test_make_reports_undecodable_lyrics_file(tmp_path, monkeypatch, capsys):
setup_cfg(monkeypatch, tmp_path)
lyrics = tmp_path / "l.txt"
lyrics.write_bytes(b"\xff\xfe\x00bad")
def boom(*a, **kw):
raise AssertionError("run_pipeline must not run with undecodable lyrics")
monkeypatch.setattr(cli, "run_pipeline", boom)
rc = cli.main(["make", "--title", "T", "--artist", "A",
"--lyrics", str(lyrics), "--youtube", "http://y"])
assert rc != 0
assert "--lyrics" in capsys.readouterr().err
+40
View File
@@ -0,0 +1,40 @@
import pytest
from pathlib import Path
from karaoke_maker.config import Config, load_config
def test_load_minimal(tmp_path):
cfg_file = tmp_path / "config.toml"
cfg_file.write_text('whisper_url = "http://w:9000/v1"\n')
cfg = load_config(cfg_file)
assert cfg.whisper_url == "http://w:9000/v1"
assert cfg.demucs_model == "htdemucs_ft"
def test_paths_and_overrides(tmp_path):
cfg_file = tmp_path / "config.toml"
cfg_file.write_text(
'whisper_url = "http://w/v1"\noutput_dir = "/srv/songs"\nport = 9999\n'
)
cfg = load_config(cfg_file)
assert cfg.output_dir == Path("/srv/songs")
assert cfg.port == 9999
def test_env_var(tmp_path, monkeypatch):
cfg_file = tmp_path / "c.toml"
cfg_file.write_text('whisper_url = "http://env/v1"\n')
monkeypatch.setenv("KARAOKE_MAKER_CONFIG", str(cfg_file))
assert load_config().whisper_url == "http://env/v1"
def test_unknown_key_rejected(tmp_path):
cfg_file = tmp_path / "c.toml"
cfg_file.write_text('whisper_url = "x"\nwhipser_model = "typo"\n')
with pytest.raises(ValueError, match="whipser_model"):
load_config(cfg_file)
def test_missing_file(tmp_path):
with pytest.raises(FileNotFoundError):
load_config(tmp_path / "nope.toml")
+30
View File
@@ -0,0 +1,30 @@
import os
import shutil
import pytest
requires_full = pytest.mark.skipif(
not os.environ.get("KARAOKE_E2E"),
reason="set KARAOKE_E2E=1 (needs GPU, ffmpeg, whisper server, network)")
@requires_full
def test_full_pipeline(tmp_path):
from karaoke_maker.config import load_config
from karaoke_maker.pipeline import SongInput, run_pipeline
assert shutil.which("ffmpeg"), "ffmpeg required"
cfg = load_config()
cfg.output_dir = tmp_path / "out"
cfg.work_dir = tmp_path / "work"
inp = SongInput(
title=os.environ["KARAOKE_E2E_TITLE"],
artist=os.environ["KARAOKE_E2E_ARTIST"],
lyrics_text=open(os.environ["KARAOKE_E2E_LYRICS"]).read(),
youtube_url=os.environ.get("KARAOKE_E2E_URL"),
)
result = run_pipeline(inp, cfg)
txts = list(result.output_dir.glob("*.txt"))
assert len(txts) == 1
from karaoke_maker.ultrastar import get_header, parse_ultrastar
song = parse_ultrastar(txts[0].read_text(encoding="utf-8"))
assert get_header(song, "BPM") and get_header(song, "GAP")
assert len(song.body) > 20
+259
View File
@@ -0,0 +1,259 @@
import json
import numpy as np
import pytest
from karaoke_maker.config import Config
from karaoke_maker.grid import scale_bpm, to_ultrastar
from karaoke_maker.pipeline import Ctx, PipelineError, SongInput
from karaoke_maker.stages import all_stages, grid as grid_stage_mod
from karaoke_maker.ultrastar import (
LineBreak, Note, get_header, parse_ultrastar,
)
def note(start, duration, pitch=0, text="la"):
return {"start": start, "duration": duration, "pitch": pitch, "text": text,
"low_confidence": False}
def assert_ordered(body):
"""Beats never go backwards and no two notes share a beat."""
cursor = None
for item in body:
if cursor is not None:
assert item.beat >= cursor
cursor = item.beat + (0 if isinstance(item, LineBreak) else item.length)
notes = [i for i in body if isinstance(i, Note)]
for a, b in zip(notes, notes[1:]):
assert a.beat + a.length <= b.beat
assert all(n.length >= 1 for n in notes)
def test_scale_bpm():
assert scale_bpm(128.0) == 256.0
assert scale_bpm(64.0) == 256.0
assert scale_bpm(250.0) == 250.0
assert scale_bpm(450.0) == 225.0
@pytest.mark.parametrize("bpm", [0.0, -120.0, float("inf"), float("nan")])
def test_scale_bpm_rejects_unusable_tempo(bpm):
with pytest.raises(ValueError):
scale_bpm(bpm)
def test_to_ultrastar_rejects_empty_notes():
with pytest.raises(ValueError):
to_ultrastar([], [], 240.0, [])
def test_to_ultrastar_conversion():
notes = [note(2.0, 0.5, 3, "la"), note(2.6, 0.25, 5, " la")]
song = to_ultrastar(notes, breaks=[2.55], file_bpm=240.0,
headers=[("TITLE", "T")])
# spb = 60/(4*240) = 0.0625s; gap = 2000ms
assert get_header(song, "GAP") == "2000"
assert get_header(song, "BPM") == "240"
assert song.headers[0] == ("TITLE", "T")
n0, br, n1 = song.body
assert n0 == Note(kind=":", beat=0, length=8, pitch=3, text="la")
assert br == LineBreak(beat=9) # (2.55-2.0)/0.0625 = 8.8 -> 9
assert n1.beat == 10 and n1.length == 4
def test_min_length_one():
song = to_ultrastar([note(1.0, 0.001, 0, "x")], [], 240.0, [])
assert song.body[0].length == 1
def test_fractional_bpm_header():
song = to_ultrastar([note(0.0, 1.0)], [], 232.5, [])
assert get_header(song, "BPM") == "232,50"
def test_beat_cursor_separates_notes_quantised_onto_one_beat():
notes = [note(1.0, 0.01, 0, "a"), note(1.01, 0.01, 1, "b"),
note(1.02, 0.01, 2, "c")]
song = to_ultrastar(notes, [], 240.0, []) # all three round to beat 0
assert [n.beat for n in song.body] == [0, 1, 2]
assert [n.length for n in song.body] == [1, 1, 1]
assert_ordered(song.body)
def test_beat_cursor_preserves_onsets_that_do_not_collide():
notes = [note(0.0, 0.5), note(1.0, 0.5), note(2.0, 0.5)]
song = to_ultrastar(notes, [], 240.0, [])
assert [n.beat for n in song.body] == [0, 16, 32]
def test_line_break_pushed_past_the_preceding_note():
notes = [note(0.0, 0.56), note(0.57, 0.1)]
song = to_ultrastar(notes, [0.51], 240.0, []) # break rounds to beat 8
n0, br, n1 = song.body
assert n0.beat + n0.length == 9
assert br == LineBreak(beat=9)
assert n1.beat >= br.beat
assert_ordered(song.body)
def test_line_break_on_a_note_start_precedes_that_note():
"""No gap across a line boundary puts the break exactly on the next onset."""
notes = [note(0.0, 0.5, 0, "end"), note(0.5, 0.5, 2, "New")]
song = to_ultrastar(notes, [0.5], 240.0, [])
assert song.body[1] == LineBreak(beat=8)
assert [n.text for n in song.body if isinstance(n, Note)] == ["end", "New"]
assert_ordered(song.body)
def test_line_break_not_after_the_following_note():
notes = [note(0.0, 0.02, 0, "a"), note(0.03, 0.02, 1, "b")]
song = to_ultrastar(notes, [0.025], 240.0, [])
br = song.body[1]
assert isinstance(br, LineBreak)
assert br.beat <= song.body[2].beat
assert_ordered(song.body)
def sung_words(rng):
"""Words cut into equal syllable slices, the way build_notes emits them."""
notes, breaks, t = [], [], 1.7
for i in range(60):
word = float(rng.uniform(0.05, 0.6))
sylls = int(rng.integers(1, 4))
for k in range(sylls):
notes.append(note(t + k * word / sylls, word / sylls,
int(rng.integers(-12, 12))))
t += word + float(rng.uniform(0.0, 0.4))
if i % 5 == 4:
breaks.append(t - 0.05)
return notes, breaks
@pytest.mark.parametrize("bpm", [200.0, 300.0, 399.0])
def test_no_overlap_on_realistic_input(bpm):
rng = np.random.default_rng(7)
moved = 0
for _ in range(20):
notes, breaks = sung_words(rng)
song = to_ultrastar(notes, breaks, bpm, [])
assert len(song.body) == len(notes) + len(breaks)
assert song.body[0].beat == 0
assert_ordered(song.body)
spb = 60 / (4 * bpm)
emitted = [n for n in song.body if isinstance(n, Note)]
moved += sum(1 for n, src in zip(emitted, notes)
if n.beat != round((src["start"] - notes[0]["start"]) / spb))
assert moved > 0 # otherwise the fuzz never exercises the beat cursor
def write_work(work, video="source_video.webm"):
work.mkdir()
(work / "notes_sec.json").write_text(json.dumps({
"notes": [note(1.0, 0.5, 0, "la"), note(2.0, 0.5, 2, "la")],
"breaks": [1.75],
"warnings": [],
}))
(work / "meta.json").write_text(json.dumps(
{"video_file": video, "thumbnail": "source_video.jpg"}))
def make_ctx(work, **kwargs):
inp = SongInput(title="Song", artist="Band", lyrics_text="x", **kwargs)
return Ctx(inp=inp, cfg=Config(whisper_url="u", creator="me"), work=work,
log=lambda m: None)
def test_grid_stage_writes_song_txt(tmp_path, monkeypatch):
work = tmp_path / "w"
write_work(work)
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work, language="English", genre="Pop", year=1999)
stage = grid_stage_mod.grid_stage()
assert stage.outputs(ctx) == [work / "song.txt"]
stage.run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert [k for k, _ in song.headers] == [
"TITLE", "ARTIST", "LANGUAGE", "GENRE", "YEAR", "CREATOR",
"MP3", "COVER", "VIDEO", "BPM", "GAP"]
assert get_header(song, "BPM") == "240" # 120 doubled into range
assert get_header(song, "GAP") == "1000"
assert get_header(song, "MP3") == "Band - Song.mp3"
assert get_header(song, "COVER") == "Band - Song.jpg"
assert get_header(song, "VIDEO") == "Band - Song.webm"
assert [n.text for n in song.body if isinstance(n, Note)] == ["la", "la"]
assert_ordered(song.body)
def test_grid_stage_omits_optional_headers(tmp_path, monkeypatch):
work = tmp_path / "w"
write_work(work, video="source_video.mp4")
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 210.0)
ctx = make_ctx(work)
grid_stage_mod.grid_stage().run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert [k for k, _ in song.headers] == [
"TITLE", "ARTIST", "CREATOR", "MP3", "COVER", "VIDEO", "BPM", "GAP"]
assert get_header(song, "VIDEO") == "Band - Song.mp4"
assert get_header(song, "CREATOR") == "me"
def test_grid_stage_uses_bpm_override(tmp_path, monkeypatch):
work = tmp_path / "w"
write_work(work)
def boom(path):
raise AssertionError("detect_bpm must not run with an override")
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
ctx = make_ctx(work, bpm_override=333.0)
grid_stage_mod.grid_stage().run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert get_header(song, "BPM") == "333"
def test_grid_stage_honours_zero_bpm_override(tmp_path, monkeypatch):
"""bpm_override=0.0 must not be mistaken for 'not provided' (falsy-zero bug)."""
work = tmp_path / "w"
write_work(work)
def boom(path):
raise AssertionError("detect_bpm must not run with an explicit override")
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
captured = {}
def fake_to_ultrastar(notes, breaks, file_bpm, headers):
captured["bpm"] = file_bpm
return to_ultrastar(notes, breaks, 240.0, headers)
monkeypatch.setattr(grid_stage_mod.grid, "to_ultrastar", fake_to_ultrastar)
ctx = make_ctx(work, bpm_override=0.0)
grid_stage_mod.grid_stage().run(ctx)
assert captured["bpm"] == 0.0
def test_grid_stage_reports_unusable_tempo_as_pipeline_error(tmp_path, monkeypatch):
work = tmp_path / "w"
write_work(work)
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 0.0)
ctx = make_ctx(work)
with pytest.raises(PipelineError, match="tempo"):
grid_stage_mod.grid_stage().run(ctx)
def test_grid_stage_reports_no_notes_as_pipeline_error(tmp_path, monkeypatch):
work = tmp_path / "w"
work.mkdir()
(work / "notes_sec.json").write_text(json.dumps(
{"notes": [], "breaks": [], "warnings": []}))
(work / "meta.json").write_text(json.dumps(
{"video_file": "source_video.webm", "thumbnail": "source_video.jpg"}))
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work)
with pytest.raises(PipelineError, match="no notes"):
grid_stage_mod.grid_stage().run(ctx)
def test_grid_stage_registered():
names = [s.name for s in all_stages()]
assert names[-1] == "package" and names[-2] == "grid"
+40
View File
@@ -0,0 +1,40 @@
from karaoke_maker.lyrics import normalize_lyrics, alignment_token
RAW = """[Verse 1]
It's one two three
[Chorus]
Whoa, la la o' la
Count me up to 7 times
"""
def test_normalize_strips_sections_and_blanks():
ref = normalize_lyrics(RAW)
assert ref.lines == [
["It's", "one", "two", "three"],
["Whoa,", "la", "la", "o'", "la"],
["Count", "me", "up", "to", "7", "times"],
]
assert ref.words[0] == "It's"
assert ref.word_line_index == [0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]
def test_alignment_token():
assert alignment_token("Whoa,") == "whoa"
assert alignment_token("o'") == "o'"
assert alignment_token("7") == "seven"
assert alignment_token("...") == ""
def test_alignment_token_no_internal_whitespace():
assert alignment_token("21") == "twentyone"
assert alignment_token("7th") == "seventh"
def test_alignment_token_hyphenated_word():
assert alignment_token("well-known") == "wellknown"
def test_alignment_token_curly_apostrophe():
assert alignment_token("don’t") == "don't"
+297
View File
@@ -0,0 +1,297 @@
import json
import numpy as np
import pytest
from karaoke_maker.config import Config
from karaoke_maker.notes import (
MIN_NOTE_SEC, TimedNote, build_notes, hz_to_ultrastar, segment_span,
syllabify,
)
from karaoke_maker.pipeline import Ctx, SongInput
from karaoke_maker.stages import all_stages, notes as notes_stage_mod
def flat_pitch(duration=10.0, hz=261.63): # C4
times = np.arange(0, duration, 0.01)
return times, np.full_like(times, hz), np.full_like(times, 0.9)
def assert_no_overlap(notes):
for a, b in zip(notes, notes[1:]):
assert a.start < b.start
assert a.start + a.duration <= b.start
assert all(n.duration > 0 for n in notes)
def test_hz_to_ultrastar():
assert hz_to_ultrastar(261.63) == 0 # C4
assert hz_to_ultrastar(440.0) == 9 # A4
assert hz_to_ultrastar(220.0) == -3 # A3
def test_hz_to_ultrastar_rejects_non_positive():
with pytest.raises(ValueError):
hz_to_ultrastar(0.0)
with pytest.raises(ValueError):
hz_to_ultrastar(-100.0)
def test_segment_span_single_flat():
times, f0, conf = flat_pitch()
segs = segment_span(times, f0, conf, 1.0, 2.0)
assert len(segs) == 1
s, e, midi = segs[0]
assert abs(midi - 60.0) < 0.1
assert abs(s - 1.0) < 0.02 and abs(e - 2.0) < 0.02
def test_segment_span_splits_on_jump():
times = np.arange(0, 2.0, 0.01)
f0 = np.where(times < 1.0, 261.63, 329.63) # C4 -> E4
conf = np.full_like(times, 0.9)
segs = segment_span(times, f0, conf, 0.0, 2.0)
assert len(segs) == 2
assert abs(segs[0][2] - 60.0) < 0.2 and abs(segs[1][2] - 64.0) < 0.2
assert abs(segs[0][1] - segs[1][0]) < 1e-6 # contiguous
def test_segment_span_masks_low_conf():
times, f0, conf = flat_pitch()
conf[:] = 0.1
assert segment_span(times, f0, conf, 0.0, 1.0) == []
def test_segment_span_ignores_brief_pitch_glitch():
times, f0, conf = flat_pitch(duration=1.0)
f0[50:52] = 523.25 # 2-frame octave blip
conf = np.full_like(times, 0.9)
segs = segment_span(times, f0, conf, 0.0, 1.0)
assert len(segs) == 1
def test_segment_span_drops_sub_minimum_segments():
times, f0, conf = flat_pitch(duration=1.0)
assert segment_span(times, f0, conf, 0.5, 0.52) == []
def test_syllabify():
assert syllabify("reminds", "English") == ["re", "minds"]
assert syllabify("a", "English") == ["a"]
assert syllabify("xyzzy", None) == ["xyzzy"]
assert syllabify("She's", "English") == ["She's"]
def test_syllabify_preserves_word_characters():
assert "".join(syllabify("well-known", "English")) == "well-known"
assert "".join(syllabify("beautiful", "English")) == "beautiful"
def test_syllabify_language_without_pyphen_dict_is_not_mishandled_as_english():
# Japanese is a known language (e.g. to whisper_client) but has no pyphen
# dictionary; it must not silently fall back to English hyphenation rules.
assert syllabify("beautiful", "Japanese") == ["beautiful"]
assert syllabify("beautiful", "English") == ["beau", "ti", "ful"]
def test_build_notes_basic_line():
times, f0, conf = flat_pitch()
rows = [
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "got", "start": 1.6, "end": 2.0, "score": 0.9, "line_index": 0},
{"word": "Now", "start": 5.0, "end": 5.5, "score": 0.9, "line_index": 1},
]
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
assert notes[0].text == "She's" # line-initial: no leading space
assert notes[1].text == " got" # mid-line: leading space
assert notes[2].text == "Now" # new line: no leading space
assert len(breaks) == 1 and 2.0 <= breaks[0] <= 5.0
assert warnings == []
def test_build_notes_melisma_continuation():
times = np.arange(0, 3.0, 0.01)
f0 = np.where(times < 1.0, 261.63, 329.63)
conf = np.full_like(times, 0.9)
rows = [{"word": "mine", "start": 0.2, "end": 2.8, "score": 0.9, "line_index": 0}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["mine", "~"]
assert notes[0].pitch != notes[1].pitch
def test_build_notes_unvoiced_word_flagged():
times, f0, conf = flat_pitch()
conf[:] = 0.0
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
assert notes[0].low_confidence and len(warnings) == 1
assert warnings[0] == "1 low-confidence note near 0:01–0:01"
def test_build_notes_low_score_word_flagged():
times, f0, conf = flat_pitch()
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.1, "line_index": 0}]
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
assert all(n.low_confidence for n in notes) and len(warnings) == 1
def test_build_notes_unvoiced_word_inherits_previous_pitch():
times, f0, conf = flat_pitch(hz=440.0) # A4 -> 9, not the fallback 0
conf[300:] = 0.0
rows = [
{"word": "one", "start": 1.0, "end": 2.0, "score": 0.9, "line_index": 0},
{"word": "two", "start": 4.0, "end": 4.5, "score": 0.9, "line_index": 0},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert notes[1].pitch == notes[0].pitch == 9
assert notes[1].low_confidence and not notes[0].low_confidence
def test_build_notes_unvoiced_first_word_uses_zero_pitch():
times, f0, conf = flat_pitch()
conf[:] = 0.0
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert notes[0].pitch == 0
def test_build_notes_multisyllable_word_emits_syllables_only():
times, f0, conf = flat_pitch()
rows = [
{"word": "reminds", "start": 1.0, "end": 1.8, "score": 0.9, "line_index": 0},
{"word": "reminds", "start": 2.0, "end": 2.8, "score": 0.9, "line_index": 0},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["re", "minds", " re", "minds"]
def test_build_notes_more_syllables_than_segments():
times, f0, conf = flat_pitch()
rows = [{"word": "beautiful", "start": 1.0, "end": 1.9, "score": 0.9,
"line_index": 0}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["beau", "ti", "ful"]
assert all(abs(n.duration - notes[0].duration) < 1e-6 for n in notes)
for a, b in zip(notes, notes[1:]):
assert abs(a.start + a.duration - b.start) < 1e-6
def test_build_notes_subdivides_the_widest_segment_first():
times = np.arange(0, 2.0, 0.01)
f0 = np.where(times < 1.1, 329.63, 261.63) # 0.1s E4 then 0.9s C4 in-word
conf = np.full_like(times, 0.9)
rows = [{"word": "beautiful", "start": 1.0, "end": 2.0, "score": 0.9,
"line_index": 0}]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["beau", "ti", "ful"]
assert [n.pitch for n in notes] == [4, 0, 0] # the 0.9s segment split
assert abs(notes[0].duration - 0.1) < 1e-6
assert abs(notes[1].duration - notes[2].duration) < 1e-6
def test_build_notes_zero_duration_row_gets_minimum_length():
times, f0, conf = flat_pitch()
rows = [
{"word": "...", "start": 1.0, "end": 1.0, "score": 0.0, "line_index": 0},
{"word": "go", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert notes[0].duration >= MIN_NOTE_SEC
assert_no_overlap(notes)
def test_build_notes_repeated_zero_duration_rows_stay_separate():
times, f0, conf = flat_pitch()
rows = [{"word": w, "start": 1.0, "end": 1.0, "score": 0.0, "line_index": 0}
for w in ("...", "...", "...", "...")]
rows.append({"word": "go", "start": 1.0, "end": 1.5, "score": 0.9,
"line_index": 0})
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert len(notes) == 5
assert_no_overlap(notes)
def test_build_notes_short_multisyllable_word_does_not_overlap():
times, f0, conf = flat_pitch()
rows = [
{"word": "reminds", "start": 8.92, "end": 8.99, "score": 0.9,
"line_index": 0},
{"word": "me", "start": 8.99, "end": 9.20, "score": 0.9, "line_index": 0},
]
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["re", "minds", " me"]
assert_no_overlap(notes)
def test_build_notes_never_overlaps_on_dense_input():
rng = np.random.default_rng(11)
times = np.arange(0, 40.0, 0.01)
words = ["We", "count", "a", "beautiful", "number", "reminds", "us",
"everything", "we", "listed", "yesterday", "oh"]
for _ in range(8):
f0 = 220.0 * 2 ** (rng.integers(0, 12, len(times)) // 3 / 12.0)
conf = np.where(rng.random(len(times)) < 0.9, 0.9, 0.1)
rows, t = [], 0.5
for i in range(80):
dur = float(rng.uniform(0.06, 0.55))
rows.append({"word": words[i % len(words)], "start": t,
"end": t + dur, "score": 0.9, "line_index": i // 6})
t += dur + float(rng.uniform(0.0, 0.15))
notes, _, _ = build_notes(rows, times, f0, conf, "English")
assert_no_overlap(notes)
def test_build_notes_starts_are_non_decreasing():
times = np.arange(0, 12.0, 0.01)
f0 = 220.0 * 2 ** (np.floor(times * 2) % 5 / 12.0) # stepped melody
conf = np.where((times % 3.0) < 2.6, 0.9, 0.1) # unvoiced gaps
words = ["We", "count", "a", "beautiful", "number", "that",
"reminds", "us", "of", "everything", "we", "list"]
rows = []
for i, w in enumerate(words):
dur = 0.8 if i % 3 else 0.07 # every third word sung very fast
rows.append({"word": w, "start": 0.5 + i * 0.9, "end": 0.5 + i * 0.9 + dur,
"score": 0.9 if i % 5 else 0.2, "line_index": i // 4})
notes, breaks, warnings = build_notes(rows, times, f0, conf, "English")
assert len(notes) >= len(words)
starts = [n.start for n in notes]
assert starts == sorted(starts)
assert breaks == sorted(breaks) and len(breaks) == 2
assert_no_overlap(notes)
assert warnings and all("low-confidence note" in w for w in warnings)
def test_build_notes_line_initial_after_break_has_no_space():
times, f0, conf = flat_pitch()
rows = [
{"word": "one", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 0},
{"word": "two", "start": 2.0, "end": 2.4, "score": 0.9, "line_index": 1},
{"word": "three", "start": 3.0, "end": 3.4, "score": 0.9, "line_index": 1},
]
notes, breaks, _ = build_notes(rows, times, f0, conf, "English")
assert [n.text for n in notes] == ["one", "two", " three"]
assert breaks == [1.7]
def test_notes_stage_writes_json(tmp_path):
work = tmp_path / "w"
work.mkdir()
times, f0, conf = flat_pitch()
np.savez(work / "pitch.npz", times=times, f0=f0, conf=conf)
(work / "alignment.json").write_text(json.dumps([
{"word": "She's", "start": 1.0, "end": 1.5, "score": 0.9, "line_index": 0},
{"word": "gone", "start": 3.0, "end": 3.5, "score": 0.9, "line_index": 1},
]))
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x", language="English"),
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
stage = notes_stage_mod.notes_stage()
assert stage.outputs(ctx) == [work / "notes_sec.json"]
stage.run(ctx)
data = json.loads((work / "notes_sec.json").read_text())
assert [n["text"] for n in data["notes"]] == ["She's", "gone"]
assert data["breaks"] == [2.25]
assert data["warnings"] == []
assert TimedNote(**data["notes"][0]).pitch == 0
def test_notes_stage_registered():
assert "notes" in [s.name for s in all_stages()]
+187
View File
@@ -0,0 +1,187 @@
import json
from pathlib import Path
import pytest
from PIL import Image
from karaoke_maker.config import Config
from karaoke_maker.pipeline import (
STAGE_ORDER, Ctx, PipelineError, SongInput, run_pipeline, song_slug,
)
from karaoke_maker.stages import all_stages, package
from karaoke_maker.ultrastar import get_header, parse_ultrastar
SONG_TXT = ("#TITLE:T\n#ARTIST:A\n#MP3:A - T.mp3\n#COVER:A - T.jpg\n"
"#VIDEO:A - T.webm\n#BPM:200\n#GAP:0\n: 0 1 0 la\nE\n")
def make_ctx(tmp_path, cover=None):
work = tmp_path / "work"
work.mkdir()
(work / "song.txt").write_text(SONG_TXT)
(work / "audio.wav").write_bytes(b"w")
(work / "source_video.webm").write_bytes(b"v")
(work / "meta.json").write_text(json.dumps(
{"video_file": "source_video.webm", "thumbnail": None}))
inp = SongInput(title="T", artist="A", lyrics_text="x", cover_path=cover)
cfg = Config(whisper_url="u", output_dir=tmp_path / "out", work_dir=tmp_path)
return Ctx(inp=inp, cfg=cfg, work=work, log=lambda m: None)
def make_cover(tmp_path):
p = tmp_path / "c.png"
Image.new("RGB", (640, 480), "red").save(p)
return p
def make_pipeline_inputs(tmp_path, cover=None):
"""Set up inputs at the exact work-dir path run_pipeline() would use."""
cfg = Config(whisper_url="u", output_dir=tmp_path / "out",
work_dir=tmp_path / "work_root")
inp = SongInput(title="T", artist="A", lyrics_text="x", cover_path=cover)
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True)
(work / "song.txt").write_text(SONG_TXT)
(work / "audio.wav").write_bytes(b"w")
(work / "source_video.webm").write_bytes(b"v")
(work / "meta.json").write_text(json.dumps(
{"video_file": "source_video.webm", "thumbnail": None}))
return inp, cfg
def test_package_copies_compatible_video(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
assert (out / "A - T.txt").exists()
assert (out / "A - T.mp3").exists()
assert (out / "A - T.webm").read_bytes() == b"v"
img = Image.open(out / "A - T.jpg")
assert img.width == img.height
def test_package_reencodes_and_rewrites_video_header(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("avi", "mpeg4"))
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
song = parse_ultrastar((out / "A - T.txt").read_text())
assert get_header(song, "VIDEO") == "A - T.mp4"
# VIDEO keeps its original slot, ahead of BPM/GAP
keys = [k for k, _ in song.headers]
assert keys.index("VIDEO") < keys.index("BPM") < keys.index("GAP")
def test_package_no_cover_drops_header(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
ctx = make_ctx(tmp_path, cover=None)
package.package_stage().run(ctx)
song = parse_ultrastar((tmp_path / "out" / "A - T" / "A - T.txt").read_text())
assert get_header(song, "COVER") is None
def test_package_uses_work_thumbnail_when_no_cover_path(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
ctx = make_ctx(tmp_path, cover=None)
make_cover(tmp_path).rename(ctx.work / "thumb.png")
meta = json.loads((ctx.work / "meta.json").read_text())
meta["thumbnail"] = "thumb.png"
(ctx.work / "meta.json").write_text(json.dumps(meta))
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
assert (out / "A - T.jpg").exists()
song = parse_ultrastar((out / "A - T.txt").read_text())
assert get_header(song, "COVER") == "A - T.jpg"
def test_package_outputs_done_marker():
ctx_outputs = package.package_stage().outputs
cfg = Config(whisper_url="u", output_dir=Path("out"), work_dir=Path("work"))
inp = SongInput(title="T", artist="A", lyrics_text="x")
ctx = Ctx(inp=inp, cfg=cfg, work=Path("work"), log=lambda m: None)
assert ctx_outputs(ctx) == [Path("work/package.done")]
def test_package_corrupt_cover_degrades_to_no_cover(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
bad_cover = tmp_path / "bad.jpg"
bad_cover.write_bytes(b"not an image")
ctx = make_ctx(tmp_path, cover=bad_cover)
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
assert not (out / "A - T.jpg").exists()
song = parse_ultrastar((out / "A - T.txt").read_text())
assert get_header(song, "COVER") is None
def test_package_partial_failure_is_not_cached(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
def boom(p):
raise PipelineError("ffprobe failed")
monkeypatch.setattr(package, "ffprobe_codec", boom)
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
with pytest.raises(PipelineError):
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T"
assert (out / "A - T.txt").exists()
assert (out / "A - T.mp3").exists()
assert not (out / "A - T.webm").exists()
assert not package.package_stage().outputs(ctx)[0].exists()
def test_package_success_is_cached_by_pipeline(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
inp, cfg = make_pipeline_inputs(tmp_path, cover=make_cover(tmp_path))
stage = package.package_stage()
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
logs = []
run_pipeline(inp, cfg, log=logs.append, stages=[stage])
assert any("[package] cached, skipping" in m for m in logs)
def test_package_retry_after_partial_failure_completes_folder(tmp_path, monkeypatch):
calls = {"n": 0}
def flaky_ffprobe(p):
calls["n"] += 1
if calls["n"] == 1:
raise PipelineError("ffprobe failed")
return ("webm", "vp9")
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", flaky_ffprobe)
inp, cfg = make_pipeline_inputs(tmp_path, cover=make_cover(tmp_path))
stage = package.package_stage()
with pytest.raises(PipelineError):
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
out = cfg.output_dir / "A - T"
assert (out / "A - T.txt").exists() and (out / "A - T.mp3").exists()
assert not (out / "A - T.webm").exists()
run_pipeline(inp, cfg, log=lambda m: None, stages=[stage])
assert (out / "A - T.webm").exists()
assert (out / "A - T.jpg").exists()
def test_slash_in_title_is_stripped_from_folder_and_filenames(tmp_path, monkeypatch):
monkeypatch.setattr(package, "ffmpeg", lambda *a: Path(a[-1]).write_bytes(b"m"))
monkeypatch.setattr(package, "ffprobe_codec", lambda p: ("webm", "vp9"))
ctx = make_ctx(tmp_path, cover=make_cover(tmp_path))
ctx.inp.title = "T/2"
package.package_stage().run(ctx)
out = tmp_path / "out" / "A - T2"
assert (out / "A - T2.txt").exists()
assert (out / "A - T2.mp3").exists()
def test_all_stages_matches_stage_order():
assert [s.name for s in all_stages()] == STAGE_ORDER
+48
View File
@@ -0,0 +1,48 @@
import json
from pathlib import Path
from karaoke_maker.config import Config
from karaoke_maker.pipeline import SongInput, Stage, run_pipeline, song_slug
def make_cfg(tmp_path):
return Config(whisper_url="http://x/v1", output_dir=tmp_path / "out",
work_dir=tmp_path / "work")
def make_inp():
return SongInput(title="T/est", artist="A", lyrics_text="la la")
def fake_stage(name, calls):
def outputs(ctx):
return [ctx.work / f"{name}.done"]
def run(ctx):
calls.append(name)
(ctx.work / f"{name}.done").write_text("x")
return Stage(name=name, outputs=outputs, run=run)
def test_slug_strips_slash():
assert song_slug(make_inp()) == "A - Test"
def test_runs_all_stages_then_skips_cached(tmp_path):
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
stages = [fake_stage("s1", calls), fake_stage("s2", calls)]
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
assert calls == ["s1", "s2"]
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
assert calls == ["s1", "s2"]
def test_force_from_reruns_tail(tmp_path):
cfg, inp, calls = make_cfg(tmp_path), make_inp(), []
stages = [fake_stage("s1", calls), fake_stage("s2", calls), fake_stage("s3", calls)]
run_pipeline(inp, cfg, log=lambda m: None, stages=stages)
calls.clear()
run_pipeline(inp, cfg, log=lambda m: None, stages=stages, force_from="s2")
assert calls == ["s2", "s3"]
def test_warnings_surfaced(tmp_path):
cfg, inp = make_cfg(tmp_path), make_inp()
def run(ctx):
(ctx.work / "notes_sec.json").write_text(
json.dumps({"notes": [], "breaks": [], "warnings": ["w1"]}))
(ctx.work / "n.done").write_text("x")
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"]
+19
View File
@@ -0,0 +1,19 @@
import numpy as np
from karaoke_maker.config import Config
from karaoke_maker.pipeline import Ctx, SongInput
from karaoke_maker.stages import pitch
def test_pitch_stage_writes_npz(tmp_path, monkeypatch):
work = tmp_path / "w"
work.mkdir()
times = np.arange(0, 1.0, 0.01)
f0 = np.full_like(times, 220.0)
conf = np.full_like(times, 0.9)
monkeypatch.setattr(pitch, "track_pitch", lambda p, m, d: (times, f0, conf))
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x"),
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
pitch.pitch_stage().run(ctx)
data = np.load(work / "pitch.npz")
assert np.allclose(data["f0"], 220.0)
assert data["times"].shape == data["conf"].shape
+26
View File
@@ -0,0 +1,26 @@
import numpy as np
import soundfile as sf
import torch
from karaoke_maker.config import Config
from karaoke_maker.pipeline import Ctx, SongInput
from karaoke_maker.stages import separate
def test_separate_writes_stems(tmp_path, monkeypatch):
work = tmp_path / "w"
work.mkdir()
(work / "audio.wav").write_bytes(b"")
stems = {
"vocals": torch.ones(2, 100) * 0.5,
"drums": torch.ones(2, 100) * 0.1,
"bass": torch.ones(2, 100) * 0.1,
"other": torch.ones(2, 100) * 0.1,
}
monkeypatch.setattr(separate, "separate_audio", lambda p, m, d: stems)
ctx = Ctx(inp=SongInput(title="T", artist="A", lyrics_text="x"),
cfg=Config(whisper_url="u"), work=work, log=lambda m: None)
separate.separate_stage().run(ctx)
vocals, sr = sf.read(work / "vocals.wav")
assert sr == 44100 and vocals.shape == (100, 2)
instrumental, _ = sf.read(work / "instrumental.wav")
assert np.allclose(instrumental, 0.3, atol=1e-4)
+28
View File
@@ -0,0 +1,28 @@
from karaoke_maker.lyrics import normalize_lyrics
from karaoke_maker.whisper_client import TranscriptSegment
from karaoke_maker.stages.transcribe import lyrics_similarity, vocal_sections
def seg(s, e, t=""):
return TranscriptSegment(start=s, end=e, text=t)
def test_vocal_sections_merges_close_segments():
sections = vocal_sections([seg(2, 5), seg(6, 9), seg(30, 33)])
assert sections == [(1.0, 10.0), (29.0, 34.0)]
def test_vocal_sections_handles_out_of_order_segments():
sections = vocal_sections([seg(30, 33), seg(1, 4)])
assert sections == [(0.0, 5.0), (29.0, 34.0)]
def test_vocal_sections_keeps_contained_segment():
sections = vocal_sections([seg(1, 10), seg(3, 5)])
assert sections == [(0.0, 11.0)]
def test_similarity_high_for_matching_lyrics():
ref = normalize_lyrics("it's one two three\nfour five six seven eight")
segs = [seg(0, 2, "It's one two three"), seg(2, 4, "four five six seven eight")]
assert lyrics_similarity(segs, ref) > 0.9
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
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from karaoke_maker.ultrastar import (
Note, LineBreak, parse_ultrastar, serialize_ultrastar,
get_header, format_bpm, parse_bpm,
)
FIXTURES = Path(__file__).parent / "fixtures"
def load(name):
return (FIXTURES / name).read_text(encoding="utf-8", errors="replace")
def test_parse_headers_and_notes():
song = parse_ultrastar(load("sanctuary.txt"))
assert get_header(song, "artist") == "Utada Hikaru"
assert parse_bpm(get_header(song, "BPM")) == 217.98
first = song.body[0]
assert first == Note(kind=":", beat=0, length=3, pitch=16, text="ah")
assert any(isinstance(x, LineBreak) and x.beat == 95 for x in song.body)
assert any(n.kind == "F" for n in song.body if isinstance(n, Note))
def test_note_text_keeps_leading_space():
song = parse_ultrastar(load("sanctuary.txt"))
notes = [n for n in song.body if isinstance(n, Note)]
assert notes[1].text == " ah"
def test_negative_pitch():
song = parse_ultrastar(load("sweet_child.txt"))
assert any(n.pitch == -9 for n in song.body if isinstance(n, Note))
def test_round_trip():
for name in ("sweet_child.txt", "sanctuary.txt"):
song = parse_ultrastar(load(name))
again = parse_ultrastar(serialize_ultrastar(song))
assert again == song
def test_format_bpm():
assert format_bpm(256.0) == "256"
assert format_bpm(217.98) == "217,98"
+502
View File
@@ -0,0 +1,502 @@
import io
import re
import threading
import time
import pytest
from fastapi.testclient import TestClient
from karaoke_maker.config import Config
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, PipelineResult
from karaoke_maker.web import create_app
def make_client(tmp_path, runner):
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
output_dir=tmp_path / "out")
return TestClient(create_app(cfg, runner=runner)), cfg
def ok_runner(inp, cfg, log=print, force_from=None, stages=None):
log("[acquire] running")
return PipelineResult(output_dir=cfg.output_dir / "X", warnings=["check 1:32"])
def wait_status(client, job_id, status, timeout=5):
for _ in range(int(timeout / 0.05)):
job = client.get(f"/api/jobs/{job_id}").json()
if job["status"] == status:
return job
time.sleep(0.05)
raise AssertionError(f"job never reached {status}: {job}")
def test_submit_and_complete(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la la",
"youtube_url": "http://y"})
assert resp.status_code == 201
job = wait_status(client, resp.json()["id"], "done")
assert job["warnings"] == ["check 1:32"]
assert any("[acquire] running" in line for line in job["log"])
def test_failure_reported(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 = wait_status(client, resp.json()["id"], "failed")
assert job["error"] == "boom"
def test_requires_one_source(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"})
assert resp.status_code == 400
def test_rejects_both_sources(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"},
files={"video": ("v.mp4", b"data", "video/mp4")})
assert resp.status_code == 400
def test_job_list_and_404(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.get("/api/jobs").json() == []
assert client.get("/api/jobs/nope").status_code == 404
def test_index_served(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.get("/")
assert resp.status_code == 200 and "<form" in resp.text
def test_rejects_nonpositive_bpm(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y", "bpm": "0"})
assert resp.status_code == 400
def test_rejects_nonfinite_bpm(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y", "bpm": "nan"})
assert resp.status_code == 400
def test_accepts_positive_bpm(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y", "bpm": "120"})
assert resp.status_code == 201
wait_status(client, resp.json()["id"], "done")
def test_worker_survives_arbitrary_exception_and_keeps_processing(tmp_path):
calls = []
def flaky_runner(inp, cfg, log=print, force_from=None, stages=None):
calls.append(inp.title)
if inp.title == "bad":
raise ValueError("kaboom")
log("[acquire] running")
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, flaky_runner)
bad = client.post("/api/jobs", data={
"title": "bad", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
good = client.post("/api/jobs", data={
"title": "good", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
bad_job = wait_status(client, bad.json()["id"], "failed")
assert bad_job["error"] == "kaboom"
good_job = wait_status(client, good.json()["id"], "done")
assert good_job["status"] == "done"
def test_uploaded_video_filename_is_sanitised(tmp_path):
seen = {}
def capture_runner(inp, cfg, log=print, force_from=None, stages=None):
seen["video_path"] = inp.video_path
return PipelineResult(output_dir=cfg.output_dir / "X")
client, cfg = make_client(tmp_path, capture_runner)
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
files={"video": ("../../etc/passwd", io.BytesIO(b"data"), "video/mp4")})
assert resp.status_code == 201
job_id = resp.json()["id"]
wait_status(client, job_id, "done")
uploads_root = (cfg.work_dir / "uploads").resolve()
saved_path = seen["video_path"]
assert saved_path is not None
assert saved_path.resolve().parent == uploads_root / job_id
assert saved_path.name == "passwd"
assert not (tmp_path / "etc" / "passwd").exists()
def test_upload_dir_cleaned_up_after_success(tmp_path):
client, cfg = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
job_id = resp.json()["id"]
wait_status(client, job_id, "done")
assert not (cfg.work_dir / "uploads" / job_id).exists()
def test_upload_dir_cleaned_up_after_failure(tmp_path):
def bad_runner(*a, **kw):
raise PipelineError("boom")
client, cfg = make_client(tmp_path, bad_runner)
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
job_id = resp.json()["id"]
wait_status(client, job_id, "failed")
assert not (cfg.work_dir / "uploads" / job_id).exists()
def test_jobs_run_one_at_a_time(tmp_path):
counter_lock = threading.Lock()
state = {"current": 0, "max": 0}
def slow_runner(inp, cfg, log=print, force_from=None, stages=None):
with counter_lock:
state["current"] += 1
state["max"] = max(state["max"], state["current"])
time.sleep(0.1)
with counter_lock:
state["current"] -= 1
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, slow_runner)
ids = [client.post("/api/jobs", data={
"title": f"T{i}", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}
).json()["id"] for i in range(4)]
for job_id in ids:
wait_status(client, job_id, "done")
assert state["max"] == 1
def test_job_has_timestamps(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
job = wait_status(client, resp.json()["id"], "done")
assert job["created_at"] <= job["started_at"] <= job["finished_at"]
def test_queued_job_has_no_start_or_finish(tmp_path):
gate = threading.Event()
def blocking_runner(inp, cfg, log=print, force_from=None, stages=None):
gate.wait(timeout=5)
return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[])
client, _ = make_client(tmp_path, blocking_runner)
try:
first = client.post("/api/jobs", data={
"title": "T1", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
second = client.post("/api/jobs", data={
"title": "T2", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
queued = client.get(f"/api/jobs/{second.json()['id']}").json()
assert queued["created_at"] > 0
assert queued["started_at"] is None and queued["finished_at"] is None
finally:
gate.set()
wait_status(client, first.json()["id"], "done")
def test_failed_job_still_records_finish(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 = wait_status(client, resp.json()["id"], "failed")
assert job["finished_at"] is not None and job["finished_at"] >= job["started_at"]
def test_job_list_includes_timestamps(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
wait_status(client, resp.json()["id"], "done")
summary = client.get("/api/jobs").json()[0]
assert {"created_at", "started_at", "finished_at"} <= set(summary)
def test_stages_endpoint_matches_stage_order(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.get("/api/stages").json()["stages"] == STAGE_ORDER
def test_cached_stage_line_advances_stage(tmp_path):
def cached_runner(inp, cfg, log=print, force_from=None, stages=None):
log("[acquire] cached, skipping")
log("[separate] cached, skipping")
log("[transcribe] running")
return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[])
client, _ = make_client(tmp_path, cached_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
job = wait_status(client, resp.json()["id"], "done")
assert job["stage"] == "transcribe"
def test_cached_only_run_reports_last_cached_stage(tmp_path):
def all_cached(inp, cfg, log=print, force_from=None, stages=None):
for name in ("acquire", "separate", "transcribe"):
log(f"[{name}] cached, skipping")
return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[])
client, _ = make_client(tmp_path, all_cached)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
job = wait_status(client, resp.json()["id"], "done")
assert job["stage"] == "transcribe"
def test_unrelated_log_lines_do_not_change_stage(tmp_path):
def noisy(inp, cfg, log=print, force_from=None, stages=None):
log("[align] running")
log("some incidental output")
log("[not a stage line]")
return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[])
client, _ = make_client(tmp_path, noisy)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"})
job = wait_status(client, resp.json()["id"], "done")
assert job["stage"] == "align"
def test_fonts_are_served(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
resp = client.get("/static/fonts/chakra-petch-600.woff2")
assert resp.status_code == 200
assert resp.content[:4] == b"wOF2"
def test_font_licence_is_bundled():
from karaoke_maker.web import STATIC
licence = (STATIC / "fonts" / "OFL.txt").read_text(encoding="utf-8", errors="replace")
assert "SIL OPEN FONT LICENSE" in licence.upper()
def _index_html():
from karaoke_maker.web import STATIC
return (STATIC / "index.html").read_text(encoding="utf-8")
def test_index_has_required_hooks():
html = _index_html()
for hook in ("song-form", "jobs", "detail", "review", "now-bar", "sidebar"):
assert f'id="{hook}"' in html, hook
for name in ("title", "artist", "lyrics", "youtube_url", "video",
"genre", "year", "language", "bpm", "cover"):
assert f'name="{name}"' in html, name
def test_index_makes_no_external_requests():
import re as _re
html = _index_html()
for url in _re.findall(r'(?:src|href)\s*=\s*["\']([^"\']+)', html):
assert not url.startswith(("http://", "https://", "//")), url
assert "@import" not in html
def test_index_renders_api_data_without_innerhtml():
"""API-derived values must go through textContent, never innerHTML."""
import re as _re
html = _index_html()
for m in _re.finditer(r"\.(innerHTML|outerHTML|insertAdjacentHTML)\s*(=|\()\s*(.*)", html):
assert "${" not in m.group(3), f"interpolation into {m.group(1)}: {m.group(3)[:80]}"
# the page reaches the same end by never naming a markup-parsing sink at all,
# which also catches `el.innerHTML = job.title` (no interpolation to spot)
for sink in ("innerHTML", "outerHTML", "insertAdjacentHTML"):
assert sink not in html, f"{sink} must not appear in the page"
def test_index_respects_reduced_motion():
assert "prefers-reduced-motion" in _index_html()
def test_index_parses_warnings_with_endash():
html = _index_html()
assert "–" in html, "warning parser must handle the en-dash separator"
assert "data-start" in html and "data-end" in html
def test_index_has_copy_path_control():
assert "copy-path" in _index_html()
def test_index_fetches_stage_order_from_api():
html = _index_html()
assert "/api/stages" in html, "stage list must come from the API, not be hardcoded"
# Only the distinctive stage names — "grid", "align", "notes" and "pitch" can
# legitimately appear in CSS properties or UI copy.
for name in ("acquire", "separate", "transcribe", "package"):
assert f'"{name}"' not in html, f"stage {name} appears hardcoded in the page"
def test_index_has_dragdrop_and_poll_control():
html = _index_html()
assert "dragover" in html and "drop" in html
assert "clearInterval" in html or "pollTimer" in html
def _fn_body(html, name):
"""The source of a top-level `function name(...) {...}` in the page script."""
start = html.index(f"function {name}(")
return html[start:html.index("\n}\n", start)]
def _page_css(html):
return html[html.index("<style>"):html.index("</style>")]
def _focus_flaws(html):
"""Ways the 3s poll could detach — and so blur — a focused node.
Both the queue cards and #copy-path are focusable and both are rewritten on
every poll, so each one has to be patched in place rather than rebuilt.
"""
flaws = []
queue = _fn_body(html, "renderJobs")
if 'querySelectorAll(".job")' not in queue or "dataset.id" not in queue:
flaws.append("queue cards are not matched to the payload by id")
if re.search(r"replaceChildren\(\s*\.\.\.", queue):
flaws.append("the queue is re-rendered wholesale, detaching every card")
for line in re.findall(r".*insertBefore.*", queue):
if not re.search(r"if \((\w+)\.children\[(\w+)\] !== (\w+)\) "
r"\1\.insertBefore\(\s*\3\s*,\s*\1\.children\[\2\]", line):
flaws.append(f"a card is moved without checking it needs to be: {line.strip()}")
out = _fn_body(html, "renderOutputPath")
guard = re.search(r"if \((\w+) === (\w+)\) return;", out[:out.index("replaceChildren")])
if guard is None:
flaws.append("#copy-path is rebuilt without first checking whether it changed")
elif not re.search(rf"(?:{guard[1]}|{guard[2]}) = outputPathDiv\.querySelector",
out[:guard.start()]):
flaws.append("the #copy-path guard does not compare against what is rendered")
return flaws
def test_polling_cannot_blur_a_focused_control():
assert _focus_flaws(_index_html()) == []
def _now_bar_flaws(html):
body = _fn_body(html, "renderNowBar")
picked = re.search(r'const (\w+) = jobs\.find\(\w+ => \w+\.status === "running"\)', body)
if picked is None:
return ["the bar does not pick out the running job"]
job, flaws = picked[1], []
if not re.search(rf'classList\.toggle\("idle", !{job}\)', body):
flaws.append("the idle class does not track whether a job is running")
if f"renderStageTrack({job})" not in body:
flaws.append("the stage track is not driven by the running job")
if f"{job} ?" not in body:
flaws.append("the now-bar title is not driven by the running job")
return flaws
def test_now_bar_state_follows_the_running_job():
assert _now_bar_flaws(_index_html()) == []
def _stale_detail_flaws(html):
"""A slow response for a since-deselected job must not overwrite the UI."""
body = _fn_body(html, "refreshDetail")
m = re.search(r"const (\w+) = selectedId;", body)
if m is None:
return ["refreshDetail does not snapshot selectedId before fetching"]
if f"if ({m[1]} !== selectedId) return;" not in body:
return ["refreshDetail does not bail when selectedId changed during the fetch"]
return []
def test_stale_detail_response_cannot_override_a_newer_selection():
assert _stale_detail_flaws(_index_html()) == []
def _pulse_flaws(html):
css, flaws = _page_css(html), []
if "@keyframes stage-pulse" not in css:
flaws.append("the stage-pulse keyframes are missing")
applied = re.search(r"([^{}]*)\{[^{}]*animation: stage-pulse[^{}]*\}", css)
if applied is None:
flaws.append("stage-pulse is defined but never applied")
else:
selector = applied[1].strip().splitlines()[-1].strip()
if ".stage-active" not in selector or ".stage-dot" not in selector:
flaws.append(f"the pulse is on {selector!r}, not the running stage's dot")
if '"stage-active"' not in _fn_body(html, "renderStageTrack"):
flaws.append("nothing ever gets the stage-active class")
reduced = re.search(r"@media \(prefers-reduced-motion: reduce\) \{(.*?)\n\}", css, re.S)
if reduced is None or not re.search(r"\*[^{}]*\{[^{}]*animation: none !important",
reduced[1]):
flaws.append("reduced motion does not disable animation everywhere")
return flaws
def test_running_stage_pulses_unless_motion_is_reduced():
assert _pulse_flaws(_index_html()) == []
# each mutation is a plausible regression; the checker above must reject all of them
MUTATIONS = [
(_focus_flaws, "queue: card moved unconditionally",
"if (jobsDiv.children[i] !== card) jobsDiv.insertBefore",
"jobsDiv.insertBefore"),
(_focus_flaws, "queue: rebuilt wholesale",
"jobsDiv.insertBefore(card, jobsDiv.children[i] ?? null)",
"jobsDiv.replaceChildren(...jobs.map(jobCard))"),
(_focus_flaws, "copy-path: unchanged panel rebuilt anyway",
"if (path === shown) return;", ""),
(_now_bar_flaws, "now bar: idle keyed off the queue instead of the running job",
'classList.toggle("idle", !running)', 'classList.toggle("idle", !jobs.length)'),
(_now_bar_flaws, "now bar: shows the newest job instead of the running one",
'const running = jobs.find(j => j.status === "running")',
"const running = jobs[0]"),
(_stale_detail_flaws, "refreshDetail: stale-response guard removed",
"if (id !== selectedId) return;", ""),
(_pulse_flaws, "pulse: attached to the wrong stage",
".stage-active .stage-dot { color: var(--aqua); animation: stage-pulse",
".stage-pending .stage-dot { color: var(--aqua); animation: stage-pulse"),
(_pulse_flaws, "pulse: reduced motion no longer wins",
"animation: none !important", "animation: none"),
]
@pytest.mark.parametrize("check,name,old,new",
MUTATIONS, ids=[m[1] for m in MUTATIONS])
def test_guards_reject_plausible_regressions(check, name, old, new):
html = _index_html()
assert old in html, f"mutation no longer applies, rewrite it: {name}"
assert check(html.replace(old, new)), f"{name} slipped past the check"
def test_stage_state_is_not_conveyed_by_colour_alone():
html = _index_html()
body = _fn_body(html, "renderStageTrack")
assert 'setAttribute("aria-current", "step")' in body
assert '"sr-only"' in body and ".sr-only {" in _page_css(html)
for word in ('" done"', '" running"', '" pending"'):
assert word in body, word
def test_narrow_stage_labels_stay_in_the_accessibility_tree():
hidden = re.search(r"\.stage:not\(\.stage-active\) \.stage-label \{([^}]*)\}",
_page_css(_index_html()))
assert hidden, "the compact stage track rule moved or was renamed"
assert "display: none" not in hidden[1] and "visibility: hidden" not in hidden[1]
+38
View File
@@ -0,0 +1,38 @@
import httpx
from pathlib import Path
from karaoke_maker.config import Config
from karaoke_maker.whisper_client import TranscriptSegment, transcribe
def test_transcribe_parses_segments(tmp_path):
audio = tmp_path / "v.wav"
audio.write_bytes(b"RIFF")
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json={
"text": "hello world",
"segments": [{"start": 0.5, "end": 2.0, "text": " hello world"}],
})
client = httpx.Client(transport=httpx.MockTransport(handler))
cfg = Config(whisper_url="http://w:9000/v1", whisper_api_key="sekrit")
segs = transcribe(cfg, audio, client=client)
assert segs == [TranscriptSegment(start=0.5, end=2.0, text="hello world")]
assert seen["url"] == "http://w:9000/v1/audio/transcriptions"
assert seen["auth"] == "Bearer sekrit"
def test_transcribe_maps_language_to_iso_code(tmp_path):
audio = tmp_path / "v.wav"
audio.write_bytes(b"RIFF")
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = request.read().decode("utf-8", errors="replace")
return httpx.Response(200, json={"segments": []})
client = httpx.Client(transport=httpx.MockTransport(handler))
cfg = Config(whisper_url="http://w:9000/v1")
transcribe(cfg, audio, language="Japanese", client=client)
assert 'name="language"' in seen["body"]
assert "\r\n\r\nja\r\n" in seen["body"]