webui: a grouped keyboard shortcut panel for the sheet #33

Merged
branderson merged 5 commits from sheet-shortcuts into sheet-editing 2026-10-14 23:27:55 -07:00
13 changed files with 779 additions and 54 deletions
+12
View File
@@ -70,6 +70,18 @@ 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 freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
the line. 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
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 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** 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 returns a line to the generated notes. Changing a line's lyrics sets its edit
+68 -8
View File
@@ -7,6 +7,7 @@ rebuild with a different BPM re-snaps it rather than breaking it.
""" """
import difflib import difflib
import json import json
import math
import time import time
from pathlib import Path from pathlib import Path
@@ -18,7 +19,9 @@ KINDS = {":", "*", "F", "R", "G"}
def _empty(lyrics: list[list[str]]) -> dict: 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;
# 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: def load(work: Path, lyrics: list[list[str]]) -> dict:
@@ -41,7 +44,7 @@ def load(work: Path, lyrics: list[list[str]]) -> dict:
def save(work: Path, data: dict) -> None: def save(work: Path, data: dict) -> None:
path = work / EDITS_FILE path = work / EDITS_FILE
if not data["lines"] and not data["stale"]: if not (data["lines"] or data["stale"] or data.get("grid") or data.get("breaks")):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
return return
tmp = path.with_suffix(".tmp") tmp = path.with_suffix(".tmp")
@@ -64,7 +67,12 @@ def _remap(data: dict, lyrics: list[list[str]]) -> dict:
stale.append(edit) stale.append(edit)
else: else:
lines[str(to)] = {**edit, "line_index": to} lines[str(to)] = {**edit, "line_index": to}
return {"lyrics": lyrics, "lines": lines, "stale": stale} # 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]: def validate_notes(notes: list[dict]) -> list[dict]:
@@ -97,6 +105,49 @@ def clear_line(data: dict, index: int) -> bool:
return data["lines"].pop(str(index), None) is not None 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:
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 def merge(notes: list[dict], breaks: list[float], data: dict
) -> tuple[list[dict], list[float]]: ) -> tuple[list[dict], list[float]]:
"""Generated notes and breaks with every edited line laid over them. """Generated notes and breaks with every edited line laid over them.
@@ -104,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 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 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 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()} 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 return notes, breaks
spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"]) spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"])
for li, ns in edited.items() if ns} for li, ns in edited.items() if ns}
@@ -122,7 +175,9 @@ def merge(notes: list[dict], breaks: list[float], data: dict
end = min(end, a) end = min(end, a)
elif li > eli: elif li > eli:
start = max(start, b) 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}) kept.append({**n, "start": start, "duration": end - start})
for li, ns in edited.items(): for li, ns in edited.items():
kept += [{**n, "line_index": li, "low_confidence": False} for n in ns] kept += [{**n, "line_index": li, "low_confidence": False} for n in ns]
@@ -139,13 +194,17 @@ def merge(notes: list[dict], breaks: list[float], data: dict
order = sorted(first, key=lambda li: first[li]["start"]) order = sorted(first, key=lambda li: first[li]["start"])
out_breaks = [] out_breaks = []
for prev, nxt in zip(order, order[1:]): 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: if prev not in edited and nxt not in edited:
# the generated break between these two lines, untouched # the generated break between these two lines, untouched
gen = [t for t in breaks if line_after(t) == nxt] gen = [t for t in breaks if line_after(t) == nxt]
if gen: if gen:
out_breaks.append(gen[0]) out_breaks.append(gen[0])
continue continue
end = last[prev]["start"] + last[prev]["duration"]
out_breaks.append((end + first[nxt]["start"]) / 2) out_breaks.append((end + first[nxt]["start"]) / 2)
return kept, out_breaks return kept, out_breaks
@@ -168,6 +227,7 @@ def finalize(work: Path, lyrics: list[list[str]]) -> dict:
" the lyrics; set aside in the editor") " the lyrics; set aside in the editor")
final = {"notes": notes, "breaks": breaks, "warnings": warnings, final = {"notes": notes, "breaks": breaks, "warnings": warnings,
"edited_lines": sorted(int(k) for k in data["lines"]), "edited_lines": sorted(int(k) for k in data["lines"]),
"stale": data["stale"]} "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)) (work / FINAL_FILE).write_text(json.dumps(final))
return 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) parse_bpm, parse_ultrastar, serialize_ultrastar)
TEMPO_FILE = "tempo.json"
def detect_bpm(instrumental_path: Path) -> float: def detect_bpm(instrumental_path: Path) -> float:
import librosa import librosa
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True) 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, 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: if not notes:
raise ValueError("no notes to place on the beat grid") raise ValueError("no notes to place on the beat grid")
gap_ms = round(notes[0]["start"] * 1000)
spb = 60 / (4 * file_bpm) 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: def beat(t: float) -> int:
return round((t - gap_ms / 1000) / spb) 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) return UltraStarSong(headers=out_headers, body=body)
def rechart(txt_path: Path, notes: list[dict], breaks: list[float]) -> None: def rechart(txt_path: Path, notes: list[dict], breaks: list[float],
"""Replace a chart's notes, keeping its headers and its BPM. 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 For an edit saved from the editor: the packaged chart's headers may differ
was built, and the packaged chart's headers may differ from the work from the work copy's (a re-encoded #VIDEO, a dropped #COVER), so each
copy's (a re-encoded #VIDEO, a dropped #COVER), so each keeps its own. keeps its own.
""" """
song = parse_ultrastar(txt_path.read_text(encoding="utf-8")) 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")] 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") 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")) bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
except ValueError: except ValueError:
raise PipelineError("could not detect a usable tempo - is the audio silent?") 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) slug = song_slug(ctx.inp)
video_ext = Path(meta["video_file"]).suffix video_ext = Path(meta["video_file"]).suffix
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)] 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"), headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")] ("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
try: 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: except ValueError:
raise PipelineError("no notes were generated - the lyrics may not match the audio") 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") (ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
+8
View File
@@ -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);
}
+74
View File
@@ -418,6 +418,39 @@ button { cursor: pointer; }
} }
.sheet-badge-edited { color: var(--aqua); } .sheet-badge-edited { color: var(--aqua); }
.sheet-badge-low { color: var(--fail); } .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-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 12px; }
.sheet-tool-group { display: flex; gap: 6px; } .sheet-tool-group { display: flex; gap: 6px; }
.sheet-tools .action:disabled { opacity: 0.35; cursor: default; } .sheet-tools .action:disabled { opacity: 0.35; cursor: default; }
@@ -443,6 +476,42 @@ button { cursor: pointer; }
.sheet-stale-head { color: var(--warn); margin-bottom: 6px; } .sheet-stale-head { color: var(--warn); margin-bottom: 6px; }
.sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; } .sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.sheet-stale-text { flex: 1; color: var(--text-muted); } .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-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); }
.sheet-svg { display: block; } .sheet-svg { display: block; }
.sheet-row { fill: var(--panel-raised); } .sheet-row { fill: var(--panel-raised); }
@@ -453,6 +522,11 @@ button { cursor: pointer; }
.sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; } .sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; }
.sheet-beat-1 { stroke: var(--control); opacity: 0.55; } .sheet-beat-1 { stroke: var(--control); opacity: 0.55; }
.sheet-beat-2 { stroke: var(--text-muted); opacity: 0.8; } .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 { pointer-events: none; }
.sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; } .sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; }
.sheet-note { fill: var(--purple); } .sheet-note { fill: var(--purple); }
+324 -33
View File
@@ -12,6 +12,56 @@ const PLOT_HEIGHT = 300; // px; rows divide this between the pitches shown
const CONTEXT = 0.75; // seconds shown either side of the line 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 EDGE = 6; // px from a note's end that grabs it for resizing
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" }; 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"]]],
["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"]]],
["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 wrap = (n, d) => ((n % d) + d) % d;
const mod12 = (p) => wrap(p, 12); const mod12 = (p) => wrap(p, 12);
@@ -88,7 +138,9 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1)); const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1));
const next = button("NEXT ▶", "next line (Page Down)", () => page(1)); const next = button("NEXT ▶", "next line (Page Down)", () => page(1));
const nav = div("sheet-nav"); 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 = { const tools = {
undo: button("UNDO", "Ctrl+Z", () => undo()), undo: button("UNDO", "Ctrl+Z", () => undo()),
@@ -123,12 +175,88 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.append(svg, playhead); plot.append(svg, playhead);
const syllables = div("sheet-syllables"); const syllables = div("sheet-syllables");
const words = div("sheet-words"); const words = div("sheet-words");
const help = div("sheet-help", const help = div("sheet-help", "Press ? for keyboard shortcuts.");
"Drag a note to move it, its ends to resize · double-click empty space to add a note, " + const keys = div("sheet-keys");
"a note to edit its syllable · ←→ move · ↑↓ pitch (Shift: octave) · " + keys.id = "sheet-keys";
"Shift+←→ length · Tab next note · Delete · S split · M merge · G/F/R kind"); 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"); const stale = div("sheet-stale");
root.replaceChildren(nav, stale, toolbar, 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) {
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 review = null;
let curve = null; let curve = null;
@@ -139,14 +267,22 @@ export function createSheet(root, { seek, playLine, onSaved }) {
let hist = null; // the current line's notes, with undo let hist = null; // the current line's notes, with undo
let baseline = null; // what the line holds on the server let baseline = null; // what the line holds on the server
let preview = null; // notes mid-drag, not yet in the history 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 selected = new Set();
let drag = null; let drag = null;
let saving = false; let saving = false;
const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l)); 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; 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) { function say(text, error = false) {
status.textContent = text; status.textContent = text;
@@ -162,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 span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line);
const range = pitchRange(notes().map(n => n.pitch)); const range = pitchRange(notes().map(n => n.pitch));
const want = { t0: span.start - CONTEXT, t1: span.end + CONTEXT, ...range }; 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 : { layout = reset || !layout ? want : {
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1), 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), lo: Math.min(layout.lo, want.lo), hi: Math.max(layout.hi, want.hi),
@@ -197,8 +339,16 @@ export function createSheet(root, { seek, playLine, onSaved }) {
function open(i) { function open(i) {
index = i; index = i;
const line = review.lines[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); hist = edit.history(baseline);
breakPreview = null;
preview = null; preview = null;
selected = new Set(); selected = new Set();
say(""); say("");
@@ -222,12 +372,47 @@ export function createSheet(root, { seek, playLine, onSaved }) {
} }
function change(next, keep = selected) { 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))); selected = new Set([...keep].filter(id => next.some(n => n.id === id)));
fit(false); fit(false);
render(); 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 undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } };
const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } }; const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } };
const one = () => (selected.size === 1 ? [...selected][0] : null); const one = () => (selected.size === 1 ? [...selected][0] : null);
@@ -259,46 +444,114 @@ export function createSheet(root, { seek, playLine, onSaved }) {
open(index); 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; saving = true;
render(); render();
say(method === "PUT" ? "saving…" : "reverting…"); say(busy);
try { try {
const resp = await fetch(`/api/jobs/${jobId}/lines/${index}`, { const resp = await fetch(`/api/jobs/${jobId}/${path}`, {
method, headers: { "Content-Type": "application/json" }, method, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
say(err.detail || `error ${resp.status}`, true); say(err.detail || `error ${resp.status}`, true);
return; return false;
} }
const data = await resp.json(); const data = await resp.json();
const at = index; const at = index;
review = data; review = data;
gridDraft = null;
open(at); open(at);
say(method === "PUT" ? "saved" : "reverted to the generated notes"); say(done);
onSaved(data); onSaved(data);
return true;
} catch { } catch {
say("could not reach songtrace", true); say("could not reach songtrace", true);
return false;
} finally { } finally {
saving = false; saving = false;
render(); render();
} }
} }
function save() { // only what changed: a moved break alone must not mark the line edited
async function save() {
if (!dirty() || saving) return; if (!dirty() || saving) return;
send("PUT", { notes: edit.toSaved(notes()) }); 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() { function revert() {
if (!review.lines[index].edited || saving) return; if (!review.lines[index].edited || saving) return;
if (!confirm("Go back to the generated notes for this line? Your edit is lost.")) 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; if (!review || index === null) return;
const line = review.lines[index]; const line = review.lines[index];
const { t0, t1, lo, hi } = layout; const { t0, t1, lo, hi } = layout;
@@ -362,6 +615,18 @@ export function createSheet(root, { seek, playLine, onSaved }) {
} }
for (const n of notes()) drawNote(n, true); 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 => { syllables.replaceChildren(...notes().map(n => {
const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""), const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""),
n.text.trim() || "~"); n.text.trim() || "~");
@@ -407,6 +672,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text))); badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
paintTools(); paintTools();
paintGrid(typing);
} }
function paintSelection() { function paintSelection() {
@@ -416,11 +682,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
: "sheet-syllable-selected", on); : "sheet-syllable-selected", on);
} }
paintTools(); paintTools();
paintGrid();
} }
function paintTools() { function paintTools() {
const line = review.lines[index]; const line = review.lines[index];
const editable = !!grid() && !saving; const editable = canEdit();
tools.undo.disabled = !editable || !hist.canUndo; tools.undo.disabled = !editable || !hist.canUndo;
tools.redo.disabled = !editable || !hist.canRedo; tools.redo.disabled = !editable || !hist.canRedo;
tools.split.disabled = tools.merge.disabled = !editable || one() === null; tools.split.disabled = tools.merge.disabled = !editable || one() === null;
@@ -440,6 +707,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const py = e.clientY - box.top; const py = e.clientY - box.top;
// by geometry, not the event's target: a render replaces the elements // by geometry, not the event's target: a render replaces the elements
// under the pointer, and the topmost note drawn wins // 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 pitch = view.pitch(py);
const note = [...notes()].reverse().find(n => n.pitch === pitch && const note = [...notes()].reverse().find(n => n.pitch === pitch &&
px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null; px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null;
@@ -450,14 +721,15 @@ export function createSheet(root, { seek, playLine, onSaved }) {
mode = right - left > 3 * EDGE && px >= right - EDGE ? "end" mode = right - left > 3 * EDGE && px >= right - EDGE ? "end"
: right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move"; : 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) => { plot.addEventListener("pointermove", (e) => {
if (!view) return; if (!view) return;
if (!drag) { if (!drag) {
const { note, mode } = hit(e); const { note, brk, mode } = hit(e);
plot.style.cursor = !note || !grid() ? "crosshair" plot.style.cursor = brk ? "col-resize"
: !note || !canEdit() ? "crosshair"
: mode === "move" ? "grab" : "ew-resize"; : mode === "move" ? "grab" : "ew-resize";
return; return;
} }
@@ -465,6 +737,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const px = e.clientX - box.left; const px = e.clientX - box.left;
const py = e.clientY - box.top; const py = e.clientY - box.top;
if (Math.abs(px - drag.px) + Math.abs(py - drag.py) > 3) drag.moved = true; 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.moved || !drag.note) return;
if (drag.mode === "move") { if (drag.mode === "move") {
const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0); const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0);
@@ -483,9 +761,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
// sheet, and with it the keyboard // sheet, and with it the keyboard
e.preventDefault(); e.preventDefault();
plot.focus(); plot.focus();
const { px, py, note, mode } = hit(e); const { px, py, note, brk, mode } = hit(e);
if (px < GUTTER) return; if (px < GUTTER) return;
if (note && grid()) { if (brk) plot.setPointerCapture(e.pointerId);
if (note && canEdit()) {
if (e.shiftKey) { if (e.shiftKey) {
selected = new Set(selected); selected = new Set(selected);
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id); if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
@@ -496,18 +775,22 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize"; plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize";
paintSelection(); paintSelection();
} }
drag = { px, py, note, mode, base: notes(), moved: false }; drag = { px, py, note, brk, mode, base: notes(), moved: false };
}); });
const endDrag = (e, cancelled) => { const endDrag = (e, cancelled) => {
if (!drag) return; if (!drag) return;
const d = drag; const d = drag;
drag = null; 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; const next = preview;
preview = null; preview = null;
if (cancelled) render(); else change(next); 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(); selected = new Set();
render(); render();
seek(view.time(d.px)); seek(view.time(d.px));
@@ -518,9 +801,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.addEventListener("pointercancel", (e) => endDrag(e, true)); plot.addEventListener("pointercancel", (e) => endDrag(e, true));
plot.addEventListener("dblclick", (e) => { plot.addEventListener("dblclick", (e) => {
if (!view || !grid()) return; if (!view || !canEdit()) return;
const { px, py, note } = hit(e); const { px, py, note, brk } = hit(e);
if (px < GUTTER) return; if (px < GUTTER) return;
if (brk) { changeBreak(brk.which, null); return; } // back to automatic
if (note) { editText(note.id); return; } if (note) { editText(note.id); return; }
const out = edit.insert(notes(), view.time(px), view.pitch(py), grid()); const out = edit.insert(notes(), view.time(px), view.pitch(py), grid());
change(out.notes, new Set([out.id])); change(out.notes, new Set([out.id]));
@@ -577,7 +861,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; } if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; }
if (key === "PageUp") { handled(); page(-1); return; } if (key === "PageUp") { handled(); page(-1); return; }
if (key === "PageDown") { 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(); const ns = notes();
if (key === "Tab") { if (key === "Tab") {
handled(); handled();
@@ -589,7 +873,13 @@ export function createSheet(root, { seek, playLine, onSaved }) {
render(); render();
return; 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 (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
if (!selected.size) return; if (!selected.size) return;
const arrows = { ArrowLeft: -1, ArrowRight: 1 }; const arrows = { ArrowLeft: -1, ArrowRight: 1 };
@@ -628,6 +918,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
if (!same) { if (!same) {
jobId = id; jobId = id;
curve = null; curve = null;
gridDraft = null;
fetch(`/api/jobs/${id}/pitch`) fetch(`/api/jobs/${id}/pitch`)
.then(r => (r.ok ? r.json() : null)) .then(r => (r.ok ? r.json() : null))
.then(c => { if (jobId === id) { curve = c; render(); } }) .then(c => { if (jobId === id) { curve = c; render(); } })
@@ -642,7 +933,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
jobId = null; jobId = null;
curve = null; curve = null;
index = null; index = null;
view = layout = hist = baseline = preview = null; view = layout = hist = baseline = preview = gridDraft = null;
root.hidden = true; root.hidden = true;
}, },
// playback following the song: never at the cost of unsaved edits // playback following the song: never at the cost of unsaved edits
+52 -2
View File
@@ -503,8 +503,18 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
for e in notes_data.get("stale", [])] for e in notes_data.get("stale", [])]
media = {kind: _resolve_media(job, header) is not None media = {kind: _resolve_media(job, header) is not None
for kind, header in MEDIA_HEADERS.items()} 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, 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") @app.get("/api/jobs/{job_id}/pitch")
def get_pitch(job_id: str): def get_pitch(job_id: str):
@@ -562,16 +572,56 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
edits.save(work, data) edits.save(work, data)
final = edits.finalize(work, lyrics) 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"] charts = [work / "song.txt"]
if job.output_dir: if job.output_dir:
charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt") charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt")
for txt in charts: for txt in charts:
if txt.exists(): if txt.exists():
grid.rechart(txt, final["notes"], final["breaks"]) grid.rechart(txt, final["notes"], final["breaks"],
chosen["bpm"], chosen["gap_ms"])
with lock: with lock:
job.warnings = list(final["warnings"]) job.warnings = list(final["warnings"])
return get_review(job_id) 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}/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."""
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: def _line(lyrics, index: int) -> int:
if not 0 <= index < len(lyrics): if not 0 <= index < len(lyrics):
raise HTTPException(404, f"no line {index}") raise HTTPException(404, f"no line {index}")
+7
View File
@@ -120,3 +120,10 @@ test("history undoes and redoes, and a new change drops the redo", () => {
assert.equal(h.canRedo, false); assert.equal(h.canRedo, false);
assert.equal(h.present, "d"); 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
});
+25
View File
@@ -45,3 +45,28 @@ test("a line spans its notes and words", () => {
{ start: 1.5, end: 3 }); { start: 1.5, end: 3 });
assert.equal(lineSpan({ notes: [], words: [] }), null); 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`);
}
});
+65
View File
@@ -122,3 +122,68 @@ def test_finalize_reports_stale_edits(tmp_path):
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]]) final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
assert final["warnings"] == ["1 edited line no longer matches the lyrics; " assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
"set aside in the editor"] "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}
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
+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) monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
captured = {} 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 captured["bpm"] = file_bpm
return to_ultrastar(notes, breaks, 240.0, headers) 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 [k for k, _ in song.headers] == ["TITLE", "VIDEO", "BPM", "GAP"]
assert get_header(song, "BPM") == "300" and get_header(song, "GAP") == "2000" 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")] 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")
+71
View File
@@ -2003,3 +2003,74 @@ def test_pitch_curve_is_404_without_a_pitch_track(tmp_path):
client, _ = make_client(tmp_path, chart_runner) client, _ = make_client(tmp_path, chart_runner)
job_id = make_charted_job(client) job_id = make_charted_job(client)
assert client.get(f"/api/jobs/{job_id}/pitch").status_code == 404 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
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