webui: set a song's BPM and GAP from the sheet
tests / test (pull_request_target) Successful in 3m52s

The beat grid came from the detected tempo (or the BPM override) and a GAP
pinned to the first note, so it did not always line up with the music,
and snapping did not land on its beats. A GRID bar on the sheet now sets
both, redrawing the grid live as values are tried, with x2 and /2 for a
tempo detected at double or half speed and ALIGN TO NOTE to move GAP the
least that puts the selected note on a beat. Note editing pauses while a
trial grid is unapplied, so notes never snap to a grid that is then
cancelled.

The grid is stored with the song's edits, so every rebuild keeps it, and
grid charts with it in place of its own choice; it records that choice in
tempo.json for RESET to return to. A GAP after the first note moves back
by whole beats, as notes on negative beats are rejected by players. PUT
and DELETE /api/jobs/{id}/grid set and reset it, rewriting the charts in
place.
This commit is contained in:
2026-10-13 16:08:57 -07:00
parent e65f62b459
commit ae5ca3465b
10 changed files with 371 additions and 33 deletions
+7
View File
@@ -70,6 +70,13 @@ next note, S splits, M merges with the next note, G/F/R make a note golden,
freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
the line.
The **GRID** bar above the notes sets the chart's BPM and GAP. Changes redraw
the grid as you type and are charted on **APPLY**; ×2 and ÷2 fix a tempo
detected at double or half speed, and **ALIGN TO NOTE** moves GAP the least
that puts the selected note on a beat. Notes keep their timing in seconds and
re-snap to the new grid. **RESET** returns to the BPM songtrace chose (your
BPM override, or the detected tempo) with GAP at the first note.
Edits survive every rebuild, REBUILD ALL FROM… included: they are kept apart
from what the pipeline generates and laid back over it each time. **REVERT**
returns a line to the generated notes. Changing a line's lyrics sets its edit
+25 -4
View File
@@ -7,6 +7,7 @@ rebuild with a different BPM re-snaps it rather than breaking it.
"""
import difflib
import json
import math
import time
from pathlib import Path
@@ -18,7 +19,8 @@ KINDS = {":", "*", "F", "R", "G"}
def _empty(lyrics: list[list[str]]) -> dict:
return {"lyrics": lyrics, "lines": {}, "stale": []}
# grid: the BPM and GAP set in the editor, or None for the pipeline's own
return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None}
def load(work: Path, lyrics: list[list[str]]) -> dict:
@@ -41,7 +43,7 @@ def load(work: Path, lyrics: list[list[str]]) -> dict:
def save(work: Path, data: dict) -> None:
path = work / EDITS_FILE
if not data["lines"] and not data["stale"]:
if not data["lines"] and not data["stale"] and not data.get("grid"):
path.unlink(missing_ok=True)
return
tmp = path.with_suffix(".tmp")
@@ -64,7 +66,7 @@ def _remap(data: dict, lyrics: list[list[str]]) -> dict:
stale.append(edit)
else:
lines[str(to)] = {**edit, "line_index": to}
return {"lyrics": lyrics, "lines": lines, "stale": stale}
return {"lyrics": lyrics, "lines": lines, "stale": stale, "grid": data.get("grid")}
def validate_notes(notes: list[dict]) -> list[dict]:
@@ -97,6 +99,25 @@ def clear_line(data: dict, index: int) -> bool:
return data["lines"].pop(str(index), None) is not None
def set_grid(data: dict, bpm, gap_ms) -> None:
"""Set the chart's BPM (UltraStar's, a quarter of a beat per beat) and GAP."""
try:
bpm, gap_ms = float(bpm), float(gap_ms)
except (TypeError, ValueError):
raise ValueError("bpm and gap_ms must be numbers")
if not (math.isfinite(bpm) and 0 < bpm <= 2000):
raise ValueError(f"bpm must be above 0 and at most 2000, not {bpm}")
if not (math.isfinite(gap_ms) and gap_ms >= 0):
raise ValueError(f"gap_ms must be 0 or more, not {gap_ms}")
data["grid"] = {"bpm": bpm, "gap_ms": gap_ms}
def clear_grid(data: dict) -> bool:
had = bool(data.get("grid"))
data["grid"] = None
return had
def merge(notes: list[dict], breaks: list[float], data: dict
) -> tuple[list[dict], list[float]]:
"""Generated notes and breaks with every edited line laid over them.
@@ -168,6 +189,6 @@ def finalize(work: Path, lyrics: list[list[str]]) -> dict:
" the lyrics; set aside in the editor")
final = {"notes": notes, "breaks": breaks, "warnings": warnings,
"edited_lines": sorted(int(k) for k in data["lines"]),
"stale": data["stale"]}
"stale": data["stale"], "grid": data.get("grid")}
(work / FINAL_FILE).write_text(json.dumps(final))
return final
+24 -9
View File
@@ -7,6 +7,9 @@ from .ultrastar import (LineBreak, Note, UltraStarSong, format_bpm, get_header,
parse_bpm, parse_ultrastar, serialize_ultrastar)
TEMPO_FILE = "tempo.json"
def detect_bpm(instrumental_path: Path) -> float:
import librosa
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True)
@@ -26,11 +29,20 @@ def scale_bpm(bpm: float) -> float:
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
headers: list[tuple[str, str]]) -> UltraStarSong:
headers: list[tuple[str, str]],
gap_ms: float | None = None) -> UltraStarSong:
"""Snap notes to the beat grid. GAP defaults to the first note's start; a
GAP set later than that would put notes on negative beats, which players
reject, so it moves back by whole beats, keeping the grid where it was."""
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)
first_ms = notes[0]["start"] * 1000
if gap_ms is None:
gap_ms = first_ms
elif gap_ms > first_ms:
gap_ms -= math.ceil((gap_ms - first_ms) / (spb * 1000)) * spb * 1000
gap_ms = max(0, round(gap_ms))
def beat(t: float) -> int:
return round((t - gap_ms / 1000) / spb)
@@ -59,15 +71,18 @@ def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
return UltraStarSong(headers=out_headers, body=body)
def rechart(txt_path: Path, notes: list[dict], breaks: list[float]) -> None:
"""Replace a chart's notes, keeping its headers and its BPM.
def rechart(txt_path: Path, notes: list[dict], breaks: list[float],
bpm: float | None = None, gap_ms: float | None = None) -> None:
"""Replace a chart's notes, keeping its headers and, unless given new
ones, its BPM.
For an edit saved from the editor: the tempo was settled when the song
was built, and the packaged chart's headers may differ from the work
copy's (a re-encoded #VIDEO, a dropped #COVER), so each keeps its own.
For an edit saved from the editor: the packaged chart's headers may differ
from the work copy's (a re-encoded #VIDEO, a dropped #COVER), so each
keeps its own.
"""
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
bpm = parse_bpm(get_header(song, "BPM"))
if bpm is None:
bpm = parse_bpm(get_header(song, "BPM"))
headers = [(k, v) for k, v in song.headers if k.upper() not in ("BPM", "GAP")]
txt_path.write_text(serialize_ultrastar(to_ultrastar(notes, breaks, bpm, headers)),
txt_path.write_text(serialize_ultrastar(to_ultrastar(notes, breaks, bpm, headers, gap_ms)),
encoding="utf-8")
+7 -1
View File
@@ -19,6 +19,12 @@ def _run(ctx: Ctx) -> None:
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?")
# the pipeline's own tempo, which RESET in the editor returns to
(ctx.work / grid.TEMPO_FILE).write_text(json.dumps({"bpm": bpm}))
gap_ms = None
if data["grid"]:
bpm, gap_ms = data["grid"]["bpm"], data["grid"]["gap_ms"]
ctx.log("[grid] using the BPM and GAP set in the editor")
slug = song_slug(ctx.inp)
video_ext = Path(meta["video_file"]).suffix
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
@@ -31,7 +37,7 @@ def _run(ctx: Ctx) -> None:
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)
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers, gap_ms)
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")
+33
View File
@@ -418,6 +418,39 @@ button { cursor: pointer; }
}
.sheet-badge-edited { color: var(--aqua); }
.sheet-badge-low { color: var(--fail); }
.sheet-grid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid var(--border);
}
.sheet-grid[hidden] { display: none; }
.sheet-grid-head {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.16em;
color: var(--aqua);
}
.sheet-grid-field {
display: flex;
align-items: center;
gap: 6px;
margin-left: 6px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.66rem;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.sheet-grid-field input { width: 6.5em; padding: 3px 2px; font: 0.85rem var(--font-mono); }
.sheet-grid .action:disabled { opacity: 0.35; cursor: default; }
.sheet-grid .sheet-save:not(:disabled) { color: var(--bg); background: var(--purple); border-color: var(--purple); }
.sheet-grid-source { flex: 1; min-width: 7em; font-size: 0.76rem; text-align: right; color: var(--text-muted); }
.sheet-grid-pending { color: var(--warn); }
.sheet-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 12px; }
.sheet-tool-group { display: flex; gap: 6px; }
.sheet-tools .action:disabled { opacity: 0.35; cursor: default; }
+120 -16
View File
@@ -199,7 +199,47 @@ export function createSheet(root, { seek, playLine, onSaved }) {
keys.append(section);
}
const stale = div("sheet-stale");
root.replaceChildren(nav, stale, toolbar, keys, plot, syllables, words, help);
const gridBar = div("sheet-grid");
const field = (label, step, unit) => {
const wrap = document.createElement("label");
wrap.className = "sheet-grid-field";
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = "0";
wrap.append(label, input);
if (unit) wrap.append(unit);
return { wrap, input };
};
const bpmField = field("BPM", "0.01");
const gapField = field("GAP", "1", "ms");
const gridSource = div("sheet-grid-source");
const gridTools = {
double: button("×2", "double the BPM", () => tryGrid({ bpm: grid().bpm * 2 })),
half: button("÷2", "halve the BPM", () => tryGrid({ bpm: grid().bpm / 2 })),
earlier: button("−10", "GAP 10ms earlier", () => tryGrid({ gap_ms: grid().gap_ms - 10 })),
later: button("+10", "GAP 10ms later", () => tryGrid({ gap_ms: grid().gap_ms + 10 })),
align: button("ALIGN TO NOTE", "move GAP the least that puts the selected note on a beat",
() => alignToNote()),
cancel: button("CANCEL", "drop the BPM and GAP being tried", () => { gridDraft = null; render(); }),
reset: button("RESET", "back to the BPM songtrace chose, with GAP at the first note",
() => resetGrid()),
apply: button("APPLY", "chart the song on this BPM and GAP", () => applyGrid(),
"action sheet-save"),
};
const gridHead = div("sheet-grid-head", "GRID");
gridBar.append(gridHead, bpmField.wrap, gridTools.double, gridTools.half,
gapField.wrap, gridTools.earlier, gridTools.later, gridTools.align,
gridSource, gridTools.cancel, gridTools.reset, gridTools.apply);
for (const [f, key] of [[bpmField, "bpm"], [gapField, "gap_ms"]]) {
f.input.addEventListener("input", () => {
const v = Number(f.input.value);
if (f.input.value !== "" && Number.isFinite(v) && v >= 0 && (key === "gap_ms" || v > 0)) {
tryGrid({ [key]: v }, f.input);
}
});
}
root.replaceChildren(nav, stale, gridBar, toolbar, keys, plot, syllables, words, help);
// stays as the person left it, across lines, songs and visits
function showKeys(open) {
@@ -231,7 +271,11 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l));
const notes = () => preview ?? hist?.present ?? [];
const dirty = () => !!hist && hist.present !== baseline;
const grid = () => review?.grid;
let gridDraft = null; // BPM and GAP being tried out, not yet applied
// the grid being drawn and snapped to: the one being tried out, if any
const grid = () => gridDraft ?? review?.grid;
// notes snap to the grid, so they wait while a new one is being tried
const canEdit = () => !!review?.grid && !gridDraft && !saving;
function say(text, error = false) {
status.textContent = text;
@@ -344,28 +388,32 @@ export function createSheet(root, { seek, playLine, onSaved }) {
open(index);
}
async function send(method, body) {
// a change saved to the server, which answers with the refreshed review
async function send(path, method, body, busy, done) {
saving = true;
render();
say(method === "PUT" ? "saving…" : "reverting…");
say(busy);
try {
const resp = await fetch(`/api/jobs/${jobId}/lines/${index}`, {
const resp = await fetch(`/api/jobs/${jobId}/${path}`, {
method, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
say(err.detail || `error ${resp.status}`, true);
return;
return false;
}
const data = await resp.json();
const at = index;
review = data;
gridDraft = null;
open(at);
say(method === "PUT" ? "saved" : "reverted to the generated notes");
say(done);
onSaved(data);
return true;
} catch {
say("could not reach songtrace", true);
return false;
} finally {
saving = false;
render();
@@ -374,16 +422,69 @@ export function createSheet(root, { seek, playLine, onSaved }) {
function save() {
if (!dirty() || saving) return;
send("PUT", { notes: edit.toSaved(notes()) });
send(`lines/${index}`, "PUT", { notes: edit.toSaved(notes()) }, "saving…", "saved");
}
function revert() {
if (!review.lines[index].edited || saving) return;
if (!confirm("Go back to the generated notes for this line? Your edit is lost.")) return;
send("DELETE");
send(`lines/${index}`, "DELETE", null, "reverting…", "reverted to the generated notes");
}
function render() {
// redraws on the trial grid straight away; nothing is saved until APPLY
function tryGrid(change, typing = null) {
const next = { ...grid(), ...change };
next.gap_ms = Math.max(0, next.gap_ms);
const same = review.grid && next.bpm === review.grid.bpm && next.gap_ms === review.grid.gap_ms;
gridDraft = same ? null : next;
render(typing);
}
function alignToNote() {
const note = notes().find(n => selected.has(n.id));
if (!note) return;
const g = grid();
const spbMs = 60000 / (4 * g.bpm);
const atMs = note.start * 1000;
const offset = (atMs - g.gap_ms) - Math.round((atMs - g.gap_ms) / spbMs) * spbMs;
tryGrid({ gap_ms: Math.round((g.gap_ms + offset) * 100) / 100 });
}
async function applyGrid() {
if (!gridDraft || saving) return;
if (dirty()) { say("save or discard this line's changes first", true); return; }
await send("grid", "PUT", gridDraft, "charting on the new grid…", "grid applied");
}
async function resetGrid() {
if (!review.grid_edited || saving) return;
if (dirty()) { say("save or discard this line's changes first", true); return; }
await send("grid", "DELETE", null, "resetting the grid…",
"grid reset to the BPM songtrace chose");
}
function paintGrid(typing) {
const g = grid();
gridBar.hidden = !review.grid;
if (!g) return;
const round = (v, d) => String(Math.round(v * 10 ** d) / 10 ** d);
// the field being typed in keeps what was typed, half-finished or not
if (typing !== bpmField.input) bpmField.input.value = round(g.bpm, 2);
if (typing !== gapField.input) gapField.input.value = round(g.gap_ms, 2);
gridSource.textContent = gridDraft ? "not applied"
: review.grid_edited ? "set in the editor"
: review.auto_bpm ? "songtrace's choice" : "";
gridSource.classList.toggle("sheet-grid-pending", !!gridDraft);
gridTools.align.disabled = saving || selected.size !== 1;
gridTools.cancel.disabled = !gridDraft || saving;
gridTools.apply.disabled = !gridDraft || saving;
gridTools.reset.disabled = !review.grid_edited || !!gridDraft || saving;
for (const b of [gridTools.double, gridTools.half, gridTools.earlier, gridTools.later]) {
b.disabled = saving;
}
}
function render(typing = null) {
if (!review || index === null) return;
const line = review.lines[index];
const { t0, t1, lo, hi } = layout;
@@ -492,6 +593,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
paintTools();
paintGrid(typing);
}
function paintSelection() {
@@ -501,11 +603,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
: "sheet-syllable-selected", on);
}
paintTools();
paintGrid();
}
function paintTools() {
const line = review.lines[index];
const editable = !!grid() && !saving;
const editable = canEdit();
tools.undo.disabled = !editable || !hist.canUndo;
tools.redo.disabled = !editable || !hist.canRedo;
tools.split.disabled = tools.merge.disabled = !editable || one() === null;
@@ -542,7 +645,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
if (!view) return;
if (!drag) {
const { note, mode } = hit(e);
plot.style.cursor = !note || !grid() ? "crosshair"
plot.style.cursor = !note || !canEdit() ? "crosshair"
: mode === "move" ? "grab" : "ew-resize";
return;
}
@@ -570,7 +673,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.focus();
const { px, py, note, mode } = hit(e);
if (px < GUTTER) return;
if (note && grid()) {
if (note && canEdit()) {
if (e.shiftKey) {
selected = new Set(selected);
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
@@ -603,7 +706,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.addEventListener("pointercancel", (e) => endDrag(e, true));
plot.addEventListener("dblclick", (e) => {
if (!view || !grid()) return;
if (!view || !canEdit()) return;
const { px, py, note } = hit(e);
if (px < GUTTER) return;
if (note) { editText(note.id); return; }
@@ -662,7 +765,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; }
if (key === "PageUp") { handled(); page(-1); return; }
if (key === "PageDown") { handled(); page(1); return; }
if (ctrl || e.altKey || !grid()) return;
if (ctrl || e.altKey || !canEdit()) return;
const ns = notes();
if (key === "Tab") {
handled();
@@ -719,6 +822,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
if (!same) {
jobId = id;
curve = null;
gridDraft = null;
fetch(`/api/jobs/${id}/pitch`)
.then(r => (r.ok ? r.json() : null))
.then(c => { if (jobId === id) { curve = c; render(); } })
@@ -733,7 +837,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
jobId = null;
curve = null;
index = null;
view = layout = hist = baseline = preview = null;
view = layout = hist = baseline = preview = gridDraft = null;
root.hidden = true;
},
// playback following the song: never at the cost of unsaved edits
+37 -2
View File
@@ -504,7 +504,8 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
media = {kind: _resolve_media(job, header) is not None
for kind, header in MEDIA_HEADERS.items()}
return {"lines": lines, "missing_spans": missing_spans, "media": media,
"stale": stale, "grid": _chart_grid(work)}
"stale": stale, "grid": _chart_grid(work),
"grid_edited": bool(notes_data.get("grid")), "auto_bpm": _auto_bpm(work)}
@app.get("/api/jobs/{job_id}/pitch")
def get_pitch(job_id: str):
@@ -562,16 +563,50 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
raise HTTPException(400, str(e))
edits.save(work, data)
final = edits.finalize(work, lyrics)
# the editor's grid, else the pipeline's own tempo with GAP at
# the first note, as the grid stage would chart it
chosen = final["grid"] or {"bpm": _auto_bpm(work), "gap_ms": None}
charts = [work / "song.txt"]
if job.output_dir:
charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt")
for txt in charts:
if txt.exists():
grid.rechart(txt, final["notes"], final["breaks"])
grid.rechart(txt, final["notes"], final["breaks"],
chosen["bpm"], chosen["gap_ms"])
with lock:
job.warnings = list(final["warnings"])
return get_review(job_id)
def _auto_bpm(work: Path) -> float | None:
"""The tempo the pipeline chose: recorded by grid, or for a song built
before that was, the one its chart already has."""
try:
return float(json.loads((work / grid.TEMPO_FILE).read_text())["bpm"])
except (OSError, ValueError, KeyError):
g = _chart_grid(work)
return g["bpm"] if g else None
@app.put("/api/jobs/{job_id}/grid")
def save_grid(job_id: str, body: dict = Body(...)):
"""Set the chart's BPM and GAP, kept through rebuilds like any edit."""
job = _editable(job_id)
work = cfg.work_dir / song_slug(job.inp)
tempo = work / grid.TEMPO_FILE
# a song built before grid recorded its tempo: the chart's, taken now
# while it is still the pipeline's, is what RESET must return to
if not tempo.exists() and (bpm := _auto_bpm(work)) is not None:
tempo.write_text(json.dumps({"bpm": bpm}))
return _edit(job_id, lambda data, lyrics: edits.set_grid(
data, body.get("bpm"), body.get("gap_ms")))
@app.delete("/api/jobs/{job_id}/grid")
def reset_grid(job_id: str):
"""Go back to the BPM the pipeline chose and GAP at the first note."""
def change(data, lyrics):
if not edits.clear_grid(data):
raise HTTPException(404, "the grid was not set in the editor")
return _edit(job_id, change)
def _line(lyrics, index: int) -> int:
if not 0 <= index < len(lyrics):
raise HTTPException(404, f"no line {index}")
+30
View File
@@ -122,3 +122,33 @@ def test_finalize_reports_stale_edits(tmp_path):
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
"set aside in the editor"]
def test_a_grid_set_in_the_editor_is_checked():
data = edits._empty(LYRICS)
edits.set_grid(data, "240.5", 1500)
assert data["grid"] == {"bpm": 240.5, "gap_ms": 1500.0}
for bpm, gap in ((0, 0), (-1, 0), (float("nan"), 0), (3000, 0), (300, -1), ("x", 0)):
with pytest.raises(ValueError):
edits.set_grid(data, bpm, gap)
def test_a_grid_alone_keeps_the_edits_file_and_survives_new_lyrics(tmp_path):
data = edits._empty(LYRICS)
edits.set_grid(data, 300, 1000)
edits.save(tmp_path, data)
assert (tmp_path / edits.EDITS_FILE).exists()
loaded = edits.load(tmp_path, [["zero"]] + LYRICS)
assert loaded["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
assert edits.clear_grid(loaded) and not edits.clear_grid(loaded)
edits.save(tmp_path, loaded)
assert not (tmp_path / edits.EDITS_FILE).exists()
def test_finalize_reports_the_grid(tmp_path):
_write_generated(tmp_path, GENERATED, [])
assert edits.finalize(tmp_path, LYRICS)["grid"] is None
data = edits._empty(LYRICS)
edits.set_grid(data, 300, 1000)
edits.save(tmp_path, data)
assert edits.finalize(tmp_path, LYRICS)["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
+42 -1
View File
@@ -222,7 +222,7 @@ def test_grid_stage_honours_zero_bpm_override(tmp_path, monkeypatch):
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
captured = {}
def fake_to_ultrastar(notes, breaks, file_bpm, headers):
def fake_to_ultrastar(notes, breaks, file_bpm, headers, gap_ms=None):
captured["bpm"] = file_bpm
return to_ultrastar(notes, breaks, 240.0, headers)
@@ -299,3 +299,44 @@ def test_rechart_replaces_the_notes_and_keeps_headers_and_tempo(tmp_path):
assert [k for k, _ in song.headers] == ["TITLE", "VIDEO", "BPM", "GAP"]
assert get_header(song, "BPM") == "300" and get_header(song, "GAP") == "2000"
assert [(n.kind, n.pitch, n.text) for n in song.body] == [("F", 7, "new")]
def test_an_explicit_gap_sets_the_grid():
# 300 BPM: 0.05s beats; notes at 1.02s and 1.27s against GAP 1.0s
song = to_ultrastar([note(1.02, 0.1), note(1.27, 0.1)], [], 300.0, [], gap_ms=1000)
assert get_header(song, "GAP") == "1000"
assert [n.beat for n in song.body] == [0, 5]
def test_a_gap_after_the_first_note_moves_back_by_whole_beats():
# GAP 1.13s is after the first note at 1.02s: back 3 beats to 0.98s, so
# the grid lines stay where they were set and the note is on beat 1
song = to_ultrastar([note(1.02, 0.1)], [], 300.0, [], gap_ms=1130)
assert get_header(song, "GAP") == "980"
assert song.body[0].beat == 1
def test_the_grid_stage_uses_the_editors_grid_and_records_its_own(tmp_path, monkeypatch):
from songtrace import edits
work = tmp_path / "w"
write_work(work)
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
ctx = make_ctx(work)
data = edits.load(work, [["x"]])
edits.set_grid(data, 300.0, 900)
edits.save(work, data)
grid_stage_mod.grid_stage().run(ctx)
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "900")
assert json.loads((work / "tempo.json").read_text()) == {"bpm": 240.0}
def test_rechart_can_set_a_new_bpm_and_gap(tmp_path):
from songtrace.grid import rechart
from songtrace.ultrastar import serialize_ultrastar
txt = tmp_path / "song.txt"
txt.write_text(serialize_ultrastar(to_ultrastar([note(1.0, 0.5)], [], 240.0, [])),
encoding="utf-8")
rechart(txt, [note(1.02, 0.1)], [], 300.0, 1000)
song = parse_ultrastar(txt.read_text(encoding="utf-8"))
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "1000")
+46
View File
@@ -2003,3 +2003,49 @@ def test_pitch_curve_is_404_without_a_pitch_track(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
assert client.get(f"/api/jobs/{job_id}/pitch").status_code == 404
def _chart_header(path, key):
from songtrace.ultrastar import get_header, parse_ultrastar
return get_header(parse_ultrastar(path.read_text(encoding="utf-8")), key)
def test_setting_the_grid_recharts_and_survives_line_saves(tmp_path):
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
resp = client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
assert resp.status_code == 200
body = resp.json()
assert body["grid"] == {"bpm": 240.0, "gap_ms": 900.0} and body["grid_edited"]
packaged = cfg.output_dir / "A - T" / "A - T.txt"
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
def test_resetting_the_grid_returns_to_the_pipelines_tempo(tmp_path):
client, cfg = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
(cfg.work_dir / "A - T" / "tempo.json").write_text(json.dumps({"bpm": 300.0}))
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
resp = client.delete(f"/api/jobs/{job_id}/grid")
assert resp.status_code == 200
body = resp.json()
assert body["grid"] == {"bpm": 300.0, "gap_ms": 1000.0} # GAP back at the first note
assert not body["grid_edited"] and body["auto_bpm"] == 300.0
assert client.delete(f"/api/jobs/{job_id}/grid").status_code == 404
def test_a_bad_grid_is_refused(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client)
for body in ({"bpm": 0, "gap_ms": 0}, {"bpm": 300, "gap_ms": -5}, {"bpm": "x"}, {}):
assert client.put(f"/api/jobs/{job_id}/grid", json=body).status_code == 400
def test_reset_on_a_song_built_before_tempo_was_recorded_restores_its_bpm(tmp_path):
client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client) # charted at 300 BPM, no tempo.json
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
body = client.delete(f"/api/jobs/{job_id}/grid").json()
assert body["grid"]["bpm"] == 300.0 and body["auto_bpm"] == 300.0