From e65f62b45996e972873009e0f67c1f0c56b815ad Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 23:13:45 -0700 Subject: [PATCH 1/3] webui: a grouped keyboard shortcut panel for the sheet The sheet's controls were listed in one run-on line under it, which was hard to scan and grew with every feature. Replace it with a panel grouped the way an editor's reference card is (select, move, length, notes, kind, syllable, line) with keys drawn as key caps, opened by a KEYS button or ?, closed by Esc, and remembered open or closed across visits. The list is data (SHORTCUTS), and a test checks every letter key it names is one the sheet's key handler handles, so the two cannot drift apart. --- src/songtrace/static/index.html | 36 +++++++++++ src/songtrace/static/sheet.js | 105 +++++++++++++++++++++++++++++--- tests/js/sheet.test.mjs | 25 ++++++++ 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html index 2267835..6a77078 100644 --- a/src/songtrace/static/index.html +++ b/src/songtrace/static/index.html @@ -443,6 +443,42 @@ button { cursor: pointer; } .sheet-stale-head { color: var(--warn); margin-bottom: 6px; } .sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; } .sheet-stale-text { flex: 1; color: var(--text-muted); } +.sheet-nav > .action[aria-expanded] { margin-left: 4px; } +.sheet-keys { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px 22px; + margin-bottom: 14px; + padding: 12px 14px; + background: var(--panel-raised); + border: 1px solid var(--border); +} +.sheet-keys[hidden] { display: none; } +.sheet-keys-head { + margin-bottom: 6px; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--aqua); +} +.sheet-keys dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; margin: 0; font-size: 0.78rem; } +.sheet-keys dt { white-space: nowrap; color: var(--text-muted); } +.sheet-keys dd { margin: 0; color: var(--text); } +.sheet-keys kbd { + display: inline-block; + min-width: 1.4em; + padding: 0 5px; + font: 0.72rem var(--font-mono); + text-align: center; + color: var(--text); + background: var(--panel); + border: 1px solid var(--control); + border-bottom-width: 2px; + border-radius: 3px; +} +.sheet-help[hidden] { display: none; } .sheet-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); } .sheet-svg { display: block; } .sheet-row { fill: var(--panel-raised); } diff --git a/src/songtrace/static/sheet.js b/src/songtrace/static/sheet.js index e84eb2b..f1f7901 100644 --- a/src/songtrace/static/sheet.js +++ b/src/songtrace/static/sheet.js @@ -12,6 +12,53 @@ const PLOT_HEIGHT = 300; // px; rows divide this between the pitches shown const CONTEXT = 0.75; // seconds shown either side of the line const EDGE = 6; // px from a note's end that grabs it for resizing const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" }; +const KEYS_OPEN = "songtrace.sheet-keys"; + +// The sheet's controls, grouped as an editor's reference card lists them. +// "+" joins keys pressed together, " / " separates alternatives, and a +// lowercase word is something done with the mouse rather than a key. +export const SHORTCUTS = [ + ["Select", [ + ["click", "select a note"], + ["Shift+click", "add to the selection"], + ["Tab / Shift+Tab", "next, previous note"], + ["Esc", "clear the selection"]]], + ["Move", [ + ["drag", "move in time and pitch"], + ["← / →", "a beat earlier, later"], + ["↑ / ↓", "a semitone up, down"], + ["Shift+↑ / Shift+↓", "an octave up, down"]]], + ["Length", [ + ["drag an end", "resize"], + ["Shift+← / Shift+→", "a beat shorter, longer"]]], + ["Notes", [ + ["double-click", "add a note, on empty space"], + ["S", "split in two"], + ["M", "merge with the next"], + ["Delete", "remove"]]], + ["Kind", [ + ["G", "golden"], + ["F", "freestyle"], + ["R", "rap"], + ["G / F / R", "again: back to normal"]]], + ["Syllable", [ + ["double-click", "edit, on a note"], + ["Enter", "edit the selected note"]]], + ["Line", [ + ["Ctrl+Z", "undo"], + ["Ctrl+Shift+Z / Ctrl+Y", "redo"], + ["Ctrl+S", "save"], + ["P", "play"], + ["Page Up / Page Down", "previous, next line"], + ["?", "show or hide these keys"]]], +]; + +// "Shift+Tab / Esc" as key caps, with mouse actions left as words +export function comboParts(combo) { + return combo.split(" / ").map(alt => alt.split("+").map(part => ({ + text: part, key: !/^[a-z]/.test(part), + }))); +} const wrap = (n, d) => ((n % d) + d) % d; const mod12 = (p) => wrap(p, 12); @@ -88,7 +135,9 @@ export function createSheet(root, { seek, playLine, onSaved }) { const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1)); const next = button("NEXT ▶", "next line (Page Down)", () => page(1)); const nav = div("sheet-nav"); - nav.append(prev, title, next, badges); + const keysButton = button("KEYS ?", "keyboard shortcuts (?)", () => toggleKeys()); + keysButton.setAttribute("aria-expanded", "false"); + nav.append(prev, title, next, badges, keysButton); const tools = { undo: button("UNDO", "Ctrl+Z", () => undo()), @@ -123,12 +172,48 @@ export function createSheet(root, { seek, playLine, onSaved }) { plot.append(svg, playhead); const syllables = div("sheet-syllables"); const words = div("sheet-words"); - const help = div("sheet-help", - "Drag a note to move it, its ends to resize · double-click empty space to add a note, " + - "a note to edit its syllable · ←→ move · ↑↓ pitch (Shift: octave) · " + - "Shift+←→ length · Tab next note · Delete · S split · M merge · G/F/R kind"); + const help = div("sheet-help", "Press ? for keyboard shortcuts."); + const keys = div("sheet-keys"); + keys.id = "sheet-keys"; + keysButton.setAttribute("aria-controls", keys.id); + for (const [group, entries] of SHORTCUTS) { + const section = div("sheet-keys-group"); + const heading = div("sheet-keys-head", group); + const list = document.createElement("dl"); + for (const [combo, action] of entries) { + const dt = document.createElement("dt"); + comboParts(combo).forEach((alt, i) => { + if (i) dt.append(" / "); + alt.forEach((part, j) => { + if (j) dt.append("+"); + const node = document.createElement(part.key ? "kbd" : "span"); + node.textContent = part.text; + dt.append(node); + }); + }); + const dd = document.createElement("dd"); + dd.textContent = action; + list.append(dt, dd); + } + section.append(heading, list); + keys.append(section); + } const stale = div("sheet-stale"); - root.replaceChildren(nav, stale, toolbar, plot, syllables, words, help); + root.replaceChildren(nav, stale, toolbar, keys, plot, syllables, words, help); + + // stays as the person left it, across lines, songs and visits + function showKeys(open) { + keys.hidden = !open; + help.hidden = open; + keysButton.setAttribute("aria-expanded", String(open)); + try { localStorage.setItem(KEYS_OPEN, open ? "1" : ""); } catch {} + } + function toggleKeys() { + showKeys(keys.hidden); + } + let keysOpen = false; + try { keysOpen = !!localStorage.getItem(KEYS_OPEN); } catch {} + showKeys(keysOpen); let review = null; let curve = null; @@ -589,7 +674,13 @@ export function createSheet(root, { seek, playLine, onSaved }) { render(); return; } - if (key === "Escape") { handled(); selected = new Set(); render(); return; } + if (key === "?") { handled(); toggleKeys(); return; } + if (key === "Escape") { + handled(); + if (!keys.hidden) showKeys(false); + else { selected = new Set(); render(); } + return; + } if (key.toLowerCase() === "p") { handled(); playCurrent(); return; } if (!selected.size) return; const arrows = { ArrowLeft: -1, ArrowRight: 1 }; diff --git a/tests/js/sheet.test.mjs b/tests/js/sheet.test.mjs index c75d19b..52a28c9 100644 --- a/tests/js/sheet.test.mjs +++ b/tests/js/sheet.test.mjs @@ -45,3 +45,28 @@ test("a line spans its notes and words", () => { { start: 1.5, end: 3 }); assert.equal(lineSpan({ notes: [], words: [] }), null); }); + +import { readFileSync } from "node:fs"; +import { SHORTCUTS, comboParts } from "../../src/songtrace/static/sheet.js"; + +test("shortcut combos split into keys and mouse actions", () => { + assert.deepEqual(comboParts("Shift+click / Tab"), [ + [{ text: "Shift", key: true }, { text: "click", key: false }], + [{ text: "Tab", key: true }], + ]); + assert.deepEqual(comboParts("Page Up"), [[{ text: "Page Up", key: true }]]); +}); + +test("every letter key the shortcut list names is one the sheet handles", () => { + const source = readFileSync(new URL("../../src/songtrace/static/sheet.js", import.meta.url), "utf8"); + const handler = source.slice(source.indexOf('plot.addEventListener("keydown"')); + const letters = SHORTCUTS.flatMap(([, entries]) => entries) + .flatMap(([combo]) => comboParts(combo).flat()) + .filter(p => p.key && /^[A-Z]$/.test(p.text)) + .map(p => p.text.toLowerCase()); + assert.ok(letters.length >= 6); + for (const letter of new Set(letters)) { + assert.ok(handler.includes(`"${letter}"`) || handler.includes(`${letter}:`), + `the list names ${letter.toUpperCase()} but the sheet does not handle it`); + } +}); -- 2.54.0 From ae5ca3465be487f277770b944fed4cd337de3d4b Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 13 Oct 2026 16:08:57 -0700 Subject: [PATCH 2/3] webui: set a song's BPM and GAP from the sheet 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. --- README.md | 7 ++ src/songtrace/edits.py | 29 ++++++- src/songtrace/grid.py | 33 +++++--- src/songtrace/stages/grid.py | 8 +- src/songtrace/static/index.html | 33 ++++++++ src/songtrace/static/sheet.js | 136 ++++++++++++++++++++++++++++---- src/songtrace/web.py | 39 ++++++++- tests/test_edits.py | 30 +++++++ tests/test_grid.py | 43 +++++++++- tests/test_web.py | 46 +++++++++++ 10 files changed, 371 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 69a7a97..61654cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/songtrace/edits.py b/src/songtrace/edits.py index 116502f..de0bcdb 100644 --- a/src/songtrace/edits.py +++ b/src/songtrace/edits.py @@ -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 diff --git a/src/songtrace/grid.py b/src/songtrace/grid.py index b594f75..88f2803 100644 --- a/src/songtrace/grid.py +++ b/src/songtrace/grid.py @@ -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") diff --git a/src/songtrace/stages/grid.py b/src/songtrace/stages/grid.py index 17f2629..f63ee4d 100644 --- a/src/songtrace/stages/grid.py +++ b/src/songtrace/stages/grid.py @@ -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") diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html index 6a77078..1f448c5 100644 --- a/src/songtrace/static/index.html +++ b/src/songtrace/static/index.html @@ -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; } diff --git a/src/songtrace/static/sheet.js b/src/songtrace/static/sheet.js index f1f7901..34e7887 100644 --- a/src/songtrace/static/sheet.js +++ b/src/songtrace/static/sheet.js @@ -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 diff --git a/src/songtrace/web.py b/src/songtrace/web.py index 728dc60..7179e60 100644 --- a/src/songtrace/web.py +++ b/src/songtrace/web.py @@ -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}") diff --git a/tests/test_edits.py b/tests/test_edits.py index 7cb3b7b..40a3708 100644 --- a/tests/test_edits.py +++ b/tests/test_edits.py @@ -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} diff --git a/tests/test_grid.py b/tests/test_grid.py index f73fe22..a0c2c05 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -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") diff --git a/tests/test_web.py b/tests/test_web.py index b21a0d8..3f7a405 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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 -- 2.54.0 From ce44fb3d792a56196a4df55582199b279e388ea2 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 13 Oct 2026 16:24:17 -0700 Subject: [PATCH 3/3] webui: move line breaks on the sheet Line breaks were always placed by songtrace, midway between lines and recomputed around edited ones, so a break could not be put where a singer breathes. The sheet now draws the breaks either side of a line as markers that drag along the beat grid, kept between the lines they separate, and double-click back to automatic. Break moves share the line's undo history and SAVE LINE, which saves the notes only if they changed, so moving a break never locks either line's notes. Moved breaks are stored in edits.json keyed by the line each comes before, follow their lines when the lyrics change, and win over the computed break on every rebuild, clamped so a break never lands inside a line's notes. The review lists each break with that line and whether it was moved, and PUT /api/jobs/{id}/breaks takes {line: seconds | null}. merge now passes untouched notes through as they are rather than rebuilding them, which added float noise to their durations. --- README.md | 5 ++ src/songtrace/edits.py | 57 +++++++++++--- src/songtrace/static/edit-model.js | 8 ++ src/songtrace/static/index.html | 5 ++ src/songtrace/static/sheet.js | 122 ++++++++++++++++++++++++++--- src/songtrace/web.py | 17 +++- tests/js/edit-model.test.mjs | 7 ++ tests/test_edits.py | 35 +++++++++ tests/test_web.py | 25 ++++++ 9 files changed, 258 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 61654cb..824b585 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ 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 ↵ markers either side of a line are its line breaks: drag one to move it +(it snaps to beats and stays between the lines it separates) and double-click +one to put it back where songtrace places it. Moving a break does not mark +either line's notes as edited, so they still follow a rebuild. + 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 diff --git a/src/songtrace/edits.py b/src/songtrace/edits.py index de0bcdb..8a6b66c 100644 --- a/src/songtrace/edits.py +++ b/src/songtrace/edits.py @@ -19,8 +19,9 @@ KINDS = {":", "*", "F", "R", "G"} def _empty(lyrics: list[list[str]]) -> dict: - # grid: the BPM and GAP set in the editor, or None for the pipeline's own - return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None} + # grid: the BPM and GAP set in the editor, or None for the pipeline's own; + # breaks: line breaks moved by hand, keyed by the line each comes before + return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None, "breaks": {}} def load(work: Path, lyrics: list[list[str]]) -> dict: @@ -43,7 +44,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"] and not data.get("grid"): + if not (data["lines"] or data["stale"] or data.get("grid") or data.get("breaks")): path.unlink(missing_ok=True) return tmp = path.with_suffix(".tmp") @@ -66,7 +67,12 @@ 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, "grid": data.get("grid")} + # a moved break goes with the line it comes before; with that line gone, + # there is nothing left for it to separate + breaks = {str(moved[int(k)]): t for k, t in data.get("breaks", {}).items() + if int(k) in moved} + return {"lyrics": lyrics, "lines": lines, "stale": stale, "grid": data.get("grid"), + "breaks": breaks} def validate_notes(notes: list[dict]) -> list[dict]: @@ -99,6 +105,30 @@ def clear_line(data: dict, index: int) -> bool: return data["lines"].pop(str(index), None) is not None +def set_breaks(data: dict, changes: dict) -> None: + """Move line breaks, each keyed by the line it comes before, to a time in + seconds; None puts one back to where the notes alone would place it.""" + breaks = dict(data.get("breaks", {})) + for key, t in changes.items(): + try: + line = int(key) + except (TypeError, ValueError): + raise ValueError(f"a break is keyed by the line it comes before, not {key!r}") + if not 0 < line < len(data["lyrics"]): + raise ValueError(f"no line {line} for a break to come before") + if t is None: + breaks.pop(str(line), None) + continue + try: + t = float(t) + except (TypeError, ValueError): + raise ValueError(f"a break needs a time in seconds, not {t!r}") + if not (math.isfinite(t) and t >= 0): + raise ValueError(f"a break needs a time of 0 or more, not {t}") + breaks[str(line)] = t + data["breaks"] = breaks + + 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: @@ -125,10 +155,12 @@ def merge(notes: list[dict], breaks: list[float], data: dict An edited line replaces the generated notes of that line, and the user's timing wins: generated notes of earlier lines are cut back to end where it starts, and those of later lines to start where it ends. Line breaks next - to an edited line move to the middle of the gap it leaves. + to an edited line move to the middle of the gap it leaves; a break moved + by hand stays where it was put, kept between the lines it separates. """ edited = {int(k): e["notes"] for k, e in data["lines"].items()} - if not edited: + moved = {int(k): t for k, t in data.get("breaks", {}).items()} + if not edited and not moved: return notes, breaks spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"]) for li, ns in edited.items() if ns} @@ -143,7 +175,9 @@ def merge(notes: list[dict], breaks: list[float], data: dict end = min(end, a) elif li > eli: start = max(start, b) - if end - start >= MIN_NOTE_SEC: + if (start, end) == (n["start"], n["start"] + n["duration"]): + kept.append(n) + elif end - start >= MIN_NOTE_SEC: kept.append({**n, "start": start, "duration": end - start}) for li, ns in edited.items(): kept += [{**n, "line_index": li, "low_confidence": False} for n in ns] @@ -160,13 +194,17 @@ def merge(notes: list[dict], breaks: list[float], data: dict order = sorted(first, key=lambda li: first[li]["start"]) out_breaks = [] for prev, nxt in zip(order, order[1:]): + end = last[prev]["start"] + last[prev]["duration"] + if nxt in moved: + # where it was put, but never inside either line's notes + out_breaks.append(min(max(moved[nxt], end), max(first[nxt]["start"], end))) + continue if prev not in edited and nxt not in edited: # the generated break between these two lines, untouched gen = [t for t in breaks if line_after(t) == nxt] if gen: out_breaks.append(gen[0]) continue - end = last[prev]["start"] + last[prev]["duration"] out_breaks.append((end + first[nxt]["start"]) / 2) return kept, out_breaks @@ -189,6 +227,7 @@ 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"], "grid": data.get("grid")} + "stale": data["stale"], "grid": data.get("grid"), + "moved_breaks": sorted(int(k) for k in data.get("breaks", {}))} (work / FINAL_FILE).write_text(json.dumps(final)) return final diff --git a/src/songtrace/static/edit-model.js b/src/songtrace/static/edit-model.js index fb8971c..80231d2 100644 --- a/src/songtrace/static/edit-model.js +++ b/src/songtrace/static/edit-model.js @@ -148,3 +148,11 @@ export function history(initial) { }, }; } + +// Where a dragged line break lands: on a beat, but kept between the end of +// the line before it and the start of the line it introduces. +export function placeBreak(t, lo, hi, grid) { + const snapped = snap(t, grid); + if (hi < lo) return lo; + return Math.min(Math.max(snapped, lo), hi); +} diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html index 1f448c5..08ca9bb 100644 --- a/src/songtrace/static/index.html +++ b/src/songtrace/static/index.html @@ -522,6 +522,11 @@ button { cursor: pointer; } .sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; } .sheet-beat-1 { stroke: var(--control); opacity: 0.55; } .sheet-beat-2 { stroke: var(--text-muted); opacity: 0.8; } +.sheet-breaks { pointer-events: none; } +.sheet-break { stroke: var(--text-muted); stroke-width: 1.5; stroke-dasharray: 5 4; } +.sheet-break-moved { stroke: var(--aqua); stroke-dasharray: none; } +.sheet-break-label { fill: var(--text-muted); stroke: none; font: 12px var(--font-mono); } +.sheet-break-label.sheet-break-moved { fill: var(--aqua); stroke: none; } .sheet-curve { pointer-events: none; } .sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; } .sheet-note { fill: var(--purple); } diff --git a/src/songtrace/static/sheet.js b/src/songtrace/static/sheet.js index 34e7887..e31b192 100644 --- a/src/songtrace/static/sheet.js +++ b/src/songtrace/static/sheet.js @@ -41,6 +41,9 @@ export const SHORTCUTS = [ ["F", "freestyle"], ["R", "rap"], ["G / F / R", "again: back to normal"]]], + ["Line break", [ + ["drag a ↵ marker", "move it, between the lines it separates"], + ["double-click a ↵", "back to where songtrace puts it"]]], ["Syllable", [ ["double-click", "edit, on a note"], ["Enter", "edit the selected note"]]], @@ -264,12 +267,16 @@ export function createSheet(root, { seek, playLine, onSaved }) { let hist = null; // the current line's notes, with undo let baseline = null; // what the line holds on the server let preview = null; // notes mid-drag, not yet in the history + let breakPreview = null; // a break mid-drag: { which, t } let selected = new Set(); let drag = null; let saving = false; const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l)); - const notes = () => preview ?? hist?.present ?? []; + // the line being edited: its notes and the breaks either side of it, as + // one state so undo covers both. A break is its time, undefined where there + // is none to move, or null when it is to go back to automatic. + const notes = () => preview ?? hist?.present.notes ?? []; const dirty = () => !!hist && hist.present !== baseline; 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 @@ -291,6 +298,12 @@ export function createSheet(root, { seek, playLine, onSaved }) { const span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line); const range = pitchRange(notes().map(n => n.pitch)); const want = { t0: span.start - CONTEXT, t1: span.end + CONTEXT, ...range }; + // the breaks either side are part of the line's page, so they stay on it + for (const slot of breakSlots()) { + const t = breakTime(slot); + want.t0 = Math.min(want.t0, t - 0.3); + want.t1 = Math.max(want.t1, t + 0.3); + } layout = reset || !layout ? want : { t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1), lo: Math.min(layout.lo, want.lo), hi: Math.max(layout.hi, want.hi), @@ -326,8 +339,16 @@ export function createSheet(root, { seek, playLine, onSaved }) { function open(i) { index = i; const line = review.lines[i]; - baseline = edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" }))); + const lines = visible(); + const at = lines.findIndex(l => l.index === i); + const breakBefore = (l) => review.breaks?.find(b => b.line === l?.index)?.time; + baseline = { + notes: edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" }))), + before: at > 0 ? breakBefore(line) : undefined, + after: breakBefore(lines[at + 1]), + }; hist = edit.history(baseline); + breakPreview = null; preview = null; selected = new Set(); say(""); @@ -351,12 +372,47 @@ export function createSheet(root, { seek, playLine, onSaved }) { } function change(next, keep = selected) { - hist.push(next); + hist.push({ ...hist.present, notes: next }); selected = new Set([...keep].filter(id => next.some(n => n.id === id))); fit(false); render(); } + function changeBreak(which, t) { + if (hist.present[which] === t) return; + hist.push({ ...hist.present, [which]: t }); + render(); + } + + // the breaks either side of the line, each with the gap it may move in: + // from the end of the line before it to the start of the line after + function breakSlots() { + const ns = notes(); + if (!hist || !ns.length) return []; + const lines = visible(); + const at = lines.findIndex(l => l.index === index); + const prev = lines[at - 1]; + const next = lines[at + 1]; + const slots = []; + if (prev?.notes.length && hist.present.before !== undefined) { + slots.push({ which: "before", line: index, + lo: Math.max(...prev.notes.map(n => n.end)), hi: ns[0].start }); + } + if (next?.notes.length && hist.present.after !== undefined) { + slots.push({ which: "after", line: next.index, + lo: Math.max(...ns.map(n => n.end)), hi: Math.min(...next.notes.map(n => n.start)) }); + } + return slots; + } + + // where a break is drawn; one going back to automatic is shown mid-gap, + // roughly where it will land + function breakTime(slot) { + const v = breakPreview?.which === slot.which ? breakPreview.t : hist.present[slot.which]; + const t = v === null ? (slot.lo + slot.hi) / 2 : v; + return Math.min(Math.max(t, slot.lo), Math.max(slot.hi, slot.lo)); + } + const undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } }; const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } }; const one = () => (selected.size === 1 ? [...selected][0] : null); @@ -420,9 +476,20 @@ export function createSheet(root, { seek, playLine, onSaved }) { } } - function save() { + // only what changed: a moved break alone must not mark the line edited + async function save() { if (!dirty() || saving) return; - send(`lines/${index}`, "PUT", { notes: edit.toSaved(notes()) }, "saving…", "saved"); + const state = hist.present; + const lines = visible(); + const next = lines[lines.findIndex(l => l.index === index) + 1]; + const moved = {}; + if (state.before !== baseline.before) moved[index] = state.before; + if (state.after !== baseline.after && next) moved[next.index] = state.after; + if (state.notes !== baseline.notes && + !await send(`lines/${index}`, "PUT", { notes: edit.toSaved(state.notes) }, "saving…", "saved")) { + return; + } + if (Object.keys(moved).length) await send("breaks", "PUT", moved, "saving…", "saved"); } function revert() { @@ -548,6 +615,18 @@ export function createSheet(root, { seek, playLine, onSaved }) { } for (const n of notes()) drawNote(n, true); + const marks = el("g", { class: "sheet-breaks" }, svg); + for (const slot of breakSlots()) { + const t = breakTime(slot); + const value = hist.present[slot.which]; + const byHand = value !== null && + (value !== baseline[slot.which] || review.breaks?.find(b => b.line === slot.line)?.moved); + const cls = `sheet-break${byHand ? " sheet-break-moved" : ""}`; + el("line", { x1: x(t), x2: x(t), y1: 0, y2: PLOT_HEIGHT, class: cls }, marks); + el("text", { x: x(t) + 3, y: 11, class: `${cls} sheet-break-label` }, marks) + .textContent = "↵"; + } + syllables.replaceChildren(...notes().map(n => { const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""), n.text.trim() || "~"); @@ -628,6 +707,10 @@ export function createSheet(root, { seek, playLine, onSaved }) { const py = e.clientY - box.top; // by geometry, not the event's target: a render replaces the elements // under the pointer, and the topmost note drawn wins + if (canEdit()) { + const brk = breakSlots().find(s => Math.abs(px - view.x(breakTime(s))) <= 5); + if (brk) return { px, py, note: null, brk, mode: "break" }; + } const pitch = view.pitch(py); const note = [...notes()].reverse().find(n => n.pitch === pitch && px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null; @@ -638,14 +721,15 @@ export function createSheet(root, { seek, playLine, onSaved }) { mode = right - left > 3 * EDGE && px >= right - EDGE ? "end" : right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move"; } - return { px, py, note, mode }; + return { px, py, note, brk: null, mode }; } plot.addEventListener("pointermove", (e) => { if (!view) return; if (!drag) { - const { note, mode } = hit(e); - plot.style.cursor = !note || !canEdit() ? "crosshair" + const { note, brk, mode } = hit(e); + plot.style.cursor = brk ? "col-resize" + : !note || !canEdit() ? "crosshair" : mode === "move" ? "grab" : "ew-resize"; return; } @@ -653,6 +737,12 @@ export function createSheet(root, { seek, playLine, onSaved }) { const px = e.clientX - box.left; const py = e.clientY - box.top; if (Math.abs(px - drag.px) + Math.abs(py - drag.py) > 3) drag.moved = true; + if (drag.moved && drag.brk) { + const { which, lo, hi } = drag.brk; + breakPreview = { which, t: edit.placeBreak(view.time(px), lo, hi, grid()) }; + render(); + return; + } if (!drag.moved || !drag.note) return; if (drag.mode === "move") { const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0); @@ -671,8 +761,9 @@ export function createSheet(root, { seek, playLine, onSaved }) { // sheet, and with it the keyboard e.preventDefault(); plot.focus(); - const { px, py, note, mode } = hit(e); + const { px, py, note, brk, mode } = hit(e); if (px < GUTTER) return; + if (brk) plot.setPointerCapture(e.pointerId); if (note && canEdit()) { if (e.shiftKey) { selected = new Set(selected); @@ -684,18 +775,22 @@ export function createSheet(root, { seek, playLine, onSaved }) { plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize"; paintSelection(); } - drag = { px, py, note, mode, base: notes(), moved: false }; + drag = { px, py, note, brk, mode, base: notes(), moved: false }; }); const endDrag = (e, cancelled) => { if (!drag) return; const d = drag; drag = null; - if (preview) { + if (breakPreview) { + const { which, t } = breakPreview; + breakPreview = null; + if (cancelled) render(); else changeBreak(which, t); + } else if (preview) { const next = preview; preview = null; if (cancelled) render(); else change(next); - } else if (!d.moved && !d.note && !cancelled) { + } else if (!d.moved && !d.note && !d.brk && !cancelled) { selected = new Set(); render(); seek(view.time(d.px)); @@ -707,8 +802,9 @@ export function createSheet(root, { seek, playLine, onSaved }) { plot.addEventListener("dblclick", (e) => { if (!view || !canEdit()) return; - const { px, py, note } = hit(e); + const { px, py, note, brk } = hit(e); if (px < GUTTER) return; + if (brk) { changeBreak(brk.which, null); return; } // back to automatic if (note) { editText(note.id); return; } const out = edit.insert(notes(), view.time(px), view.pitch(py), grid()); change(out.notes, new Set([out.id])); diff --git a/src/songtrace/web.py b/src/songtrace/web.py index 7179e60..ea8f727 100644 --- a/src/songtrace/web.py +++ b/src/songtrace/web.py @@ -503,8 +503,17 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI: for e in notes_data.get("stale", [])] media = {kind: _resolve_media(job, header) is not None for kind, header in MEDIA_HEADERS.items()} + # each break with the line it comes before, the key a move is saved by + moved = set(notes_data.get("moved_breaks", [])) + starts = sorted((n["start"], n["line_index"]) for n in notes_data["notes"] + if "line_index" in n) + breaks = [] + for t in notes_data.get("breaks", []): + after = next((li for start, li in starts if start >= t), None) + if after is not None: + breaks.append({"line": after, "time": t, "moved": after in moved}) return {"lines": lines, "missing_spans": missing_spans, "media": media, - "stale": stale, "grid": _chart_grid(work), + "stale": stale, "breaks": breaks, "grid": _chart_grid(work), "grid_edited": bool(notes_data.get("grid")), "auto_bpm": _auto_bpm(work)} @app.get("/api/jobs/{job_id}/pitch") @@ -586,6 +595,12 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI: g = _chart_grid(work) return g["bpm"] if g else None + @app.put("/api/jobs/{job_id}/breaks") + def save_breaks(job_id: str, body: dict = Body(...)): + """Move line breaks: {line: seconds}, keyed by the line each comes + before; null puts one back where the notes alone would place it.""" + return _edit(job_id, lambda data, lyrics: edits.set_breaks(data, body)) + @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.""" diff --git a/tests/js/edit-model.test.mjs b/tests/js/edit-model.test.mjs index 30cba5b..eeda59a 100644 --- a/tests/js/edit-model.test.mjs +++ b/tests/js/edit-model.test.mjs @@ -120,3 +120,10 @@ test("history undoes and redoes, and a new change drops the redo", () => { assert.equal(h.canRedo, false); assert.equal(h.present, "d"); }); + +test("a dragged break snaps to a beat but stays between its lines", () => { + assert.ok(near(m.placeBreak(1.52, 1.4, 1.8, GRID), 1.5)); + assert.equal(m.placeBreak(1.0, 1.42, 1.8, GRID), 1.42); // not into the line before + assert.equal(m.placeBreak(2.5, 1.4, 1.78, GRID), 1.78); // nor into the line after + assert.equal(m.placeBreak(1.6, 1.9, 1.8, GRID), 1.9); // lines overlap: at the end of the first +}); diff --git a/tests/test_edits.py b/tests/test_edits.py index 40a3708..60de476 100644 --- a/tests/test_edits.py +++ b/tests/test_edits.py @@ -152,3 +152,38 @@ def test_finalize_reports_the_grid(tmp_path): 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} + + +def test_moved_breaks_are_checked(): + data = edits._empty(LYRICS) + edits.set_breaks(data, {"1": 3.5, 2: "6.25"}) + assert data["breaks"] == {"1": 3.5, "2": 6.25} + edits.set_breaks(data, {1: None}) + assert data["breaks"] == {"2": 6.25} + for bad in ({0: 1.0}, {3: 1.0}, {"x": 1.0}, {1: -1}, {1: "soon"}, {1: float("inf")}): + with pytest.raises(ValueError): + edits.set_breaks(data, bad) + + +def test_a_moved_break_stays_put_between_its_lines(): + data = edits._empty(LYRICS) + edits.set_breaks(data, {1: 2.5}) + notes, breaks = edits.merge(GENERATED, BREAKS, data) + assert notes == GENERATED # moving a break locks no notes + assert breaks == [2.5, 6.0] + + +def test_a_moved_break_never_lands_inside_a_line(): + data = edits._empty(LYRICS) + edits.set_breaks(data, {1: 1.0, 2: 9.0}) # into line 0's notes, past line 2's start + _, breaks = edits.merge(GENERATED, BREAKS, data) + assert breaks == [pytest.approx(1.9), 8.0] + + +def test_moved_breaks_follow_their_lines_and_keep_the_file(tmp_path): + data = edits._empty(LYRICS) + edits.set_breaks(data, {1: 3.5, 2: 6.5}) + edits.save(tmp_path, data) + assert (tmp_path / edits.EDITS_FILE).exists() + moved = edits.load(tmp_path, [["zero"], LYRICS[0], LYRICS[1], ["other"]]) + assert moved["breaks"] == {"2": 3.5} # line 2's words changed: its break goes diff --git a/tests/test_web.py b/tests/test_web.py index 3f7a405..0fc1e88 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -2049,3 +2049,28 @@ def test_reset_on_a_song_built_before_tempo_was_recorded_restores_its_bpm(tmp_pa 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 + + +def test_moving_a_break_recharts_and_is_reported(tmp_path): + from songtrace.ultrastar import LineBreak, parse_ultrastar + client, cfg = make_client(tmp_path, chart_runner) + job_id = make_charted_job(client) + assert client.get(f"/api/jobs/{job_id}/review").json()["breaks"] == [ + {"line": 1, "time": 2.0, "moved": False}] + resp = client.put(f"/api/jobs/{job_id}/breaks", json={"1": 2.8}) + assert resp.status_code == 200 + assert resp.json()["breaks"] == [{"line": 1, "time": 2.8, "moved": True}] + assert not any(line["edited"] for line in resp.json()["lines"]) + packaged = cfg.output_dir / "A - T" / "A - T.txt" + body = parse_ultrastar(packaged.read_text(encoding="utf-8")).body + # 300 BPM: 0.05s beats from GAP 1.0s, so 2.8s is beat 36 + assert [b.beat for b in body if isinstance(b, LineBreak)] == [36] + back = client.put(f"/api/jobs/{job_id}/breaks", json={"1": None}).json() + assert back["breaks"] == [{"line": 1, "time": 2.0, "moved": False}] + + +def test_a_bad_break_is_refused(tmp_path): + client, _ = make_client(tmp_path, chart_runner) + job_id = make_charted_job(client) + for body in ({"0": 1.0}, {"9": 1.0}, {"1": -2}, {"1": "x"}): + assert client.put(f"/api/jobs/{job_id}/breaks", json=body).status_code == 400 -- 2.54.0