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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user