Line breaks were always placed by songtrace, midway between lines and
recomputed around edited ones, so a break could not be put where a singer
breathes. The sheet now draws the breaks either side of a line as markers
that drag along the beat grid, kept between the lines they separate, and
double-click back to automatic. Break moves share the line's undo history
and SAVE LINE, which saves the notes only if they changed, so moving a
break never locks either line's notes.
Moved breaks are stored in edits.json keyed by the line each comes before,
follow their lines when the lyrics change, and win over the computed break
on every rebuild, clamped so a break never lands inside a line's notes.
The review lists each break with that line and whether it was moved, and
PUT /api/jobs/{id}/breaks takes {line: seconds | null}. merge now passes
untouched notes through as they are rather than rebuilding them, which
added float noise to their durations.
This commit is contained in:
@@ -70,6 +70,11 @@ next note, S splits, M merges with the next note, G/F/R make a note golden,
|
|||||||
freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
|
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** 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
|
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
|
detected at double or half speed, and **ALIGN TO NOTE** moves GAP the least
|
||||||
|
|||||||
+48
-9
@@ -19,8 +19,9 @@ KINDS = {":", "*", "F", "R", "G"}
|
|||||||
|
|
||||||
|
|
||||||
def _empty(lyrics: list[list[str]]) -> dict:
|
def _empty(lyrics: list[list[str]]) -> dict:
|
||||||
# grid: the BPM and GAP set in the editor, or None for the pipeline's own
|
# grid: the BPM and GAP set in the editor, or None for the pipeline's own;
|
||||||
return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None}
|
# 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:
|
||||||
@@ -43,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"] and not data.get("grid"):
|
if not (data["lines"] or data["stale"] or data.get("grid") or data.get("breaks")):
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
tmp = path.with_suffix(".tmp")
|
tmp = path.with_suffix(".tmp")
|
||||||
@@ -66,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, "grid": data.get("grid")}
|
# a moved break goes with the line it comes before; with that line gone,
|
||||||
|
# there is nothing left for it to separate
|
||||||
|
breaks = {str(moved[int(k)]): t for k, t in data.get("breaks", {}).items()
|
||||||
|
if int(k) in moved}
|
||||||
|
return {"lyrics": lyrics, "lines": lines, "stale": stale, "grid": data.get("grid"),
|
||||||
|
"breaks": breaks}
|
||||||
|
|
||||||
|
|
||||||
def validate_notes(notes: list[dict]) -> list[dict]:
|
def validate_notes(notes: list[dict]) -> list[dict]:
|
||||||
@@ -99,6 +105,30 @@ def clear_line(data: dict, index: int) -> bool:
|
|||||||
return data["lines"].pop(str(index), None) is not None
|
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:
|
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."""
|
"""Set the chart's BPM (UltraStar's, a quarter of a beat per beat) and GAP."""
|
||||||
try:
|
try:
|
||||||
@@ -125,10 +155,12 @@ def merge(notes: list[dict], breaks: list[float], data: dict
|
|||||||
An edited line replaces the generated notes of that line, and the user's
|
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}
|
||||||
@@ -143,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]
|
||||||
@@ -160,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
|
||||||
|
|
||||||
@@ -189,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"], "grid": data.get("grid")}
|
"stale": data["stale"], "grid": data.get("grid"),
|
||||||
|
"moved_breaks": sorted(int(k) for k in data.get("breaks", {}))}
|
||||||
(work / FINAL_FILE).write_text(json.dumps(final))
|
(work / FINAL_FILE).write_text(json.dumps(final))
|
||||||
return final
|
return final
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -522,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); }
|
||||||
|
|||||||
+109
-13
@@ -41,6 +41,9 @@ export const SHORTCUTS = [
|
|||||||
["F", "freestyle"],
|
["F", "freestyle"],
|
||||||
["R", "rap"],
|
["R", "rap"],
|
||||||
["G / F / R", "again: back to normal"]]],
|
["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", [
|
["Syllable", [
|
||||||
["double-click", "edit, on a note"],
|
["double-click", "edit, on a note"],
|
||||||
["Enter", "edit the selected note"]]],
|
["Enter", "edit the selected note"]]],
|
||||||
@@ -264,12 +267,16 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
let hist = null; // the current line's notes, with undo
|
let 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;
|
||||||
let gridDraft = null; // BPM and GAP being tried out, not yet applied
|
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
|
// the grid being drawn and snapped to: the one being tried out, if any
|
||||||
@@ -291,6 +298,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
const span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line);
|
const 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),
|
||||||
@@ -326,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("");
|
||||||
@@ -351,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);
|
||||||
@@ -420,9 +476,20 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
// only what changed: a moved break alone must not mark the line edited
|
||||||
|
async function save() {
|
||||||
if (!dirty() || saving) return;
|
if (!dirty() || saving) return;
|
||||||
send(`lines/${index}`, "PUT", { notes: edit.toSaved(notes()) }, "saving…", "saved");
|
const state = hist.present;
|
||||||
|
const lines = visible();
|
||||||
|
const next = lines[lines.findIndex(l => l.index === index) + 1];
|
||||||
|
const moved = {};
|
||||||
|
if (state.before !== baseline.before) moved[index] = state.before;
|
||||||
|
if (state.after !== baseline.after && next) moved[next.index] = state.after;
|
||||||
|
if (state.notes !== baseline.notes &&
|
||||||
|
!await send(`lines/${index}`, "PUT", { notes: edit.toSaved(state.notes) }, "saving…", "saved")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(moved).length) await send("breaks", "PUT", moved, "saving…", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
function revert() {
|
function revert() {
|
||||||
@@ -548,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() || "~");
|
||||||
@@ -628,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;
|
||||||
@@ -638,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 || !canEdit() ? "crosshair"
|
plot.style.cursor = brk ? "col-resize"
|
||||||
|
: !note || !canEdit() ? "crosshair"
|
||||||
: mode === "move" ? "grab" : "ew-resize";
|
: mode === "move" ? "grab" : "ew-resize";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -653,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);
|
||||||
@@ -671,8 +761,9 @@ 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 (brk) plot.setPointerCapture(e.pointerId);
|
||||||
if (note && canEdit()) {
|
if (note && canEdit()) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
selected = new Set(selected);
|
selected = new Set(selected);
|
||||||
@@ -684,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));
|
||||||
@@ -707,8 +802,9 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
|
|
||||||
plot.addEventListener("dblclick", (e) => {
|
plot.addEventListener("dblclick", (e) => {
|
||||||
if (!view || !canEdit()) 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]));
|
||||||
|
|||||||
+16
-1
@@ -503,8 +503,17 @@ 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)}
|
"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")
|
||||||
@@ -586,6 +595,12 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
g = _chart_grid(work)
|
g = _chart_grid(work)
|
||||||
return g["bpm"] if g else None
|
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")
|
@app.put("/api/jobs/{job_id}/grid")
|
||||||
def save_grid(job_id: str, body: dict = Body(...)):
|
def save_grid(job_id: str, body: dict = Body(...)):
|
||||||
"""Set the chart's BPM and GAP, kept through rebuilds like any edit."""
|
"""Set the chart's BPM and GAP, kept through rebuilds like any edit."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -152,3 +152,38 @@ def test_finalize_reports_the_grid(tmp_path):
|
|||||||
edits.set_grid(data, 300, 1000)
|
edits.set_grid(data, 300, 1000)
|
||||||
edits.save(tmp_path, data)
|
edits.save(tmp_path, data)
|
||||||
assert edits.finalize(tmp_path, LYRICS)["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
|
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
|
||||||
|
|||||||
@@ -2049,3 +2049,28 @@ def test_reset_on_a_song_built_before_tempo_was_recorded_restores_its_bpm(tmp_pa
|
|||||||
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
|
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
|
||||||
body = client.delete(f"/api/jobs/{job_id}/grid").json()
|
body = client.delete(f"/api/jobs/{job_id}/grid").json()
|
||||||
assert body["grid"]["bpm"] == 300.0 and body["auto_bpm"] == 300.0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user