webui: edit a song's notes on the sheet

Notes on the sheet can now be selected and edited: dragged in time and
pitch, resized from either end, nudged and stretched from the keyboard,
split, merged, added, deleted, given a syllable or a kind (golden,
freestyle, rap), with undo/redo. Everything snaps to the chart's beat grid
and to semitones, as the chart itself will. SAVE LINE stores the line
through the edits API, which rewrites the charts in place; REVERT goes back
to the generated notes; a line's edits set aside because its lyrics changed
are listed with a way to discard them. Unsaved changes are not lost to
paging, following playback, switching songs or closing the tab.

The operations live in static/edit-model.js as pure functions, unit-tested
with node --test. The sheet hit-tests by geometry rather than event
targets, since a render replaces the elements under the pointer.
This commit is contained in:
2026-10-11 02:37:00 -07:00
parent 7fa1f3912f
commit d92135e8f2
5 changed files with 798 additions and 63 deletions
+21
View File
@@ -55,6 +55,27 @@ before this change still have it — check with `ffprobe <song>.mp4` for an
audio stream, and rebuild with **Restart from** set to `package` to clear it
without re-downloading.
## Editing a song
Above the lyrics, the note sheet shows one line at a time on a pitch-by-beat
grid, with the pitch songtrace detected drawn over the notes: where a note is
right the curve runs through it, so a wrong one stands out. Fix it there and
**SAVE LINE**; the song folder's chart is rewritten on the spot.
Drag a note to move it and its ends to resize it; everything snaps to the
chart's beat grid and to semitones. Double-click empty space to add a note,
or a note to change its syllable. With the sheet focused: ←/→ move, ↑/↓
change pitch (Shift for an octave), Shift+←/→ change length, Tab selects the
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.
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
aside rather than applying it to different words; the sheet lists those so
you can discard them.
## Configuration
Config is TOML. Resolution order:
+150
View File
@@ -0,0 +1,150 @@
// Editing one line's notes. Every operation returns a new array and leaves
// its input alone, so undo is just keeping the old one. Times are seconds,
// snapped to the chart's beat grid as the chart itself will be. Notes carry
// a client-side `id` so a selection survives reordering; it is never saved.
const MIN_BEATS = 1;
const spbOf = (grid) => 60 / (4 * grid.bpm);
const gapOf = (grid) => grid.gap_ms / 1000;
const byStart = (a, b) => a.start - b.start;
let nextId = 1;
export const newId = () => nextId++;
export function beatOf(t, grid) {
return (t - gapOf(grid)) / spbOf(grid);
}
export function timeOf(beat, grid) {
return gapOf(grid) + beat * spbOf(grid);
}
export function snap(t, grid) {
return timeOf(Math.round(beatOf(t, grid)), grid);
}
// A note as the chart will have it: start on a beat, at least one beat long.
function onGrid(n, grid) {
const start = snap(n.start, grid);
const beats = Math.max(MIN_BEATS, Math.round((n.end - n.start) / spbOf(grid)));
return { ...n, start, end: start + beats * spbOf(grid) };
}
export function withIds(notes) {
return notes.map(n => ({ ...n, id: n.id ?? newId() })).sort(byStart);
}
export function move(notes, ids, beats, semitones, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (!ids.has(n.id)) return n;
const g = onGrid(n, grid);
const shift = Math.max(beats * spb, -g.start); // never before the song starts
return { ...g, start: g.start + shift, end: g.end + shift, pitch: n.pitch + semitones };
}).sort(byStart);
}
// Drag one edge of a note to time t; the other edge stays put.
export function resize(notes, id, edge, t, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (n.id !== id) return n;
const g = onGrid(n, grid);
if (edge === "end") return { ...g, end: Math.max(snap(t, grid), g.start + spb) };
return { ...g, start: Math.max(0, Math.min(snap(t, grid), g.end - spb)) };
}).sort(byStart);
}
// Longer or shorter by whole beats, keeping the start.
export function stretch(notes, ids, beats, grid) {
const spb = spbOf(grid);
return notes.map(n => {
if (!ids.has(n.id)) return n;
const g = onGrid(n, grid);
return { ...g, end: Math.max(g.end + beats * spb, g.start + spb) };
});
}
// In two at the beat nearest the middle; the second half holds the syllable.
export function split(notes, id, grid) {
const i = notes.findIndex(n => n.id === id);
if (i < 0) return { notes, ids: [] };
const g = onGrid(notes[i], grid);
const beats = Math.round((g.end - g.start) / spbOf(grid));
if (beats < 2) return { notes, ids: [] };
const mid = g.start + Math.floor(beats / 2) * spbOf(grid);
const a = { ...g, end: mid };
const b = { ...g, id: newId(), start: mid, text: "~", low_confidence: false };
return { notes: [...notes.slice(0, i), a, b, ...notes.slice(i + 1)], ids: [a.id, b.id] };
}
// Into one with the note after it; a held "~" adds no text.
export function merge(notes, id) {
const i = notes.findIndex(n => n.id === id);
if (i < 0 || i + 1 >= notes.length) return notes;
const a = notes[i];
const b = notes[i + 1];
const text = b.text.trim() === "~" ? a.text : a.text + b.text;
return [...notes.slice(0, i), { ...a, end: b.end, text }, ...notes.slice(i + 2)];
}
export function remove(notes, ids) {
return notes.filter(n => !ids.has(n.id));
}
export function insert(notes, t, pitch, grid) {
const start = Math.max(0, snap(t, grid));
const note = { id: newId(), start, end: start + 2 * spbOf(grid), pitch,
text: "~", kind: ":", low_confidence: false };
return { notes: [...notes, note].sort(byStart), id: note.id };
}
// Applying a kind every selected note already has turns it back to normal.
export function setKind(notes, ids, kind) {
const chosen = notes.filter(n => ids.has(n.id));
const to = chosen.length && chosen.every(n => n.kind === kind) ? ":" : kind;
return notes.map(n => (ids.has(n.id) ? { ...n, kind: to } : n));
}
// A syllable that starts a word keeps the space before it that marks it so.
export function setText(notes, id, text) {
const clean = text.trim();
if (!clean) return notes;
return notes.map(n => {
if (n.id !== id) return n;
const lead = n.text.startsWith(" ") && !text.startsWith(" ") ? " " : "";
return { ...n, text: lead + (text.startsWith(" ") ? " " + clean : clean) };
});
}
// What the server stores for a line.
export function toSaved(notes) {
return notes.map(n => ({ start: n.start, duration: n.end - n.start, pitch: n.pitch,
text: n.text, kind: n.kind ?? ":" }));
}
export function history(initial) {
const past = [];
const future = [];
let present = initial;
return {
get present() { return present; },
get canUndo() { return past.length > 0; },
get canRedo() { return future.length > 0; },
push(next) {
if (next === present) return;
past.push(present);
future.length = 0;
present = next;
},
undo() {
if (past.length) { future.push(present); present = past.pop(); }
return present;
},
redo() {
if (future.length) { past.push(present); present = future.pop(); }
return present;
},
};
}
+51 -4
View File
@@ -418,7 +418,32 @@ button { cursor: pointer; }
}
.sheet-badge-edited { color: var(--aqua); }
.sheet-badge-low { color: var(--fail); }
.sheet-plot { position: relative; cursor: crosshair; }
.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; }
.sheet-tools .sheet-save:not(:disabled) { color: var(--bg); background: var(--purple); border-color: var(--purple); }
.sheet-status { flex: 1; min-width: 6em; font-size: 0.78rem; color: var(--text-muted); text-align: right; }
.sheet-status-error { color: var(--fail); }
.sheet-badge-dirty { color: var(--warn); }
.sheet-plot { position: relative; cursor: crosshair; touch-action: none; }
.sheet-plot:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
.sheet-note-selected { stroke: var(--text); stroke-width: 2; stroke-dasharray: none; }
.sheet-syllable-selected { color: var(--aqua); }
.sheet-text-input {
position: absolute;
top: -2px;
width: 7em;
padding: 1px 4px;
font-size: 0.8rem;
background: var(--panel-raised);
border: 1px solid var(--aqua);
}
.sheet-stale { margin-bottom: 12px; padding: 8px 10px; font-size: 0.82rem; border: 1px solid var(--warn); }
.sheet-stale[hidden] { display: none; }
.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-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); }
.sheet-row-black { fill: var(--bg); }
@@ -440,8 +465,8 @@ button { cursor: pointer; }
.sheet-playhead { position: absolute; top: 0; height: 300px; width: 2px; margin-left: -1px; background: var(--aqua); pointer-events: none; }
.sheet-playhead[hidden] { display: none; }
.sheet-syllables, .sheet-words { position: relative; overflow: hidden; }
.sheet-syllables { height: 20px; margin-top: 6px; }
.sheet-syllable { position: absolute; top: 0; font-size: 0.8rem; white-space: pre; color: var(--text); }
.sheet-syllables { height: 22px; margin-top: 6px; overflow: visible; }
.sheet-syllable { position: absolute; top: 0; font-size: 0.8rem; white-space: pre; color: var(--text); cursor: text; }
.sheet-words { height: 24px; margin-top: 4px; }
.sheet-word {
position: absolute;
@@ -837,6 +862,7 @@ form.addEventListener("input", () => { formDirty = true; });
// scrolled the panel to. Leaving another song's review under an empty form
// reads as though the two belong together.
createSong.addEventListener("click", () => {
if (sheet?.isDirty() && !confirm("Discard unsaved changes to this line?")) return;
if (formDirty && !confirm("Discard your unsaved edits to this form?")) return;
composing = true;
selectedId = null;
@@ -1087,6 +1113,8 @@ async function refreshJobs() {
}
async function selectJob(id) {
if (id !== selectedId && sheet?.isDirty() &&
!confirm("Discard unsaved changes to this line?")) return;
// the form already holds this job; reloading it would drop edits in progress
const reload = id !== rebuildOf;
if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;
@@ -1262,12 +1290,27 @@ const DRIFT = 0.25; // seconds before the picture is worth re-seeking
// the note sheet lives in its own module, loaded before any review renders
let sheet = null;
let stopAt = null; // set by playing one line: where playback pauses again
const sheetReady = import("/static/sheet.js").then((m) => {
sheet = m.createSheet(document.getElementById("sheet"), {
seek: (t) => {
media.currentTime = t;
if (media.paused) syncPlayback();
},
playLine: (from, to) => {
if (player.hidden) return;
media.currentTime = from;
stopAt = to;
media.play().catch(() => {});
},
// a saved line changes the notes the lyric list draws
onSaved: (data) => {
const box = document.getElementById("lyrics-review");
const { nodes, entries } = buildLyricsReview(data);
box.replaceChildren(...nodes);
bindTimeline(player.hidden ? [] : entries);
if (!player.hidden) syncPlayback();
},
});
});
@@ -1284,7 +1327,10 @@ for (const event of ["seeked", "ratechange"]) {
slaveVideo(true);
});
}
media.addEventListener("pause", () => video.pause());
media.addEventListener("pause", () => {
video.pause();
stopAt = null;
});
let timeline = [];
let activeEntry = null;
let syncFrame = null;
@@ -1342,6 +1388,7 @@ function focusActive() {
}
function pumpPlayback() {
if (stopAt !== null && media.currentTime >= stopAt) media.pause();
syncPlayback();
slaveVideo(false);
syncFrame = media.paused ? null : requestAnimationFrame(pumpPlayback);
+454 -59
View File
@@ -1,6 +1,8 @@
// The note sheet: one lyric line at a time on a pitch-by-beat grid, the way
// an UltraStar editor lays a song out, with the detected pitch drawn behind
// the notes so a wrong one stands out.
// an UltraStar editor lays a song out, with the detected pitch drawn over
// the notes so a wrong one stands out. The line on show can be edited.
import * as edit from "./edit-model.js";
const SVG = "http://www.w3.org/2000/svg";
const NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"];
@@ -8,6 +10,8 @@ const BLACK = new Set([1, 3, 6, 8, 10]);
const GUTTER = 46; // px of note names left of the grid
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 wrap = (n, d) => ((n % d) + d) % d;
const mod12 = (p) => wrap(p, 12);
@@ -69,61 +73,242 @@ function div(cls, text = null) {
return node;
}
export function createSheet(root, { seek }) {
export function createSheet(root, { seek, playLine, onSaved }) {
const button = (label, title, onClick, cls = "action") => {
const b = document.createElement("button");
b.type = "button";
b.className = cls;
b.textContent = label;
b.title = title;
b.addEventListener("click", onClick);
return b;
};
const title = div("sheet-title");
const badges = div("sheet-badges");
const prev = document.createElement("button");
const next = document.createElement("button");
for (const [b, label, step] of [[prev, "◀ PREV", -1], [next, "NEXT ▶", 1]]) {
b.type = "button";
b.className = "action";
b.textContent = label;
b.addEventListener("click", () => page(step));
}
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 tools = {
undo: button("UNDO", "Ctrl+Z", () => undo()),
redo: button("REDO", "Ctrl+Shift+Z", () => redo()),
split: button("SPLIT", "split the selected note (S)", () => splitSelected()),
merge: button("MERGE", "merge with the next note (M)", () => mergeSelected()),
remove: button("DELETE", "delete the selected notes (Delete)", () => removeSelected()),
golden: button("GOLDEN", "golden note (G)", () => kind("*")),
free: button("FREESTYLE", "freestyle note (F)", () => kind("F")),
rap: button("RAP", "rap note (R)", () => kind("R")),
play: button("▶ LINE", "play this line (P)", () => playCurrent()),
discard: button("DISCARD", "drop unsaved changes to this line", () => discard()),
revert: button("REVERT", "go back to the generated notes for this line", () => revert()),
save: button("SAVE LINE", "Ctrl+S", () => save(), "action sheet-save"),
};
const toolbar = div("sheet-tools");
const group = (...items) => { const g = div("sheet-tool-group"); g.append(...items); return g; };
const status = div("sheet-status");
status.setAttribute("role", "status");
toolbar.append(group(tools.undo, tools.redo),
group(tools.split, tools.merge, tools.remove),
group(tools.golden, tools.free, tools.rap),
group(tools.play), status,
group(tools.discard, tools.revert, tools.save));
const plot = div("sheet-plot");
plot.tabIndex = 0;
plot.setAttribute("aria-label", "note sheet: select notes and edit them with the mouse or keyboard");
const svg = el("svg", { class: "sheet-svg" });
const playhead = div("sheet-playhead");
playhead.hidden = true;
plot.append(svg, playhead);
const syllables = div("sheet-syllables");
const words = div("sheet-words");
root.replaceChildren(nav, plot, syllables, 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 stale = div("sheet-stale");
root.replaceChildren(nav, stale, toolbar, plot, syllables, words, help);
let review = null;
let curve = null;
let index = null;
let view = null; // what the last render laid out: window and scale
let jobId = null;
let view = null; // what the last render laid out: window and scale
let layout = null; // window and pitch range, kept steady while editing
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 selected = new Set();
let drag = null;
let saving = false;
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;
function say(text, error = false) {
status.textContent = text;
status.classList.toggle("sheet-status-error", error);
}
// the window and pitch range that hold the line, grown (never shrunk while
// editing) so rows do not jump under the pointer
function fit(reset) {
const line = review.lines[index];
const ts = [...notes().flatMap(n => [n.start, n.end]),
...line.words.flatMap(w => [w.start, w.end])];
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 };
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),
};
}
// edits whose line's lyrics have since changed: kept, not applied, until
// the person who made them decides
function paintStale() {
const items = review?.stale ?? [];
stale.hidden = !items.length;
stale.replaceChildren();
if (!items.length) return;
stale.append(div("sheet-stale-head",
`${items.length} edited line${items.length === 1 ? " was" : "s were"} set aside ` +
"because the lyrics changed:"));
items.forEach((item, n) => {
const row = div("sheet-stale-row");
row.append(div("sheet-stale-text", `"${item.text}"`),
button("DISCARD", "throw this edit away", () => discardStale(n)));
stale.append(row);
});
}
async function discardStale(n) {
const resp = await fetch(`/api/jobs/${jobId}/stale/${n}`, { method: "DELETE" });
if (!resp.ok) { say(`error ${resp.status}`, true); return; }
review = await resp.json();
paintStale();
onSaved(review);
}
function open(i) {
index = i;
const line = review.lines[i];
baseline = edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" })));
hist = edit.history(baseline);
preview = null;
selected = new Set();
say("");
fit(true);
paintStale();
render();
}
// asked for by the person using the sheet, so it may cost them their edits
function goTo(i) {
if (!review || i === index) return;
if (dirty() && !confirm("Discard unsaved changes to this line?")) return;
open(i);
}
function page(step) {
const lines = visible();
const at = lines.findIndex(l => l.index === index);
const to = lines[Math.min(Math.max(at + step, 0), lines.length - 1)];
if (to) showLine(to.index);
if (to) goTo(to.index);
}
function showLine(i) {
if (!review || i === index) return;
index = i;
function change(next, keep = selected) {
hist.push(next);
selected = new Set([...keep].filter(id => next.some(n => n.id === id)));
fit(false);
render();
}
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);
function splitSelected() {
if (one() === null || !grid()) return;
const out = edit.split(notes(), one(), grid());
if (out.notes !== notes()) change(out.notes, new Set([out.ids[0]]));
}
function mergeSelected() {
if (one() !== null) change(edit.merge(notes(), one()));
}
function removeSelected() {
if (selected.size) change(edit.remove(notes(), selected), new Set());
}
function kind(k) {
if (selected.size) change(edit.setKind(notes(), selected, k));
}
function playCurrent() {
if (!layout) return;
const ns = notes();
const start = ns.length ? ns[0].start : layout.t0 + CONTEXT;
const end = ns.length ? Math.max(...ns.map(n => n.end)) : layout.t1 - CONTEXT;
playLine(Math.max(0, start - 0.3), end + 0.2);
}
function discard() {
if (!dirty()) return;
open(index);
}
async function send(method, body) {
saving = true;
render();
say(method === "PUT" ? "saving…" : "reverting…");
try {
const resp = await fetch(`/api/jobs/${jobId}/lines/${index}`, {
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;
}
const data = await resp.json();
const at = index;
review = data;
open(at);
say(method === "PUT" ? "saved" : "reverted to the generated notes");
onSaved(data);
} catch {
say("could not reach songtrace", true);
} finally {
saving = false;
render();
}
}
function save() {
if (!dirty() || saving) return;
send("PUT", { notes: edit.toSaved(notes()) });
}
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");
}
function render() {
if (!review || index === null) return;
const line = review.lines[index];
const span = lineSpan(line);
const t0 = span.start - CONTEXT;
const t1 = span.end + CONTEXT;
const { t0, t1, lo, hi } = layout;
const width = Math.max(plot.clientWidth, 320);
const { lo, hi } = pitchRange(line.notes.map(n => n.pitch));
const rowH = PLOT_HEIGHT / (hi - lo + 1);
const x = (t) => GUTTER + ((t - t0) / (t1 - t0)) * (width - GUTTER);
const y = (p) => (hi - p) * rowH; // top of the row for pitch p
view = { t0, t1, x, width };
view = { t0, t1, x, width, rowH, hi,
time: (px) => t0 + ((px - GUTTER) / (width - GUTTER)) * (t1 - t0),
pitch: (py) => hi - Math.floor(py / rowH) };
svg.setAttribute("viewBox", `0 0 ${width} ${PLOT_HEIGHT}`);
svg.setAttribute("width", width);
@@ -148,41 +333,42 @@ export function createSheet(root, { seek }) {
}
}
if (review.grid) {
if (grid()) {
const beats = el("g", { class: "sheet-beats" }, svg);
const spbPx = x(60 / (4 * review.grid.bpm)) - x(0);
for (const b of beatLines(t0, t1, review.grid)) {
const spbPx = x(60 / (4 * grid().bpm)) - x(0);
for (const b of beatLines(t0, t1, grid())) {
if (b.weight === 0 && spbPx < 10) continue;
el("line", { x1: x(b.t), x2: x(b.t), y1: 0, y2: PLOT_HEIGHT,
class: `sheet-beat sheet-beat-${b.weight}` }, beats);
}
}
const notes = el("g", { class: "sheet-notes" }, svg);
const syllableNodes = [];
const layer = el("g", { class: "sheet-notes" }, svg);
const pad = Math.min(2, rowH * 0.12);
const drawNote = (n, current) => {
const cls = ["sheet-note", `sheet-note-${KINDS[n.kind] ?? "normal"}`];
if (!current) cls.push("sheet-note-context");
if (current && n.low_confidence) cls.push("sheet-note-low");
if (current && selected.has(n.id)) cls.push("sheet-note-selected");
const rect = el("rect", { x: x(n.start), y: y(n.pitch) + pad,
width: Math.max(x(n.end) - x(n.start), 2), height: rowH - 2 * pad,
rx: Math.min(4, rowH / 3), class: cls.join(" ") }, layer);
if (current) rect.dataset.id = n.id;
el("title", {}, rect).textContent = `${n.text.trim() || "~"} · ${noteName(n.pitch)}`;
};
for (const l of review.lines) {
if (l.dropped) continue;
const current = l.index === index;
for (const n of l.notes) {
if (n.end < t0 || n.start > t1) continue;
const kind = { "*": "golden", F: "free", R: "rap", G: "golden-rap" }[n.kind] ?? "normal";
const cls = ["sheet-note", `sheet-note-${kind}`];
if (!current) cls.push("sheet-note-context");
if (current && n.low_confidence) cls.push("sheet-note-low");
const pad = Math.min(2, rowH * 0.12);
el("rect", { x: x(n.start), y: y(n.pitch) + pad,
width: Math.max(x(n.end) - x(n.start), 2), height: rowH - 2 * pad,
rx: Math.min(4, rowH / 3), class: cls.join(" ") }, notes)
.append(Object.assign(el("title"), {
textContent: `${n.text.trim() || "~"} · ${noteName(n.pitch)}` }));
if (current) {
const s = div("sheet-syllable", n.text.trim() || "~");
s.style.left = `${x(n.start)}px`;
syllableNodes.push(s);
}
}
if (l.dropped || l.index === index) continue;
for (const n of l.notes) if (n.end >= t0 && n.start <= t1) drawNote(n, false);
}
syllables.replaceChildren(...syllableNodes);
for (const n of notes()) drawNote(n, true);
syllables.replaceChildren(...notes().map(n => {
const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""),
n.text.trim() || "~");
s.style.left = `${x(n.start)}px`;
s.dataset.id = n.id;
return s;
}));
// over the notes: the curve matters most where it agrees with one
if (curve) {
@@ -215,27 +401,231 @@ export function createSheet(root, { seek }) {
prev.disabled = at <= 0;
next.disabled = at >= lines.length - 1;
const tags = [];
if (dirty()) tags.push(["unsaved", "sheet-badge-dirty"]);
if (line.edited) tags.push(["edited", "sheet-badge-edited"]);
if (line.notes.some(n => n.low_confidence)) tags.push(["low confidence", "sheet-badge-low"]);
if (notes().some(n => n.low_confidence)) tags.push(["low confidence", "sheet-badge-low"]);
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
paintTools();
}
plot.addEventListener("click", (e) => {
function paintSelection() {
for (const node of root.querySelectorAll("[data-id]")) {
const on = selected.has(Number(node.dataset.id));
node.classList.toggle(node.tagName === "rect" ? "sheet-note-selected"
: "sheet-syllable-selected", on);
}
paintTools();
}
function paintTools() {
const line = review.lines[index];
const editable = !!grid() && !saving;
tools.undo.disabled = !editable || !hist.canUndo;
tools.redo.disabled = !editable || !hist.canRedo;
tools.split.disabled = tools.merge.disabled = !editable || one() === null;
for (const b of [tools.remove, tools.golden, tools.free, tools.rap]) {
b.disabled = !editable || !selected.size;
}
tools.discard.disabled = !dirty() || saving;
tools.save.disabled = !dirty() || saving;
tools.revert.disabled = !line.edited || saving;
}
// --- pointer -------------------------------------------------------------
function hit(e) {
const box = plot.getBoundingClientRect();
const px = e.clientX - box.left;
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
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;
let mode = null;
if (note) {
const left = view.x(note.start);
const right = view.x(note.end);
mode = right - left > 3 * EDGE && px >= right - EDGE ? "end"
: right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move";
}
return { px, py, note, mode };
}
plot.addEventListener("pointermove", (e) => {
if (!view) return;
const px = e.clientX - plot.getBoundingClientRect().left;
if (!drag) {
const { note, mode } = hit(e);
plot.style.cursor = !note || !grid() ? "crosshair"
: mode === "move" ? "grab" : "ew-resize";
return;
}
const box = plot.getBoundingClientRect();
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.note) return;
if (drag.mode === "move") {
const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0);
const beats = Math.round((px - drag.px) / spbPx);
const semis = Math.round((drag.py - py) / view.rowH);
preview = edit.move(drag.base, selected, beats, semis, grid());
} else {
preview = edit.resize(drag.base, drag.note.id, drag.mode, view.time(px), grid());
}
render();
});
plot.addEventListener("pointerdown", (e) => {
if (!view || e.button !== 0) return;
// the browser's own focus handling would otherwise take focus off the
// sheet, and with it the keyboard
e.preventDefault();
plot.focus();
const { px, py, note, mode } = hit(e);
if (px < GUTTER) return;
seek(view.t0 + ((px - GUTTER) / (view.width - GUTTER)) * (view.t1 - view.t0));
if (note && grid()) {
if (e.shiftKey) {
selected = new Set(selected);
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
} else if (!selected.has(note.id)) {
selected = new Set([note.id]);
}
plot.setPointerCapture(e.pointerId);
plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize";
paintSelection();
}
drag = { px, py, note, mode, base: notes(), moved: false };
});
const endDrag = (e, cancelled) => {
if (!drag) return;
const d = drag;
drag = null;
if (preview) {
const next = preview;
preview = null;
if (cancelled) render(); else change(next);
} else if (!d.moved && !d.note && !cancelled) {
selected = new Set();
render();
seek(view.time(d.px));
}
if (plot.hasPointerCapture?.(e.pointerId)) plot.releasePointerCapture(e.pointerId);
};
plot.addEventListener("pointerup", (e) => endDrag(e, false));
plot.addEventListener("pointercancel", (e) => endDrag(e, true));
plot.addEventListener("dblclick", (e) => {
if (!view || !grid()) return;
const { px, py, note } = hit(e);
if (px < GUTTER) return;
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]));
editText(out.id);
});
// --- syllable text -------------------------------------------------------
function editText(id) {
const note = notes().find(n => n.id === id);
if (!note) return;
const input = document.createElement("input");
input.className = "sheet-text-input";
input.value = note.text.trim();
input.style.left = `${view.x(note.start)}px`;
input.setAttribute("aria-label", "syllable");
syllables.append(input);
input.focus();
input.select();
let done = false;
const finish = (commit) => {
if (done) return;
done = true;
const value = input.value;
input.remove();
if (commit) {
const next = edit.setText(notes(), id, value);
if (next !== notes()) change(next);
}
plot.focus();
};
input.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") finish(true);
else if (e.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
}
syllables.addEventListener("dblclick", (e) => {
const id = e.target.dataset?.id;
if (id) editText(Number(id));
});
// --- keyboard ------------------------------------------------------------
plot.addEventListener("keydown", (e) => {
if (!view) return;
const ctrl = e.ctrlKey || e.metaKey;
const key = e.key;
const handled = () => { e.preventDefault(); e.stopPropagation(); };
if (ctrl && key.toLowerCase() === "s") { handled(); save(); return; }
if (ctrl && key.toLowerCase() === "z") { handled(); e.shiftKey ? redo() : undo(); return; }
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;
const ns = notes();
if (key === "Tab") {
handled();
if (!ns.length) return;
const at = ns.findIndex(n => selected.has(n.id));
const to = at < 0 ? (e.shiftKey ? ns.length - 1 : 0)
: (at + (e.shiftKey ? -1 : 1) + ns.length) % ns.length;
selected = new Set([ns[to].id]);
render();
return;
}
if (key === "Escape") { handled(); selected = new Set(); render(); return; }
if (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
if (!selected.size) return;
const arrows = { ArrowLeft: -1, ArrowRight: 1 };
if (key in arrows) {
handled();
change(e.shiftKey ? edit.stretch(ns, selected, arrows[key], grid())
: edit.move(ns, selected, arrows[key], 0, grid()));
} else if (key === "ArrowUp" || key === "ArrowDown") {
handled();
change(edit.move(ns, selected, 0, (key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? 12 : 1), grid()));
} else if (key === "Delete" || key === "Backspace") {
handled(); removeSelected();
} else if (key === "Enter" && one() !== null) {
handled(); editText(one());
} else if (key.toLowerCase() === "s") {
handled(); splitSelected();
} else if (key.toLowerCase() === "m") {
handled(); mergeSelected();
} else if ({ g: "*", f: "F", r: "R" }[key.toLowerCase()]) {
handled(); kind({ g: "*", f: "F", r: "R" }[key.toLowerCase()]);
}
});
window.addEventListener("beforeunload", (e) => {
if (dirty()) e.preventDefault();
});
new ResizeObserver(() => render()).observe(plot);
return {
// keeps the line on show when the song is the same one, refreshed
load(id, data) {
const same = id === jobId;
review = data;
root.hidden = false;
const first = visible()[0];
index = null;
if (id !== jobId) {
if (!same) {
jobId = id;
curve = null;
fetch(`/api/jobs/${id}/pitch`)
@@ -243,18 +633,23 @@ export function createSheet(root, { seek }) {
.then(c => { if (jobId === id) { curve = c; render(); } })
.catch(() => {});
}
if (first) showLine(first.index);
else root.hidden = true;
const lines = visible();
if (!lines.length) { root.hidden = true; return; }
open(same && lines.some(l => l.index === index) ? index : lines[0].index);
},
clear() {
review = null;
jobId = null;
curve = null;
index = null;
view = null;
view = layout = hist = baseline = preview = null;
root.hidden = true;
},
showLine,
// playback following the song: never at the cost of unsaved edits
showLine(i) {
if (review && i !== index && !dirty()) open(i);
},
isDirty: dirty,
setTime(t) {
if (!view) return;
const inside = t >= view.t0 && t <= view.t1;
+122
View File
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as m from "../../src/songtrace/static/edit-model.js";
// 300 BPM: one UltraStar beat is 0.05s, starting at GAP = 1s
const GRID = { bpm: 300, gap_ms: 1000 };
const near = (a, b) => Math.abs(a - b) < 1e-9;
function line() {
return m.withIds([
{ start: 1.52, end: 1.71, pitch: 0, text: "one", kind: ":", low_confidence: false },
{ start: 1.8, end: 2.0, pitch: 2, text: " two", kind: ":", low_confidence: true },
]);
}
test("snapping lands on the beat grid from GAP", () => {
assert.ok(near(m.snap(1.52, GRID), 1.5));
assert.ok(near(m.snap(1.53, GRID), 1.55));
assert.ok(near(m.snap(0.97, GRID), 0.95)); // beats before GAP too
});
test("moving snaps to the grid, then shifts by beats and semitones", () => {
const notes = line();
const out = m.move(notes, new Set([notes[0].id]), 2, -3, GRID);
assert.ok(near(out[0].start, 1.6) && near(out[0].end, 1.8));
assert.equal(out[0].pitch, -3);
assert.equal(out[1], notes[1]); // untouched note is the same object
assert.equal(notes[0].start, 1.52); // input left alone
});
test("moving never goes before the start of the song", () => {
const notes = m.withIds([{ start: 0.1, end: 0.2, pitch: 0, text: "x", kind: ":" }]);
const out = m.move(notes, new Set([notes[0].id]), -100, 0, GRID);
assert.ok(near(out[0].start, 0));
});
test("resizing moves one edge and keeps at least a beat", () => {
const notes = line();
const id = notes[0].id;
assert.ok(near(m.resize(notes, id, "end", 1.9, GRID)[0].end, 1.9));
assert.ok(near(m.resize(notes, id, "end", 1.0, GRID)[0].end, 1.55));
const startMoved = m.resize(notes, id, "start", 1.4, GRID)[0];
assert.ok(near(startMoved.start, 1.4) && near(startMoved.end, 1.7));
});
test("stretching changes length by whole beats", () => {
const notes = line();
const out = m.stretch(notes, new Set([notes[1].id]), 2, GRID);
assert.ok(near(out[1].start, 1.8) && near(out[1].end, 2.1));
assert.ok(near(m.stretch(notes, new Set([notes[1].id]), -10, GRID)[1].end, 1.85));
});
test("splitting halves a note on a beat and holds the syllable", () => {
const notes = line();
const { notes: out, ids } = m.split(notes, notes[1].id, GRID);
assert.equal(out.length, 3);
assert.ok(near(out[1].end, 1.9) && near(out[2].start, 1.9));
assert.deepEqual([out[1].text, out[2].text], [" two", "~"]);
assert.equal(ids.length, 2);
// one beat cannot be split
const short = m.withIds([{ start: 1.0, end: 1.05, pitch: 0, text: "x", kind: ":" }]);
assert.equal(m.split(short, short[0].id, GRID).notes, short);
});
test("merging joins a note with the next, dropping a held ~", () => {
const notes = m.withIds([
{ start: 1.0, end: 1.2, pitch: 0, text: "sev", kind: ":" },
{ start: 1.2, end: 1.4, pitch: 0, text: "en", kind: ":" },
{ start: 1.4, end: 1.6, pitch: 0, text: "~", kind: ":" },
]);
const once = m.merge(notes, notes[0].id);
assert.deepEqual(once.map(n => n.text), ["seven", "~"]);
const twice = m.merge(once, once[0].id);
assert.deepEqual(twice.map(n => [n.text, n.end]), [["seven", 1.6]]);
assert.equal(m.merge(twice, twice[0].id), twice); // nothing after it
});
test("inserting adds a two-beat held note on the grid", () => {
const { notes, id } = m.insert(line(), 1.76, 4, GRID);
const added = notes.find(n => n.id === id);
assert.ok(near(added.start, 1.75) && near(added.end, 1.85));
assert.deepEqual([added.pitch, added.text, added.kind], [4, "~", ":"]);
assert.deepEqual(notes.map(n => n.text), ["one", "~", " two"]);
});
test("a kind toggles back to normal when every selected note has it", () => {
const notes = line();
const both = new Set(notes.map(n => n.id));
const golden = m.setKind(notes, both, "*");
assert.deepEqual(golden.map(n => n.kind), ["*", "*"]);
assert.deepEqual(m.setKind(golden, both, "*").map(n => n.kind), [":", ":"]);
const mixed = m.setKind(golden, new Set([notes[0].id]), "F");
assert.deepEqual(m.setKind(mixed, both, "F").map(n => n.kind), ["F", "F"]);
});
test("editing text keeps a word-initial space and refuses blank", () => {
const notes = line();
assert.equal(m.setText(notes, notes[1].id, "too")[1].text, " too");
assert.equal(m.setText(notes, notes[0].id, "won")[0].text, "won");
assert.equal(m.setText(notes, notes[0].id, " won")[0].text, " won");
assert.equal(m.setText(notes, notes[0].id, " "), notes);
});
test("saved notes carry duration and kind, not ids", () => {
const saved = m.toSaved(line());
assert.deepEqual(Object.keys(saved[0]).sort(), ["duration", "kind", "pitch", "start", "text"]);
assert.ok(near(saved[0].duration, 0.19));
});
test("history undoes and redoes, and a new change drops the redo", () => {
const h = m.history("a");
h.push("b");
h.push("c");
assert.equal(h.undo(), "b");
assert.equal(h.undo(), "a");
assert.equal(h.canUndo, false);
assert.equal(h.redo(), "b");
h.push("d");
assert.equal(h.canRedo, false);
assert.equal(h.present, "d");
});