Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d36216769 | ||
|
|
df5eff8c20 | ||
|
|
439f6f75b8 | ||
|
|
9e01f0b204 | ||
|
|
cf48324f17 | ||
|
|
8675d5594b | ||
|
|
ca6006023d | ||
|
|
69810f584d | ||
|
|
232f0354fc | ||
|
|
d2ad3d1c90 | ||
|
|
ce57680a6a | ||
|
|
465d420b11 | ||
|
|
76075baf93 | ||
|
|
19d8ed6d7f | ||
|
|
e093d8afd4 | ||
|
|
a349d697e4 | ||
|
|
0f09e6702b | ||
|
|
ba78587a54 | ||
|
|
b3a89b2f37 | ||
|
|
7f1911642b | ||
|
|
bdde0af312 | ||
|
|
f75686b736 | ||
|
|
19990d6883 | ||
|
|
c9d633eaf6 | ||
|
|
ef1ac6e59c | ||
|
|
e5f1a17e0f | ||
|
|
d9dbfbfffa | ||
|
|
988970676a | ||
|
|
f3bd496461 | ||
|
|
ca42a6bad8 | ||
|
|
2ad77775ef | ||
|
|
d24d44582e |
+19
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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", "")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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(",", "."))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user