Merge pull request 'webui: a grouped keyboard shortcut panel for the sheet' (#33) from sheet-shortcuts into sheet-editing
tests / test (pull_request_target) Successful in 4m28s
tests / test (pull_request_target) Successful in 4m28s
Reviewed-on: #33 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -70,6 +70,18 @@ next note, S splits, M merges with the next note, G/F/R make a note golden,
|
|||||||
freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
|
freestyle or rap, Delete removes, Ctrl+Z undoes, Ctrl+S saves and P plays
|
||||||
the line.
|
the line.
|
||||||
|
|
||||||
|
The ↵ markers either side of a line are its line breaks: drag one to move it
|
||||||
|
(it snaps to beats and stays between the lines it separates) and double-click
|
||||||
|
one to put it back where songtrace places it. Moving a break does not mark
|
||||||
|
either line's notes as edited, so they still follow a rebuild.
|
||||||
|
|
||||||
|
The **GRID** bar above the notes sets the chart's BPM and GAP. Changes redraw
|
||||||
|
the grid as you type and are charted on **APPLY**; ×2 and ÷2 fix a tempo
|
||||||
|
detected at double or half speed, and **ALIGN TO NOTE** moves GAP the least
|
||||||
|
that puts the selected note on a beat. Notes keep their timing in seconds and
|
||||||
|
re-snap to the new grid. **RESET** returns to the BPM songtrace chose (your
|
||||||
|
BPM override, or the detected tempo) with GAP at the first note.
|
||||||
|
|
||||||
Edits survive every rebuild, REBUILD ALL FROM… included: they are kept apart
|
Edits survive every rebuild, REBUILD ALL FROM… included: they are kept apart
|
||||||
from what the pipeline generates and laid back over it each time. **REVERT**
|
from what the pipeline generates and laid back over it each time. **REVERT**
|
||||||
returns a line to the generated notes. Changing a line's lyrics sets its edit
|
returns a line to the generated notes. Changing a line's lyrics sets its edit
|
||||||
|
|||||||
+68
-8
@@ -7,6 +7,7 @@ rebuild with a different BPM re-snaps it rather than breaking it.
|
|||||||
"""
|
"""
|
||||||
import difflib
|
import difflib
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -18,7 +19,9 @@ KINDS = {":", "*", "F", "R", "G"}
|
|||||||
|
|
||||||
|
|
||||||
def _empty(lyrics: list[list[str]]) -> dict:
|
def _empty(lyrics: list[list[str]]) -> dict:
|
||||||
return {"lyrics": lyrics, "lines": {}, "stale": []}
|
# grid: the BPM and GAP set in the editor, or None for the pipeline's own;
|
||||||
|
# breaks: line breaks moved by hand, keyed by the line each comes before
|
||||||
|
return {"lyrics": lyrics, "lines": {}, "stale": [], "grid": None, "breaks": {}}
|
||||||
|
|
||||||
|
|
||||||
def load(work: Path, lyrics: list[list[str]]) -> dict:
|
def load(work: Path, lyrics: list[list[str]]) -> dict:
|
||||||
@@ -41,7 +44,7 @@ def load(work: Path, lyrics: list[list[str]]) -> dict:
|
|||||||
|
|
||||||
def save(work: Path, data: dict) -> None:
|
def save(work: Path, data: dict) -> None:
|
||||||
path = work / EDITS_FILE
|
path = work / EDITS_FILE
|
||||||
if not data["lines"] and not data["stale"]:
|
if not (data["lines"] or data["stale"] or data.get("grid") or data.get("breaks")):
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return
|
return
|
||||||
tmp = path.with_suffix(".tmp")
|
tmp = path.with_suffix(".tmp")
|
||||||
@@ -64,7 +67,12 @@ def _remap(data: dict, lyrics: list[list[str]]) -> dict:
|
|||||||
stale.append(edit)
|
stale.append(edit)
|
||||||
else:
|
else:
|
||||||
lines[str(to)] = {**edit, "line_index": to}
|
lines[str(to)] = {**edit, "line_index": to}
|
||||||
return {"lyrics": lyrics, "lines": lines, "stale": stale}
|
# a moved break goes with the line it comes before; with that line gone,
|
||||||
|
# there is nothing left for it to separate
|
||||||
|
breaks = {str(moved[int(k)]): t for k, t in data.get("breaks", {}).items()
|
||||||
|
if int(k) in moved}
|
||||||
|
return {"lyrics": lyrics, "lines": lines, "stale": stale, "grid": data.get("grid"),
|
||||||
|
"breaks": breaks}
|
||||||
|
|
||||||
|
|
||||||
def validate_notes(notes: list[dict]) -> list[dict]:
|
def validate_notes(notes: list[dict]) -> list[dict]:
|
||||||
@@ -97,6 +105,49 @@ def clear_line(data: dict, index: int) -> bool:
|
|||||||
return data["lines"].pop(str(index), None) is not None
|
return data["lines"].pop(str(index), None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def set_breaks(data: dict, changes: dict) -> None:
|
||||||
|
"""Move line breaks, each keyed by the line it comes before, to a time in
|
||||||
|
seconds; None puts one back to where the notes alone would place it."""
|
||||||
|
breaks = dict(data.get("breaks", {}))
|
||||||
|
for key, t in changes.items():
|
||||||
|
try:
|
||||||
|
line = int(key)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"a break is keyed by the line it comes before, not {key!r}")
|
||||||
|
if not 0 < line < len(data["lyrics"]):
|
||||||
|
raise ValueError(f"no line {line} for a break to come before")
|
||||||
|
if t is None:
|
||||||
|
breaks.pop(str(line), None)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
t = float(t)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"a break needs a time in seconds, not {t!r}")
|
||||||
|
if not (math.isfinite(t) and t >= 0):
|
||||||
|
raise ValueError(f"a break needs a time of 0 or more, not {t}")
|
||||||
|
breaks[str(line)] = t
|
||||||
|
data["breaks"] = breaks
|
||||||
|
|
||||||
|
|
||||||
|
def set_grid(data: dict, bpm, gap_ms) -> None:
|
||||||
|
"""Set the chart's BPM (UltraStar's, a quarter of a beat per beat) and GAP."""
|
||||||
|
try:
|
||||||
|
bpm, gap_ms = float(bpm), float(gap_ms)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("bpm and gap_ms must be numbers")
|
||||||
|
if not (math.isfinite(bpm) and 0 < bpm <= 2000):
|
||||||
|
raise ValueError(f"bpm must be above 0 and at most 2000, not {bpm}")
|
||||||
|
if not (math.isfinite(gap_ms) and gap_ms >= 0):
|
||||||
|
raise ValueError(f"gap_ms must be 0 or more, not {gap_ms}")
|
||||||
|
data["grid"] = {"bpm": bpm, "gap_ms": gap_ms}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_grid(data: dict) -> bool:
|
||||||
|
had = bool(data.get("grid"))
|
||||||
|
data["grid"] = None
|
||||||
|
return had
|
||||||
|
|
||||||
|
|
||||||
def merge(notes: list[dict], breaks: list[float], data: dict
|
def merge(notes: list[dict], breaks: list[float], data: dict
|
||||||
) -> tuple[list[dict], list[float]]:
|
) -> tuple[list[dict], list[float]]:
|
||||||
"""Generated notes and breaks with every edited line laid over them.
|
"""Generated notes and breaks with every edited line laid over them.
|
||||||
@@ -104,10 +155,12 @@ def merge(notes: list[dict], breaks: list[float], data: dict
|
|||||||
An edited line replaces the generated notes of that line, and the user's
|
An edited line replaces the generated notes of that line, and the user's
|
||||||
timing wins: generated notes of earlier lines are cut back to end where it
|
timing wins: generated notes of earlier lines are cut back to end where it
|
||||||
starts, and those of later lines to start where it ends. Line breaks next
|
starts, and those of later lines to start where it ends. Line breaks next
|
||||||
to an edited line move to the middle of the gap it leaves.
|
to an edited line move to the middle of the gap it leaves; a break moved
|
||||||
|
by hand stays where it was put, kept between the lines it separates.
|
||||||
"""
|
"""
|
||||||
edited = {int(k): e["notes"] for k, e in data["lines"].items()}
|
edited = {int(k): e["notes"] for k, e in data["lines"].items()}
|
||||||
if not edited:
|
moved = {int(k): t for k, t in data.get("breaks", {}).items()}
|
||||||
|
if not edited and not moved:
|
||||||
return notes, breaks
|
return notes, breaks
|
||||||
spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"])
|
spans = {li: (ns[0]["start"], ns[-1]["start"] + ns[-1]["duration"])
|
||||||
for li, ns in edited.items() if ns}
|
for li, ns in edited.items() if ns}
|
||||||
@@ -122,7 +175,9 @@ def merge(notes: list[dict], breaks: list[float], data: dict
|
|||||||
end = min(end, a)
|
end = min(end, a)
|
||||||
elif li > eli:
|
elif li > eli:
|
||||||
start = max(start, b)
|
start = max(start, b)
|
||||||
if end - start >= MIN_NOTE_SEC:
|
if (start, end) == (n["start"], n["start"] + n["duration"]):
|
||||||
|
kept.append(n)
|
||||||
|
elif end - start >= MIN_NOTE_SEC:
|
||||||
kept.append({**n, "start": start, "duration": end - start})
|
kept.append({**n, "start": start, "duration": end - start})
|
||||||
for li, ns in edited.items():
|
for li, ns in edited.items():
|
||||||
kept += [{**n, "line_index": li, "low_confidence": False} for n in ns]
|
kept += [{**n, "line_index": li, "low_confidence": False} for n in ns]
|
||||||
@@ -139,13 +194,17 @@ def merge(notes: list[dict], breaks: list[float], data: dict
|
|||||||
order = sorted(first, key=lambda li: first[li]["start"])
|
order = sorted(first, key=lambda li: first[li]["start"])
|
||||||
out_breaks = []
|
out_breaks = []
|
||||||
for prev, nxt in zip(order, order[1:]):
|
for prev, nxt in zip(order, order[1:]):
|
||||||
|
end = last[prev]["start"] + last[prev]["duration"]
|
||||||
|
if nxt in moved:
|
||||||
|
# where it was put, but never inside either line's notes
|
||||||
|
out_breaks.append(min(max(moved[nxt], end), max(first[nxt]["start"], end)))
|
||||||
|
continue
|
||||||
if prev not in edited and nxt not in edited:
|
if prev not in edited and nxt not in edited:
|
||||||
# the generated break between these two lines, untouched
|
# the generated break between these two lines, untouched
|
||||||
gen = [t for t in breaks if line_after(t) == nxt]
|
gen = [t for t in breaks if line_after(t) == nxt]
|
||||||
if gen:
|
if gen:
|
||||||
out_breaks.append(gen[0])
|
out_breaks.append(gen[0])
|
||||||
continue
|
continue
|
||||||
end = last[prev]["start"] + last[prev]["duration"]
|
|
||||||
out_breaks.append((end + first[nxt]["start"]) / 2)
|
out_breaks.append((end + first[nxt]["start"]) / 2)
|
||||||
return kept, out_breaks
|
return kept, out_breaks
|
||||||
|
|
||||||
@@ -168,6 +227,7 @@ def finalize(work: Path, lyrics: list[list[str]]) -> dict:
|
|||||||
" the lyrics; set aside in the editor")
|
" the lyrics; set aside in the editor")
|
||||||
final = {"notes": notes, "breaks": breaks, "warnings": warnings,
|
final = {"notes": notes, "breaks": breaks, "warnings": warnings,
|
||||||
"edited_lines": sorted(int(k) for k in data["lines"]),
|
"edited_lines": sorted(int(k) for k in data["lines"]),
|
||||||
"stale": data["stale"]}
|
"stale": data["stale"], "grid": data.get("grid"),
|
||||||
|
"moved_breaks": sorted(int(k) for k in data.get("breaks", {}))}
|
||||||
(work / FINAL_FILE).write_text(json.dumps(final))
|
(work / FINAL_FILE).write_text(json.dumps(final))
|
||||||
return final
|
return final
|
||||||
|
|||||||
+24
-9
@@ -7,6 +7,9 @@ from .ultrastar import (LineBreak, Note, UltraStarSong, format_bpm, get_header,
|
|||||||
parse_bpm, parse_ultrastar, serialize_ultrastar)
|
parse_bpm, parse_ultrastar, serialize_ultrastar)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPO_FILE = "tempo.json"
|
||||||
|
|
||||||
|
|
||||||
def detect_bpm(instrumental_path: Path) -> float:
|
def detect_bpm(instrumental_path: Path) -> float:
|
||||||
import librosa
|
import librosa
|
||||||
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True)
|
y, sr = librosa.load(str(instrumental_path), sr=22050, mono=True)
|
||||||
@@ -26,11 +29,20 @@ def scale_bpm(bpm: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
|
def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
|
||||||
headers: list[tuple[str, str]]) -> UltraStarSong:
|
headers: list[tuple[str, str]],
|
||||||
|
gap_ms: float | None = None) -> UltraStarSong:
|
||||||
|
"""Snap notes to the beat grid. GAP defaults to the first note's start; a
|
||||||
|
GAP set later than that would put notes on negative beats, which players
|
||||||
|
reject, so it moves back by whole beats, keeping the grid where it was."""
|
||||||
if not notes:
|
if not notes:
|
||||||
raise ValueError("no notes to place on the beat grid")
|
raise ValueError("no notes to place on the beat grid")
|
||||||
gap_ms = round(notes[0]["start"] * 1000)
|
|
||||||
spb = 60 / (4 * file_bpm)
|
spb = 60 / (4 * file_bpm)
|
||||||
|
first_ms = notes[0]["start"] * 1000
|
||||||
|
if gap_ms is None:
|
||||||
|
gap_ms = first_ms
|
||||||
|
elif gap_ms > first_ms:
|
||||||
|
gap_ms -= math.ceil((gap_ms - first_ms) / (spb * 1000)) * spb * 1000
|
||||||
|
gap_ms = max(0, round(gap_ms))
|
||||||
|
|
||||||
def beat(t: float) -> int:
|
def beat(t: float) -> int:
|
||||||
return round((t - gap_ms / 1000) / spb)
|
return round((t - gap_ms / 1000) / spb)
|
||||||
@@ -59,15 +71,18 @@ def to_ultrastar(notes: list[dict], breaks: list[float], file_bpm: float,
|
|||||||
return UltraStarSong(headers=out_headers, body=body)
|
return UltraStarSong(headers=out_headers, body=body)
|
||||||
|
|
||||||
|
|
||||||
def rechart(txt_path: Path, notes: list[dict], breaks: list[float]) -> None:
|
def rechart(txt_path: Path, notes: list[dict], breaks: list[float],
|
||||||
"""Replace a chart's notes, keeping its headers and its BPM.
|
bpm: float | None = None, gap_ms: float | None = None) -> None:
|
||||||
|
"""Replace a chart's notes, keeping its headers and, unless given new
|
||||||
|
ones, its BPM.
|
||||||
|
|
||||||
For an edit saved from the editor: the tempo was settled when the song
|
For an edit saved from the editor: the packaged chart's headers may differ
|
||||||
was built, and the packaged chart's headers may differ from the work
|
from the work copy's (a re-encoded #VIDEO, a dropped #COVER), so each
|
||||||
copy's (a re-encoded #VIDEO, a dropped #COVER), so each keeps its own.
|
keeps its own.
|
||||||
"""
|
"""
|
||||||
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
|
song = parse_ultrastar(txt_path.read_text(encoding="utf-8"))
|
||||||
bpm = parse_bpm(get_header(song, "BPM"))
|
if bpm is None:
|
||||||
|
bpm = parse_bpm(get_header(song, "BPM"))
|
||||||
headers = [(k, v) for k, v in song.headers if k.upper() not in ("BPM", "GAP")]
|
headers = [(k, v) for k, v in song.headers if k.upper() not in ("BPM", "GAP")]
|
||||||
txt_path.write_text(serialize_ultrastar(to_ultrastar(notes, breaks, bpm, headers)),
|
txt_path.write_text(serialize_ultrastar(to_ultrastar(notes, breaks, bpm, headers, gap_ms)),
|
||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ def _run(ctx: Ctx) -> None:
|
|||||||
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
|
bpm = grid.scale_bpm(grid.detect_bpm(ctx.work / "instrumental.wav"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise PipelineError("could not detect a usable tempo - is the audio silent?")
|
raise PipelineError("could not detect a usable tempo - is the audio silent?")
|
||||||
|
# the pipeline's own tempo, which RESET in the editor returns to
|
||||||
|
(ctx.work / grid.TEMPO_FILE).write_text(json.dumps({"bpm": bpm}))
|
||||||
|
gap_ms = None
|
||||||
|
if data["grid"]:
|
||||||
|
bpm, gap_ms = data["grid"]["bpm"], data["grid"]["gap_ms"]
|
||||||
|
ctx.log("[grid] using the BPM and GAP set in the editor")
|
||||||
slug = song_slug(ctx.inp)
|
slug = song_slug(ctx.inp)
|
||||||
video_ext = Path(meta["video_file"]).suffix
|
video_ext = Path(meta["video_file"]).suffix
|
||||||
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
|
headers = [("TITLE", ctx.inp.title), ("ARTIST", ctx.inp.artist)]
|
||||||
@@ -31,7 +37,7 @@ def _run(ctx: Ctx) -> None:
|
|||||||
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
|
headers += [("CREATOR", ctx.cfg.creator), ("MP3", f"{slug}.mp3"),
|
||||||
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
|
("COVER", f"{slug}.jpg"), ("VIDEO", f"{slug}{video_ext}")]
|
||||||
try:
|
try:
|
||||||
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers)
|
song = grid.to_ultrastar(data["notes"], data["breaks"], bpm, headers, gap_ms)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise PipelineError("no notes were generated - the lyrics may not match the audio")
|
raise PipelineError("no notes were generated - the lyrics may not match the audio")
|
||||||
(ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
|
(ctx.work / "song.txt").write_text(serialize_ultrastar(song), encoding="utf-8")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -418,6 +418,39 @@ button { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
.sheet-badge-edited { color: var(--aqua); }
|
.sheet-badge-edited { color: var(--aqua); }
|
||||||
.sheet-badge-low { color: var(--fail); }
|
.sheet-badge-low { color: var(--fail); }
|
||||||
|
.sheet-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sheet-grid[hidden] { display: none; }
|
||||||
|
.sheet-grid-head {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--aqua);
|
||||||
|
}
|
||||||
|
.sheet-grid-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.sheet-grid-field input { width: 6.5em; padding: 3px 2px; font: 0.85rem var(--font-mono); }
|
||||||
|
.sheet-grid .action:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.sheet-grid .sheet-save:not(:disabled) { color: var(--bg); background: var(--purple); border-color: var(--purple); }
|
||||||
|
.sheet-grid-source { flex: 1; min-width: 7em; font-size: 0.76rem; text-align: right; color: var(--text-muted); }
|
||||||
|
.sheet-grid-pending { color: var(--warn); }
|
||||||
.sheet-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 12px; }
|
.sheet-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 12px; }
|
||||||
.sheet-tool-group { display: flex; gap: 6px; }
|
.sheet-tool-group { display: flex; gap: 6px; }
|
||||||
.sheet-tools .action:disabled { opacity: 0.35; cursor: default; }
|
.sheet-tools .action:disabled { opacity: 0.35; cursor: default; }
|
||||||
@@ -443,6 +476,42 @@ button { cursor: pointer; }
|
|||||||
.sheet-stale-head { color: var(--warn); margin-bottom: 6px; }
|
.sheet-stale-head { color: var(--warn); margin-bottom: 6px; }
|
||||||
.sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
|
.sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
|
||||||
.sheet-stale-text { flex: 1; color: var(--text-muted); }
|
.sheet-stale-text { flex: 1; color: var(--text-muted); }
|
||||||
|
.sheet-nav > .action[aria-expanded] { margin-left: 4px; }
|
||||||
|
.sheet-keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 14px 22px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--panel-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sheet-keys[hidden] { display: none; }
|
||||||
|
.sheet-keys-head {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--aqua);
|
||||||
|
}
|
||||||
|
.sheet-keys dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; margin: 0; font-size: 0.78rem; }
|
||||||
|
.sheet-keys dt { white-space: nowrap; color: var(--text-muted); }
|
||||||
|
.sheet-keys dd { margin: 0; color: var(--text); }
|
||||||
|
.sheet-keys kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.4em;
|
||||||
|
padding: 0 5px;
|
||||||
|
font: 0.72rem var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--control);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.sheet-help[hidden] { display: none; }
|
||||||
.sheet-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); }
|
.sheet-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); }
|
||||||
.sheet-svg { display: block; }
|
.sheet-svg { display: block; }
|
||||||
.sheet-row { fill: var(--panel-raised); }
|
.sheet-row { fill: var(--panel-raised); }
|
||||||
@@ -453,6 +522,11 @@ button { cursor: pointer; }
|
|||||||
.sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; }
|
.sheet-beat { stroke: var(--border); stroke-width: 1; opacity: 0.35; }
|
||||||
.sheet-beat-1 { stroke: var(--control); opacity: 0.55; }
|
.sheet-beat-1 { stroke: var(--control); opacity: 0.55; }
|
||||||
.sheet-beat-2 { stroke: var(--text-muted); opacity: 0.8; }
|
.sheet-beat-2 { stroke: var(--text-muted); opacity: 0.8; }
|
||||||
|
.sheet-breaks { pointer-events: none; }
|
||||||
|
.sheet-break { stroke: var(--text-muted); stroke-width: 1.5; stroke-dasharray: 5 4; }
|
||||||
|
.sheet-break-moved { stroke: var(--aqua); stroke-dasharray: none; }
|
||||||
|
.sheet-break-label { fill: var(--text-muted); stroke: none; font: 12px var(--font-mono); }
|
||||||
|
.sheet-break-label.sheet-break-moved { fill: var(--aqua); stroke: none; }
|
||||||
.sheet-curve { pointer-events: none; }
|
.sheet-curve { pointer-events: none; }
|
||||||
.sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; }
|
.sheet-curve polyline { fill: none; stroke: var(--aqua); stroke-width: 1.5; opacity: 0.9; }
|
||||||
.sheet-note { fill: var(--purple); }
|
.sheet-note { fill: var(--purple); }
|
||||||
|
|||||||
+324
-33
@@ -12,6 +12,56 @@ const PLOT_HEIGHT = 300; // px; rows divide this between the pitches shown
|
|||||||
const CONTEXT = 0.75; // seconds shown either side of the line
|
const CONTEXT = 0.75; // seconds shown either side of the line
|
||||||
const EDGE = 6; // px from a note's end that grabs it for resizing
|
const EDGE = 6; // px from a note's end that grabs it for resizing
|
||||||
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" };
|
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" };
|
||||||
|
const KEYS_OPEN = "songtrace.sheet-keys";
|
||||||
|
|
||||||
|
// The sheet's controls, grouped as an editor's reference card lists them.
|
||||||
|
// "+" joins keys pressed together, " / " separates alternatives, and a
|
||||||
|
// lowercase word is something done with the mouse rather than a key.
|
||||||
|
export const SHORTCUTS = [
|
||||||
|
["Select", [
|
||||||
|
["click", "select a note"],
|
||||||
|
["Shift+click", "add to the selection"],
|
||||||
|
["Tab / Shift+Tab", "next, previous note"],
|
||||||
|
["Esc", "clear the selection"]]],
|
||||||
|
["Move", [
|
||||||
|
["drag", "move in time and pitch"],
|
||||||
|
["← / →", "a beat earlier, later"],
|
||||||
|
["↑ / ↓", "a semitone up, down"],
|
||||||
|
["Shift+↑ / Shift+↓", "an octave up, down"]]],
|
||||||
|
["Length", [
|
||||||
|
["drag an end", "resize"],
|
||||||
|
["Shift+← / Shift+→", "a beat shorter, longer"]]],
|
||||||
|
["Notes", [
|
||||||
|
["double-click", "add a note, on empty space"],
|
||||||
|
["S", "split in two"],
|
||||||
|
["M", "merge with the next"],
|
||||||
|
["Delete", "remove"]]],
|
||||||
|
["Kind", [
|
||||||
|
["G", "golden"],
|
||||||
|
["F", "freestyle"],
|
||||||
|
["R", "rap"],
|
||||||
|
["G / F / R", "again: back to normal"]]],
|
||||||
|
["Line break", [
|
||||||
|
["drag a ↵ marker", "move it, between the lines it separates"],
|
||||||
|
["double-click a ↵", "back to where songtrace puts it"]]],
|
||||||
|
["Syllable", [
|
||||||
|
["double-click", "edit, on a note"],
|
||||||
|
["Enter", "edit the selected note"]]],
|
||||||
|
["Line", [
|
||||||
|
["Ctrl+Z", "undo"],
|
||||||
|
["Ctrl+Shift+Z / Ctrl+Y", "redo"],
|
||||||
|
["Ctrl+S", "save"],
|
||||||
|
["P", "play"],
|
||||||
|
["Page Up / Page Down", "previous, next line"],
|
||||||
|
["?", "show or hide these keys"]]],
|
||||||
|
];
|
||||||
|
|
||||||
|
// "Shift+Tab / Esc" as key caps, with mouse actions left as words
|
||||||
|
export function comboParts(combo) {
|
||||||
|
return combo.split(" / ").map(alt => alt.split("+").map(part => ({
|
||||||
|
text: part, key: !/^[a-z]/.test(part),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
const wrap = (n, d) => ((n % d) + d) % d;
|
const wrap = (n, d) => ((n % d) + d) % d;
|
||||||
const mod12 = (p) => wrap(p, 12);
|
const mod12 = (p) => wrap(p, 12);
|
||||||
@@ -88,7 +138,9 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1));
|
const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1));
|
||||||
const next = button("NEXT ▶", "next line (Page Down)", () => page(1));
|
const next = button("NEXT ▶", "next line (Page Down)", () => page(1));
|
||||||
const nav = div("sheet-nav");
|
const nav = div("sheet-nav");
|
||||||
nav.append(prev, title, next, badges);
|
const keysButton = button("KEYS ?", "keyboard shortcuts (?)", () => toggleKeys());
|
||||||
|
keysButton.setAttribute("aria-expanded", "false");
|
||||||
|
nav.append(prev, title, next, badges, keysButton);
|
||||||
|
|
||||||
const tools = {
|
const tools = {
|
||||||
undo: button("UNDO", "Ctrl+Z", () => undo()),
|
undo: button("UNDO", "Ctrl+Z", () => undo()),
|
||||||
@@ -123,12 +175,88 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
plot.append(svg, playhead);
|
plot.append(svg, playhead);
|
||||||
const syllables = div("sheet-syllables");
|
const syllables = div("sheet-syllables");
|
||||||
const words = div("sheet-words");
|
const words = div("sheet-words");
|
||||||
const help = div("sheet-help",
|
const help = div("sheet-help", "Press ? for keyboard shortcuts.");
|
||||||
"Drag a note to move it, its ends to resize · double-click empty space to add a note, " +
|
const keys = div("sheet-keys");
|
||||||
"a note to edit its syllable · ←→ move · ↑↓ pitch (Shift: octave) · " +
|
keys.id = "sheet-keys";
|
||||||
"Shift+←→ length · Tab next note · Delete · S split · M merge · G/F/R kind");
|
keysButton.setAttribute("aria-controls", keys.id);
|
||||||
|
for (const [group, entries] of SHORTCUTS) {
|
||||||
|
const section = div("sheet-keys-group");
|
||||||
|
const heading = div("sheet-keys-head", group);
|
||||||
|
const list = document.createElement("dl");
|
||||||
|
for (const [combo, action] of entries) {
|
||||||
|
const dt = document.createElement("dt");
|
||||||
|
comboParts(combo).forEach((alt, i) => {
|
||||||
|
if (i) dt.append(" / ");
|
||||||
|
alt.forEach((part, j) => {
|
||||||
|
if (j) dt.append("+");
|
||||||
|
const node = document.createElement(part.key ? "kbd" : "span");
|
||||||
|
node.textContent = part.text;
|
||||||
|
dt.append(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const dd = document.createElement("dd");
|
||||||
|
dd.textContent = action;
|
||||||
|
list.append(dt, dd);
|
||||||
|
}
|
||||||
|
section.append(heading, list);
|
||||||
|
keys.append(section);
|
||||||
|
}
|
||||||
const stale = div("sheet-stale");
|
const stale = div("sheet-stale");
|
||||||
root.replaceChildren(nav, stale, toolbar, plot, syllables, words, help);
|
const gridBar = div("sheet-grid");
|
||||||
|
const field = (label, step, unit) => {
|
||||||
|
const wrap = document.createElement("label");
|
||||||
|
wrap.className = "sheet-grid-field";
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.step = step;
|
||||||
|
input.min = "0";
|
||||||
|
wrap.append(label, input);
|
||||||
|
if (unit) wrap.append(unit);
|
||||||
|
return { wrap, input };
|
||||||
|
};
|
||||||
|
const bpmField = field("BPM", "0.01");
|
||||||
|
const gapField = field("GAP", "1", "ms");
|
||||||
|
const gridSource = div("sheet-grid-source");
|
||||||
|
const gridTools = {
|
||||||
|
double: button("×2", "double the BPM", () => tryGrid({ bpm: grid().bpm * 2 })),
|
||||||
|
half: button("÷2", "halve the BPM", () => tryGrid({ bpm: grid().bpm / 2 })),
|
||||||
|
earlier: button("−10", "GAP 10ms earlier", () => tryGrid({ gap_ms: grid().gap_ms - 10 })),
|
||||||
|
later: button("+10", "GAP 10ms later", () => tryGrid({ gap_ms: grid().gap_ms + 10 })),
|
||||||
|
align: button("ALIGN TO NOTE", "move GAP the least that puts the selected note on a beat",
|
||||||
|
() => alignToNote()),
|
||||||
|
cancel: button("CANCEL", "drop the BPM and GAP being tried", () => { gridDraft = null; render(); }),
|
||||||
|
reset: button("RESET", "back to the BPM songtrace chose, with GAP at the first note",
|
||||||
|
() => resetGrid()),
|
||||||
|
apply: button("APPLY", "chart the song on this BPM and GAP", () => applyGrid(),
|
||||||
|
"action sheet-save"),
|
||||||
|
};
|
||||||
|
const gridHead = div("sheet-grid-head", "GRID");
|
||||||
|
gridBar.append(gridHead, bpmField.wrap, gridTools.double, gridTools.half,
|
||||||
|
gapField.wrap, gridTools.earlier, gridTools.later, gridTools.align,
|
||||||
|
gridSource, gridTools.cancel, gridTools.reset, gridTools.apply);
|
||||||
|
for (const [f, key] of [[bpmField, "bpm"], [gapField, "gap_ms"]]) {
|
||||||
|
f.input.addEventListener("input", () => {
|
||||||
|
const v = Number(f.input.value);
|
||||||
|
if (f.input.value !== "" && Number.isFinite(v) && v >= 0 && (key === "gap_ms" || v > 0)) {
|
||||||
|
tryGrid({ [key]: v }, f.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.replaceChildren(nav, stale, gridBar, toolbar, keys, plot, syllables, words, help);
|
||||||
|
|
||||||
|
// stays as the person left it, across lines, songs and visits
|
||||||
|
function showKeys(open) {
|
||||||
|
keys.hidden = !open;
|
||||||
|
help.hidden = open;
|
||||||
|
keysButton.setAttribute("aria-expanded", String(open));
|
||||||
|
try { localStorage.setItem(KEYS_OPEN, open ? "1" : ""); } catch {}
|
||||||
|
}
|
||||||
|
function toggleKeys() {
|
||||||
|
showKeys(keys.hidden);
|
||||||
|
}
|
||||||
|
let keysOpen = false;
|
||||||
|
try { keysOpen = !!localStorage.getItem(KEYS_OPEN); } catch {}
|
||||||
|
showKeys(keysOpen);
|
||||||
|
|
||||||
let review = null;
|
let review = null;
|
||||||
let curve = null;
|
let curve = null;
|
||||||
@@ -139,14 +267,22 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
let hist = null; // the current line's notes, with undo
|
let hist = null; // the current line's notes, with undo
|
||||||
let baseline = null; // what the line holds on the server
|
let baseline = null; // what the line holds on the server
|
||||||
let preview = null; // notes mid-drag, not yet in the history
|
let preview = null; // notes mid-drag, not yet in the history
|
||||||
|
let breakPreview = null; // a break mid-drag: { which, t }
|
||||||
let selected = new Set();
|
let selected = new Set();
|
||||||
let drag = null;
|
let drag = null;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
|
|
||||||
const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l));
|
const visible = () => review.lines.filter(l => !l.dropped && lineSpan(l));
|
||||||
const notes = () => preview ?? hist?.present ?? [];
|
// the line being edited: its notes and the breaks either side of it, as
|
||||||
|
// one state so undo covers both. A break is its time, undefined where there
|
||||||
|
// is none to move, or null when it is to go back to automatic.
|
||||||
|
const notes = () => preview ?? hist?.present.notes ?? [];
|
||||||
const dirty = () => !!hist && hist.present !== baseline;
|
const dirty = () => !!hist && hist.present !== baseline;
|
||||||
const grid = () => review?.grid;
|
let gridDraft = null; // BPM and GAP being tried out, not yet applied
|
||||||
|
// the grid being drawn and snapped to: the one being tried out, if any
|
||||||
|
const grid = () => gridDraft ?? review?.grid;
|
||||||
|
// notes snap to the grid, so they wait while a new one is being tried
|
||||||
|
const canEdit = () => !!review?.grid && !gridDraft && !saving;
|
||||||
|
|
||||||
function say(text, error = false) {
|
function say(text, error = false) {
|
||||||
status.textContent = text;
|
status.textContent = text;
|
||||||
@@ -162,6 +298,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
const span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line);
|
const span = ts.length ? { start: Math.min(...ts), end: Math.max(...ts) } : lineSpan(line);
|
||||||
const range = pitchRange(notes().map(n => n.pitch));
|
const range = pitchRange(notes().map(n => n.pitch));
|
||||||
const want = { t0: span.start - CONTEXT, t1: span.end + CONTEXT, ...range };
|
const want = { t0: span.start - CONTEXT, t1: span.end + CONTEXT, ...range };
|
||||||
|
// the breaks either side are part of the line's page, so they stay on it
|
||||||
|
for (const slot of breakSlots()) {
|
||||||
|
const t = breakTime(slot);
|
||||||
|
want.t0 = Math.min(want.t0, t - 0.3);
|
||||||
|
want.t1 = Math.max(want.t1, t + 0.3);
|
||||||
|
}
|
||||||
layout = reset || !layout ? want : {
|
layout = reset || !layout ? want : {
|
||||||
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1),
|
t0: Math.min(layout.t0, want.t0), t1: Math.max(layout.t1, want.t1),
|
||||||
lo: Math.min(layout.lo, want.lo), hi: Math.max(layout.hi, want.hi),
|
lo: Math.min(layout.lo, want.lo), hi: Math.max(layout.hi, want.hi),
|
||||||
@@ -197,8 +339,16 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
function open(i) {
|
function open(i) {
|
||||||
index = i;
|
index = i;
|
||||||
const line = review.lines[i];
|
const line = review.lines[i];
|
||||||
baseline = edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" })));
|
const lines = visible();
|
||||||
|
const at = lines.findIndex(l => l.index === i);
|
||||||
|
const breakBefore = (l) => review.breaks?.find(b => b.line === l?.index)?.time;
|
||||||
|
baseline = {
|
||||||
|
notes: edit.withIds(line.notes.map(n => ({ ...n, kind: n.kind ?? ":" }))),
|
||||||
|
before: at > 0 ? breakBefore(line) : undefined,
|
||||||
|
after: breakBefore(lines[at + 1]),
|
||||||
|
};
|
||||||
hist = edit.history(baseline);
|
hist = edit.history(baseline);
|
||||||
|
breakPreview = null;
|
||||||
preview = null;
|
preview = null;
|
||||||
selected = new Set();
|
selected = new Set();
|
||||||
say("");
|
say("");
|
||||||
@@ -222,12 +372,47 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function change(next, keep = selected) {
|
function change(next, keep = selected) {
|
||||||
hist.push(next);
|
hist.push({ ...hist.present, notes: next });
|
||||||
selected = new Set([...keep].filter(id => next.some(n => n.id === id)));
|
selected = new Set([...keep].filter(id => next.some(n => n.id === id)));
|
||||||
fit(false);
|
fit(false);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeBreak(which, t) {
|
||||||
|
if (hist.present[which] === t) return;
|
||||||
|
hist.push({ ...hist.present, [which]: t });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the breaks either side of the line, each with the gap it may move in:
|
||||||
|
// from the end of the line before it to the start of the line after
|
||||||
|
function breakSlots() {
|
||||||
|
const ns = notes();
|
||||||
|
if (!hist || !ns.length) return [];
|
||||||
|
const lines = visible();
|
||||||
|
const at = lines.findIndex(l => l.index === index);
|
||||||
|
const prev = lines[at - 1];
|
||||||
|
const next = lines[at + 1];
|
||||||
|
const slots = [];
|
||||||
|
if (prev?.notes.length && hist.present.before !== undefined) {
|
||||||
|
slots.push({ which: "before", line: index,
|
||||||
|
lo: Math.max(...prev.notes.map(n => n.end)), hi: ns[0].start });
|
||||||
|
}
|
||||||
|
if (next?.notes.length && hist.present.after !== undefined) {
|
||||||
|
slots.push({ which: "after", line: next.index,
|
||||||
|
lo: Math.max(...ns.map(n => n.end)), hi: Math.min(...next.notes.map(n => n.start)) });
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// where a break is drawn; one going back to automatic is shown mid-gap,
|
||||||
|
// roughly where it will land
|
||||||
|
function breakTime(slot) {
|
||||||
|
const v = breakPreview?.which === slot.which ? breakPreview.t : hist.present[slot.which];
|
||||||
|
const t = v === null ? (slot.lo + slot.hi) / 2 : v;
|
||||||
|
return Math.min(Math.max(t, slot.lo), Math.max(slot.hi, slot.lo));
|
||||||
|
}
|
||||||
|
|
||||||
const undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } };
|
const undo = () => { if (hist?.canUndo) { hist.undo(); fit(false); render(); } };
|
||||||
const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } };
|
const redo = () => { if (hist?.canRedo) { hist.redo(); fit(false); render(); } };
|
||||||
const one = () => (selected.size === 1 ? [...selected][0] : null);
|
const one = () => (selected.size === 1 ? [...selected][0] : null);
|
||||||
@@ -259,46 +444,114 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
open(index);
|
open(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(method, body) {
|
// a change saved to the server, which answers with the refreshed review
|
||||||
|
async function send(path, method, body, busy, done) {
|
||||||
saving = true;
|
saving = true;
|
||||||
render();
|
render();
|
||||||
say(method === "PUT" ? "saving…" : "reverting…");
|
say(busy);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/jobs/${jobId}/lines/${index}`, {
|
const resp = await fetch(`/api/jobs/${jobId}/${path}`, {
|
||||||
method, headers: { "Content-Type": "application/json" },
|
method, headers: { "Content-Type": "application/json" },
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(() => ({}));
|
const err = await resp.json().catch(() => ({}));
|
||||||
say(err.detail || `error ${resp.status}`, true);
|
say(err.detail || `error ${resp.status}`, true);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const at = index;
|
const at = index;
|
||||||
review = data;
|
review = data;
|
||||||
|
gridDraft = null;
|
||||||
open(at);
|
open(at);
|
||||||
say(method === "PUT" ? "saved" : "reverted to the generated notes");
|
say(done);
|
||||||
onSaved(data);
|
onSaved(data);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
say("could not reach songtrace", true);
|
say("could not reach songtrace", true);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
// only what changed: a moved break alone must not mark the line edited
|
||||||
|
async function save() {
|
||||||
if (!dirty() || saving) return;
|
if (!dirty() || saving) return;
|
||||||
send("PUT", { notes: edit.toSaved(notes()) });
|
const state = hist.present;
|
||||||
|
const lines = visible();
|
||||||
|
const next = lines[lines.findIndex(l => l.index === index) + 1];
|
||||||
|
const moved = {};
|
||||||
|
if (state.before !== baseline.before) moved[index] = state.before;
|
||||||
|
if (state.after !== baseline.after && next) moved[next.index] = state.after;
|
||||||
|
if (state.notes !== baseline.notes &&
|
||||||
|
!await send(`lines/${index}`, "PUT", { notes: edit.toSaved(state.notes) }, "saving…", "saved")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(moved).length) await send("breaks", "PUT", moved, "saving…", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
function revert() {
|
function revert() {
|
||||||
if (!review.lines[index].edited || saving) return;
|
if (!review.lines[index].edited || saving) return;
|
||||||
if (!confirm("Go back to the generated notes for this line? Your edit is lost.")) return;
|
if (!confirm("Go back to the generated notes for this line? Your edit is lost.")) return;
|
||||||
send("DELETE");
|
send(`lines/${index}`, "DELETE", null, "reverting…", "reverted to the generated notes");
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
// redraws on the trial grid straight away; nothing is saved until APPLY
|
||||||
|
function tryGrid(change, typing = null) {
|
||||||
|
const next = { ...grid(), ...change };
|
||||||
|
next.gap_ms = Math.max(0, next.gap_ms);
|
||||||
|
const same = review.grid && next.bpm === review.grid.bpm && next.gap_ms === review.grid.gap_ms;
|
||||||
|
gridDraft = same ? null : next;
|
||||||
|
render(typing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignToNote() {
|
||||||
|
const note = notes().find(n => selected.has(n.id));
|
||||||
|
if (!note) return;
|
||||||
|
const g = grid();
|
||||||
|
const spbMs = 60000 / (4 * g.bpm);
|
||||||
|
const atMs = note.start * 1000;
|
||||||
|
const offset = (atMs - g.gap_ms) - Math.round((atMs - g.gap_ms) / spbMs) * spbMs;
|
||||||
|
tryGrid({ gap_ms: Math.round((g.gap_ms + offset) * 100) / 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyGrid() {
|
||||||
|
if (!gridDraft || saving) return;
|
||||||
|
if (dirty()) { say("save or discard this line's changes first", true); return; }
|
||||||
|
await send("grid", "PUT", gridDraft, "charting on the new grid…", "grid applied");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetGrid() {
|
||||||
|
if (!review.grid_edited || saving) return;
|
||||||
|
if (dirty()) { say("save or discard this line's changes first", true); return; }
|
||||||
|
await send("grid", "DELETE", null, "resetting the grid…",
|
||||||
|
"grid reset to the BPM songtrace chose");
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintGrid(typing) {
|
||||||
|
const g = grid();
|
||||||
|
gridBar.hidden = !review.grid;
|
||||||
|
if (!g) return;
|
||||||
|
const round = (v, d) => String(Math.round(v * 10 ** d) / 10 ** d);
|
||||||
|
// the field being typed in keeps what was typed, half-finished or not
|
||||||
|
if (typing !== bpmField.input) bpmField.input.value = round(g.bpm, 2);
|
||||||
|
if (typing !== gapField.input) gapField.input.value = round(g.gap_ms, 2);
|
||||||
|
gridSource.textContent = gridDraft ? "not applied"
|
||||||
|
: review.grid_edited ? "set in the editor"
|
||||||
|
: review.auto_bpm ? "songtrace's choice" : "";
|
||||||
|
gridSource.classList.toggle("sheet-grid-pending", !!gridDraft);
|
||||||
|
gridTools.align.disabled = saving || selected.size !== 1;
|
||||||
|
gridTools.cancel.disabled = !gridDraft || saving;
|
||||||
|
gridTools.apply.disabled = !gridDraft || saving;
|
||||||
|
gridTools.reset.disabled = !review.grid_edited || !!gridDraft || saving;
|
||||||
|
for (const b of [gridTools.double, gridTools.half, gridTools.earlier, gridTools.later]) {
|
||||||
|
b.disabled = saving;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(typing = null) {
|
||||||
if (!review || index === null) return;
|
if (!review || index === null) return;
|
||||||
const line = review.lines[index];
|
const line = review.lines[index];
|
||||||
const { t0, t1, lo, hi } = layout;
|
const { t0, t1, lo, hi } = layout;
|
||||||
@@ -362,6 +615,18 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
}
|
}
|
||||||
for (const n of notes()) drawNote(n, true);
|
for (const n of notes()) drawNote(n, true);
|
||||||
|
|
||||||
|
const marks = el("g", { class: "sheet-breaks" }, svg);
|
||||||
|
for (const slot of breakSlots()) {
|
||||||
|
const t = breakTime(slot);
|
||||||
|
const value = hist.present[slot.which];
|
||||||
|
const byHand = value !== null &&
|
||||||
|
(value !== baseline[slot.which] || review.breaks?.find(b => b.line === slot.line)?.moved);
|
||||||
|
const cls = `sheet-break${byHand ? " sheet-break-moved" : ""}`;
|
||||||
|
el("line", { x1: x(t), x2: x(t), y1: 0, y2: PLOT_HEIGHT, class: cls }, marks);
|
||||||
|
el("text", { x: x(t) + 3, y: 11, class: `${cls} sheet-break-label` }, marks)
|
||||||
|
.textContent = "↵";
|
||||||
|
}
|
||||||
|
|
||||||
syllables.replaceChildren(...notes().map(n => {
|
syllables.replaceChildren(...notes().map(n => {
|
||||||
const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""),
|
const s = div("sheet-syllable" + (selected.has(n.id) ? " sheet-syllable-selected" : ""),
|
||||||
n.text.trim() || "~");
|
n.text.trim() || "~");
|
||||||
@@ -407,6 +672,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
|
badges.replaceChildren(...tags.map(([text, cls]) => div(`sheet-badge ${cls}`, text)));
|
||||||
|
|
||||||
paintTools();
|
paintTools();
|
||||||
|
paintGrid(typing);
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintSelection() {
|
function paintSelection() {
|
||||||
@@ -416,11 +682,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
: "sheet-syllable-selected", on);
|
: "sheet-syllable-selected", on);
|
||||||
}
|
}
|
||||||
paintTools();
|
paintTools();
|
||||||
|
paintGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintTools() {
|
function paintTools() {
|
||||||
const line = review.lines[index];
|
const line = review.lines[index];
|
||||||
const editable = !!grid() && !saving;
|
const editable = canEdit();
|
||||||
tools.undo.disabled = !editable || !hist.canUndo;
|
tools.undo.disabled = !editable || !hist.canUndo;
|
||||||
tools.redo.disabled = !editable || !hist.canRedo;
|
tools.redo.disabled = !editable || !hist.canRedo;
|
||||||
tools.split.disabled = tools.merge.disabled = !editable || one() === null;
|
tools.split.disabled = tools.merge.disabled = !editable || one() === null;
|
||||||
@@ -440,6 +707,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
const py = e.clientY - box.top;
|
const py = e.clientY - box.top;
|
||||||
// by geometry, not the event's target: a render replaces the elements
|
// by geometry, not the event's target: a render replaces the elements
|
||||||
// under the pointer, and the topmost note drawn wins
|
// under the pointer, and the topmost note drawn wins
|
||||||
|
if (canEdit()) {
|
||||||
|
const brk = breakSlots().find(s => Math.abs(px - view.x(breakTime(s))) <= 5);
|
||||||
|
if (brk) return { px, py, note: null, brk, mode: "break" };
|
||||||
|
}
|
||||||
const pitch = view.pitch(py);
|
const pitch = view.pitch(py);
|
||||||
const note = [...notes()].reverse().find(n => n.pitch === pitch &&
|
const note = [...notes()].reverse().find(n => n.pitch === pitch &&
|
||||||
px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null;
|
px >= view.x(n.start) - 1 && px <= view.x(n.end) + 1) ?? null;
|
||||||
@@ -450,14 +721,15 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
mode = right - left > 3 * EDGE && px >= right - EDGE ? "end"
|
mode = right - left > 3 * EDGE && px >= right - EDGE ? "end"
|
||||||
: right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move";
|
: right - left > 3 * EDGE && px <= left + EDGE ? "start" : "move";
|
||||||
}
|
}
|
||||||
return { px, py, note, mode };
|
return { px, py, note, brk: null, mode };
|
||||||
}
|
}
|
||||||
|
|
||||||
plot.addEventListener("pointermove", (e) => {
|
plot.addEventListener("pointermove", (e) => {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
if (!drag) {
|
if (!drag) {
|
||||||
const { note, mode } = hit(e);
|
const { note, brk, mode } = hit(e);
|
||||||
plot.style.cursor = !note || !grid() ? "crosshair"
|
plot.style.cursor = brk ? "col-resize"
|
||||||
|
: !note || !canEdit() ? "crosshair"
|
||||||
: mode === "move" ? "grab" : "ew-resize";
|
: mode === "move" ? "grab" : "ew-resize";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -465,6 +737,12 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
const px = e.clientX - box.left;
|
const px = e.clientX - box.left;
|
||||||
const py = e.clientY - box.top;
|
const py = e.clientY - box.top;
|
||||||
if (Math.abs(px - drag.px) + Math.abs(py - drag.py) > 3) drag.moved = true;
|
if (Math.abs(px - drag.px) + Math.abs(py - drag.py) > 3) drag.moved = true;
|
||||||
|
if (drag.moved && drag.brk) {
|
||||||
|
const { which, lo, hi } = drag.brk;
|
||||||
|
breakPreview = { which, t: edit.placeBreak(view.time(px), lo, hi, grid()) };
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!drag.moved || !drag.note) return;
|
if (!drag.moved || !drag.note) return;
|
||||||
if (drag.mode === "move") {
|
if (drag.mode === "move") {
|
||||||
const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0);
|
const spbPx = view.x(60 / (4 * grid().bpm)) - view.x(0);
|
||||||
@@ -483,9 +761,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
// sheet, and with it the keyboard
|
// sheet, and with it the keyboard
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
plot.focus();
|
plot.focus();
|
||||||
const { px, py, note, mode } = hit(e);
|
const { px, py, note, brk, mode } = hit(e);
|
||||||
if (px < GUTTER) return;
|
if (px < GUTTER) return;
|
||||||
if (note && grid()) {
|
if (brk) plot.setPointerCapture(e.pointerId);
|
||||||
|
if (note && canEdit()) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
selected = new Set(selected);
|
selected = new Set(selected);
|
||||||
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
|
if (selected.has(note.id)) selected.delete(note.id); else selected.add(note.id);
|
||||||
@@ -496,18 +775,22 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize";
|
plot.style.cursor = mode === "move" ? "grabbing" : "ew-resize";
|
||||||
paintSelection();
|
paintSelection();
|
||||||
}
|
}
|
||||||
drag = { px, py, note, mode, base: notes(), moved: false };
|
drag = { px, py, note, brk, mode, base: notes(), moved: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
const endDrag = (e, cancelled) => {
|
const endDrag = (e, cancelled) => {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const d = drag;
|
const d = drag;
|
||||||
drag = null;
|
drag = null;
|
||||||
if (preview) {
|
if (breakPreview) {
|
||||||
|
const { which, t } = breakPreview;
|
||||||
|
breakPreview = null;
|
||||||
|
if (cancelled) render(); else changeBreak(which, t);
|
||||||
|
} else if (preview) {
|
||||||
const next = preview;
|
const next = preview;
|
||||||
preview = null;
|
preview = null;
|
||||||
if (cancelled) render(); else change(next);
|
if (cancelled) render(); else change(next);
|
||||||
} else if (!d.moved && !d.note && !cancelled) {
|
} else if (!d.moved && !d.note && !d.brk && !cancelled) {
|
||||||
selected = new Set();
|
selected = new Set();
|
||||||
render();
|
render();
|
||||||
seek(view.time(d.px));
|
seek(view.time(d.px));
|
||||||
@@ -518,9 +801,10 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
plot.addEventListener("pointercancel", (e) => endDrag(e, true));
|
plot.addEventListener("pointercancel", (e) => endDrag(e, true));
|
||||||
|
|
||||||
plot.addEventListener("dblclick", (e) => {
|
plot.addEventListener("dblclick", (e) => {
|
||||||
if (!view || !grid()) return;
|
if (!view || !canEdit()) return;
|
||||||
const { px, py, note } = hit(e);
|
const { px, py, note, brk } = hit(e);
|
||||||
if (px < GUTTER) return;
|
if (px < GUTTER) return;
|
||||||
|
if (brk) { changeBreak(brk.which, null); return; } // back to automatic
|
||||||
if (note) { editText(note.id); return; }
|
if (note) { editText(note.id); return; }
|
||||||
const out = edit.insert(notes(), view.time(px), view.pitch(py), grid());
|
const out = edit.insert(notes(), view.time(px), view.pitch(py), grid());
|
||||||
change(out.notes, new Set([out.id]));
|
change(out.notes, new Set([out.id]));
|
||||||
@@ -577,7 +861,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; }
|
if (ctrl && key.toLowerCase() === "y") { handled(); redo(); return; }
|
||||||
if (key === "PageUp") { handled(); page(-1); return; }
|
if (key === "PageUp") { handled(); page(-1); return; }
|
||||||
if (key === "PageDown") { handled(); page(1); return; }
|
if (key === "PageDown") { handled(); page(1); return; }
|
||||||
if (ctrl || e.altKey || !grid()) return;
|
if (ctrl || e.altKey || !canEdit()) return;
|
||||||
const ns = notes();
|
const ns = notes();
|
||||||
if (key === "Tab") {
|
if (key === "Tab") {
|
||||||
handled();
|
handled();
|
||||||
@@ -589,7 +873,13 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === "Escape") { handled(); selected = new Set(); render(); return; }
|
if (key === "?") { handled(); toggleKeys(); return; }
|
||||||
|
if (key === "Escape") {
|
||||||
|
handled();
|
||||||
|
if (!keys.hidden) showKeys(false);
|
||||||
|
else { selected = new Set(); render(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
|
if (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
|
||||||
if (!selected.size) return;
|
if (!selected.size) return;
|
||||||
const arrows = { ArrowLeft: -1, ArrowRight: 1 };
|
const arrows = { ArrowLeft: -1, ArrowRight: 1 };
|
||||||
@@ -628,6 +918,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
if (!same) {
|
if (!same) {
|
||||||
jobId = id;
|
jobId = id;
|
||||||
curve = null;
|
curve = null;
|
||||||
|
gridDraft = null;
|
||||||
fetch(`/api/jobs/${id}/pitch`)
|
fetch(`/api/jobs/${id}/pitch`)
|
||||||
.then(r => (r.ok ? r.json() : null))
|
.then(r => (r.ok ? r.json() : null))
|
||||||
.then(c => { if (jobId === id) { curve = c; render(); } })
|
.then(c => { if (jobId === id) { curve = c; render(); } })
|
||||||
@@ -642,7 +933,7 @@ export function createSheet(root, { seek, playLine, onSaved }) {
|
|||||||
jobId = null;
|
jobId = null;
|
||||||
curve = null;
|
curve = null;
|
||||||
index = null;
|
index = null;
|
||||||
view = layout = hist = baseline = preview = null;
|
view = layout = hist = baseline = preview = gridDraft = null;
|
||||||
root.hidden = true;
|
root.hidden = true;
|
||||||
},
|
},
|
||||||
// playback following the song: never at the cost of unsaved edits
|
// playback following the song: never at the cost of unsaved edits
|
||||||
|
|||||||
+52
-2
@@ -503,8 +503,18 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
for e in notes_data.get("stale", [])]
|
for e in notes_data.get("stale", [])]
|
||||||
media = {kind: _resolve_media(job, header) is not None
|
media = {kind: _resolve_media(job, header) is not None
|
||||||
for kind, header in MEDIA_HEADERS.items()}
|
for kind, header in MEDIA_HEADERS.items()}
|
||||||
|
# each break with the line it comes before, the key a move is saved by
|
||||||
|
moved = set(notes_data.get("moved_breaks", []))
|
||||||
|
starts = sorted((n["start"], n["line_index"]) for n in notes_data["notes"]
|
||||||
|
if "line_index" in n)
|
||||||
|
breaks = []
|
||||||
|
for t in notes_data.get("breaks", []):
|
||||||
|
after = next((li for start, li in starts if start >= t), None)
|
||||||
|
if after is not None:
|
||||||
|
breaks.append({"line": after, "time": t, "moved": after in moved})
|
||||||
return {"lines": lines, "missing_spans": missing_spans, "media": media,
|
return {"lines": lines, "missing_spans": missing_spans, "media": media,
|
||||||
"stale": stale, "grid": _chart_grid(work)}
|
"stale": stale, "breaks": breaks, "grid": _chart_grid(work),
|
||||||
|
"grid_edited": bool(notes_data.get("grid")), "auto_bpm": _auto_bpm(work)}
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/pitch")
|
@app.get("/api/jobs/{job_id}/pitch")
|
||||||
def get_pitch(job_id: str):
|
def get_pitch(job_id: str):
|
||||||
@@ -562,16 +572,56 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
|||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
edits.save(work, data)
|
edits.save(work, data)
|
||||||
final = edits.finalize(work, lyrics)
|
final = edits.finalize(work, lyrics)
|
||||||
|
# the editor's grid, else the pipeline's own tempo with GAP at
|
||||||
|
# the first note, as the grid stage would chart it
|
||||||
|
chosen = final["grid"] or {"bpm": _auto_bpm(work), "gap_ms": None}
|
||||||
charts = [work / "song.txt"]
|
charts = [work / "song.txt"]
|
||||||
if job.output_dir:
|
if job.output_dir:
|
||||||
charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt")
|
charts.append(Path(job.output_dir) / f"{song_slug(job.inp)}.txt")
|
||||||
for txt in charts:
|
for txt in charts:
|
||||||
if txt.exists():
|
if txt.exists():
|
||||||
grid.rechart(txt, final["notes"], final["breaks"])
|
grid.rechart(txt, final["notes"], final["breaks"],
|
||||||
|
chosen["bpm"], chosen["gap_ms"])
|
||||||
with lock:
|
with lock:
|
||||||
job.warnings = list(final["warnings"])
|
job.warnings = list(final["warnings"])
|
||||||
return get_review(job_id)
|
return get_review(job_id)
|
||||||
|
|
||||||
|
def _auto_bpm(work: Path) -> float | None:
|
||||||
|
"""The tempo the pipeline chose: recorded by grid, or for a song built
|
||||||
|
before that was, the one its chart already has."""
|
||||||
|
try:
|
||||||
|
return float(json.loads((work / grid.TEMPO_FILE).read_text())["bpm"])
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
g = _chart_grid(work)
|
||||||
|
return g["bpm"] if g else None
|
||||||
|
|
||||||
|
@app.put("/api/jobs/{job_id}/breaks")
|
||||||
|
def save_breaks(job_id: str, body: dict = Body(...)):
|
||||||
|
"""Move line breaks: {line: seconds}, keyed by the line each comes
|
||||||
|
before; null puts one back where the notes alone would place it."""
|
||||||
|
return _edit(job_id, lambda data, lyrics: edits.set_breaks(data, body))
|
||||||
|
|
||||||
|
@app.put("/api/jobs/{job_id}/grid")
|
||||||
|
def save_grid(job_id: str, body: dict = Body(...)):
|
||||||
|
"""Set the chart's BPM and GAP, kept through rebuilds like any edit."""
|
||||||
|
job = _editable(job_id)
|
||||||
|
work = cfg.work_dir / song_slug(job.inp)
|
||||||
|
tempo = work / grid.TEMPO_FILE
|
||||||
|
# a song built before grid recorded its tempo: the chart's, taken now
|
||||||
|
# while it is still the pipeline's, is what RESET must return to
|
||||||
|
if not tempo.exists() and (bpm := _auto_bpm(work)) is not None:
|
||||||
|
tempo.write_text(json.dumps({"bpm": bpm}))
|
||||||
|
return _edit(job_id, lambda data, lyrics: edits.set_grid(
|
||||||
|
data, body.get("bpm"), body.get("gap_ms")))
|
||||||
|
|
||||||
|
@app.delete("/api/jobs/{job_id}/grid")
|
||||||
|
def reset_grid(job_id: str):
|
||||||
|
"""Go back to the BPM the pipeline chose and GAP at the first note."""
|
||||||
|
def change(data, lyrics):
|
||||||
|
if not edits.clear_grid(data):
|
||||||
|
raise HTTPException(404, "the grid was not set in the editor")
|
||||||
|
return _edit(job_id, change)
|
||||||
|
|
||||||
def _line(lyrics, index: int) -> int:
|
def _line(lyrics, index: int) -> int:
|
||||||
if not 0 <= index < len(lyrics):
|
if not 0 <= index < len(lyrics):
|
||||||
raise HTTPException(404, f"no line {index}")
|
raise HTTPException(404, f"no line {index}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,3 +45,28 @@ test("a line spans its notes and words", () => {
|
|||||||
{ start: 1.5, end: 3 });
|
{ start: 1.5, end: 3 });
|
||||||
assert.equal(lineSpan({ notes: [], words: [] }), null);
|
assert.equal(lineSpan({ notes: [], words: [] }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { SHORTCUTS, comboParts } from "../../src/songtrace/static/sheet.js";
|
||||||
|
|
||||||
|
test("shortcut combos split into keys and mouse actions", () => {
|
||||||
|
assert.deepEqual(comboParts("Shift+click / Tab"), [
|
||||||
|
[{ text: "Shift", key: true }, { text: "click", key: false }],
|
||||||
|
[{ text: "Tab", key: true }],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(comboParts("Page Up"), [[{ text: "Page Up", key: true }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every letter key the shortcut list names is one the sheet handles", () => {
|
||||||
|
const source = readFileSync(new URL("../../src/songtrace/static/sheet.js", import.meta.url), "utf8");
|
||||||
|
const handler = source.slice(source.indexOf('plot.addEventListener("keydown"'));
|
||||||
|
const letters = SHORTCUTS.flatMap(([, entries]) => entries)
|
||||||
|
.flatMap(([combo]) => comboParts(combo).flat())
|
||||||
|
.filter(p => p.key && /^[A-Z]$/.test(p.text))
|
||||||
|
.map(p => p.text.toLowerCase());
|
||||||
|
assert.ok(letters.length >= 6);
|
||||||
|
for (const letter of new Set(letters)) {
|
||||||
|
assert.ok(handler.includes(`"${letter}"`) || handler.includes(`${letter}:`),
|
||||||
|
`the list names ${letter.toUpperCase()} but the sheet does not handle it`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,3 +122,68 @@ def test_finalize_reports_stale_edits(tmp_path):
|
|||||||
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
|
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
|
||||||
assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
|
assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
|
||||||
"set aside in the editor"]
|
"set aside in the editor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_grid_set_in_the_editor_is_checked():
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_grid(data, "240.5", 1500)
|
||||||
|
assert data["grid"] == {"bpm": 240.5, "gap_ms": 1500.0}
|
||||||
|
for bpm, gap in ((0, 0), (-1, 0), (float("nan"), 0), (3000, 0), (300, -1), ("x", 0)):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
edits.set_grid(data, bpm, gap)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_grid_alone_keeps_the_edits_file_and_survives_new_lyrics(tmp_path):
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_grid(data, 300, 1000)
|
||||||
|
edits.save(tmp_path, data)
|
||||||
|
assert (tmp_path / edits.EDITS_FILE).exists()
|
||||||
|
loaded = edits.load(tmp_path, [["zero"]] + LYRICS)
|
||||||
|
assert loaded["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
|
||||||
|
assert edits.clear_grid(loaded) and not edits.clear_grid(loaded)
|
||||||
|
edits.save(tmp_path, loaded)
|
||||||
|
assert not (tmp_path / edits.EDITS_FILE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_reports_the_grid(tmp_path):
|
||||||
|
_write_generated(tmp_path, GENERATED, [])
|
||||||
|
assert edits.finalize(tmp_path, LYRICS)["grid"] is None
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_grid(data, 300, 1000)
|
||||||
|
edits.save(tmp_path, data)
|
||||||
|
assert edits.finalize(tmp_path, LYRICS)["grid"] == {"bpm": 300.0, "gap_ms": 1000.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_moved_breaks_are_checked():
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_breaks(data, {"1": 3.5, 2: "6.25"})
|
||||||
|
assert data["breaks"] == {"1": 3.5, "2": 6.25}
|
||||||
|
edits.set_breaks(data, {1: None})
|
||||||
|
assert data["breaks"] == {"2": 6.25}
|
||||||
|
for bad in ({0: 1.0}, {3: 1.0}, {"x": 1.0}, {1: -1}, {1: "soon"}, {1: float("inf")}):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
edits.set_breaks(data, bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_moved_break_stays_put_between_its_lines():
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_breaks(data, {1: 2.5})
|
||||||
|
notes, breaks = edits.merge(GENERATED, BREAKS, data)
|
||||||
|
assert notes == GENERATED # moving a break locks no notes
|
||||||
|
assert breaks == [2.5, 6.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_moved_break_never_lands_inside_a_line():
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_breaks(data, {1: 1.0, 2: 9.0}) # into line 0's notes, past line 2's start
|
||||||
|
_, breaks = edits.merge(GENERATED, BREAKS, data)
|
||||||
|
assert breaks == [pytest.approx(1.9), 8.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_moved_breaks_follow_their_lines_and_keep_the_file(tmp_path):
|
||||||
|
data = edits._empty(LYRICS)
|
||||||
|
edits.set_breaks(data, {1: 3.5, 2: 6.5})
|
||||||
|
edits.save(tmp_path, data)
|
||||||
|
assert (tmp_path / edits.EDITS_FILE).exists()
|
||||||
|
moved = edits.load(tmp_path, [["zero"], LYRICS[0], LYRICS[1], ["other"]])
|
||||||
|
assert moved["breaks"] == {"2": 3.5} # line 2's words changed: its break goes
|
||||||
|
|||||||
+42
-1
@@ -222,7 +222,7 @@ def test_grid_stage_honours_zero_bpm_override(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
|
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", boom)
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_to_ultrastar(notes, breaks, file_bpm, headers):
|
def fake_to_ultrastar(notes, breaks, file_bpm, headers, gap_ms=None):
|
||||||
captured["bpm"] = file_bpm
|
captured["bpm"] = file_bpm
|
||||||
return to_ultrastar(notes, breaks, 240.0, headers)
|
return to_ultrastar(notes, breaks, 240.0, headers)
|
||||||
|
|
||||||
@@ -299,3 +299,44 @@ def test_rechart_replaces_the_notes_and_keeps_headers_and_tempo(tmp_path):
|
|||||||
assert [k for k, _ in song.headers] == ["TITLE", "VIDEO", "BPM", "GAP"]
|
assert [k for k, _ in song.headers] == ["TITLE", "VIDEO", "BPM", "GAP"]
|
||||||
assert get_header(song, "BPM") == "300" and get_header(song, "GAP") == "2000"
|
assert get_header(song, "BPM") == "300" and get_header(song, "GAP") == "2000"
|
||||||
assert [(n.kind, n.pitch, n.text) for n in song.body] == [("F", 7, "new")]
|
assert [(n.kind, n.pitch, n.text) for n in song.body] == [("F", 7, "new")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_explicit_gap_sets_the_grid():
|
||||||
|
# 300 BPM: 0.05s beats; notes at 1.02s and 1.27s against GAP 1.0s
|
||||||
|
song = to_ultrastar([note(1.02, 0.1), note(1.27, 0.1)], [], 300.0, [], gap_ms=1000)
|
||||||
|
assert get_header(song, "GAP") == "1000"
|
||||||
|
assert [n.beat for n in song.body] == [0, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_gap_after_the_first_note_moves_back_by_whole_beats():
|
||||||
|
# GAP 1.13s is after the first note at 1.02s: back 3 beats to 0.98s, so
|
||||||
|
# the grid lines stay where they were set and the note is on beat 1
|
||||||
|
song = to_ultrastar([note(1.02, 0.1)], [], 300.0, [], gap_ms=1130)
|
||||||
|
assert get_header(song, "GAP") == "980"
|
||||||
|
assert song.body[0].beat == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_grid_stage_uses_the_editors_grid_and_records_its_own(tmp_path, monkeypatch):
|
||||||
|
from songtrace import edits
|
||||||
|
work = tmp_path / "w"
|
||||||
|
write_work(work)
|
||||||
|
monkeypatch.setattr(grid_stage_mod.grid, "detect_bpm", lambda p: 120.0)
|
||||||
|
ctx = make_ctx(work)
|
||||||
|
data = edits.load(work, [["x"]])
|
||||||
|
edits.set_grid(data, 300.0, 900)
|
||||||
|
edits.save(work, data)
|
||||||
|
grid_stage_mod.grid_stage().run(ctx)
|
||||||
|
song = parse_ultrastar((work / "song.txt").read_text(encoding="utf-8"))
|
||||||
|
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "900")
|
||||||
|
assert json.loads((work / "tempo.json").read_text()) == {"bpm": 240.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rechart_can_set_a_new_bpm_and_gap(tmp_path):
|
||||||
|
from songtrace.grid import rechart
|
||||||
|
from songtrace.ultrastar import serialize_ultrastar
|
||||||
|
txt = tmp_path / "song.txt"
|
||||||
|
txt.write_text(serialize_ultrastar(to_ultrastar([note(1.0, 0.5)], [], 240.0, [])),
|
||||||
|
encoding="utf-8")
|
||||||
|
rechart(txt, [note(1.02, 0.1)], [], 300.0, 1000)
|
||||||
|
song = parse_ultrastar(txt.read_text(encoding="utf-8"))
|
||||||
|
assert (get_header(song, "BPM"), get_header(song, "GAP")) == ("300", "1000")
|
||||||
|
|||||||
@@ -2003,3 +2003,74 @@ def test_pitch_curve_is_404_without_a_pitch_track(tmp_path):
|
|||||||
client, _ = make_client(tmp_path, chart_runner)
|
client, _ = make_client(tmp_path, chart_runner)
|
||||||
job_id = make_charted_job(client)
|
job_id = make_charted_job(client)
|
||||||
assert client.get(f"/api/jobs/{job_id}/pitch").status_code == 404
|
assert client.get(f"/api/jobs/{job_id}/pitch").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_header(path, key):
|
||||||
|
from songtrace.ultrastar import get_header, parse_ultrastar
|
||||||
|
return get_header(parse_ultrastar(path.read_text(encoding="utf-8")), key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_the_grid_recharts_and_survives_line_saves(tmp_path):
|
||||||
|
client, cfg = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client)
|
||||||
|
resp = client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["grid"] == {"bpm": 240.0, "gap_ms": 900.0} and body["grid_edited"]
|
||||||
|
packaged = cfg.output_dir / "A - T" / "A - T.txt"
|
||||||
|
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
|
||||||
|
client.put(f"/api/jobs/{job_id}/lines/1", json=EDIT)
|
||||||
|
assert (_chart_header(packaged, "BPM"), _chart_header(packaged, "GAP")) == ("240", "900")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resetting_the_grid_returns_to_the_pipelines_tempo(tmp_path):
|
||||||
|
client, cfg = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client)
|
||||||
|
(cfg.work_dir / "A - T" / "tempo.json").write_text(json.dumps({"bpm": 300.0}))
|
||||||
|
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
|
||||||
|
resp = client.delete(f"/api/jobs/{job_id}/grid")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["grid"] == {"bpm": 300.0, "gap_ms": 1000.0} # GAP back at the first note
|
||||||
|
assert not body["grid_edited"] and body["auto_bpm"] == 300.0
|
||||||
|
assert client.delete(f"/api/jobs/{job_id}/grid").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_grid_is_refused(tmp_path):
|
||||||
|
client, _ = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client)
|
||||||
|
for body in ({"bpm": 0, "gap_ms": 0}, {"bpm": 300, "gap_ms": -5}, {"bpm": "x"}, {}):
|
||||||
|
assert client.put(f"/api/jobs/{job_id}/grid", json=body).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_on_a_song_built_before_tempo_was_recorded_restores_its_bpm(tmp_path):
|
||||||
|
client, _ = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client) # charted at 300 BPM, no tempo.json
|
||||||
|
client.put(f"/api/jobs/{job_id}/grid", json={"bpm": 240, "gap_ms": 900})
|
||||||
|
body = client.delete(f"/api/jobs/{job_id}/grid").json()
|
||||||
|
assert body["grid"]["bpm"] == 300.0 and body["auto_bpm"] == 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_a_break_recharts_and_is_reported(tmp_path):
|
||||||
|
from songtrace.ultrastar import LineBreak, parse_ultrastar
|
||||||
|
client, cfg = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client)
|
||||||
|
assert client.get(f"/api/jobs/{job_id}/review").json()["breaks"] == [
|
||||||
|
{"line": 1, "time": 2.0, "moved": False}]
|
||||||
|
resp = client.put(f"/api/jobs/{job_id}/breaks", json={"1": 2.8})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["breaks"] == [{"line": 1, "time": 2.8, "moved": True}]
|
||||||
|
assert not any(line["edited"] for line in resp.json()["lines"])
|
||||||
|
packaged = cfg.output_dir / "A - T" / "A - T.txt"
|
||||||
|
body = parse_ultrastar(packaged.read_text(encoding="utf-8")).body
|
||||||
|
# 300 BPM: 0.05s beats from GAP 1.0s, so 2.8s is beat 36
|
||||||
|
assert [b.beat for b in body if isinstance(b, LineBreak)] == [36]
|
||||||
|
back = client.put(f"/api/jobs/{job_id}/breaks", json={"1": None}).json()
|
||||||
|
assert back["breaks"] == [{"line": 1, "time": 2.0, "moved": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_break_is_refused(tmp_path):
|
||||||
|
client, _ = make_client(tmp_path, chart_runner)
|
||||||
|
job_id = make_charted_job(client)
|
||||||
|
for body in ({"0": 1.0}, {"9": 1.0}, {"1": -2}, {"1": "x"}):
|
||||||
|
assert client.put(f"/api/jobs/{job_id}/breaks", json=body).status_code == 400
|
||||||
|
|||||||
Reference in New Issue
Block a user