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.
190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
import json
|
||
|
||
import pytest
|
||
|
||
from songtrace import edits
|
||
|
||
LYRICS = [["one", "two"], ["three", "four"], ["five", "six"]]
|
||
|
||
|
||
def gen(start, line, pitch=0, dur=0.4, low=False, text="la"):
|
||
return {"start": start, "duration": dur, "pitch": pitch, "text": text,
|
||
"low_confidence": low, "line_index": line}
|
||
|
||
|
||
def edited(*lines):
|
||
data = edits._empty(LYRICS)
|
||
for index, notes in lines:
|
||
edits.set_line(data, index, notes)
|
||
return data
|
||
|
||
|
||
def note(start, pitch=5, dur=0.4, text="la", kind=":"):
|
||
return {"start": start, "duration": dur, "pitch": pitch, "text": text, "kind": kind}
|
||
|
||
|
||
GENERATED = [gen(1.0, 0), gen(1.5, 0), gen(4.0, 1), gen(4.5, 1), gen(8.0, 2), gen(8.5, 2)]
|
||
BREAKS = [3.0, 6.0]
|
||
|
||
|
||
def test_no_edits_leaves_the_generated_song_alone():
|
||
assert edits.merge(GENERATED, BREAKS, edits._empty(LYRICS)) == (GENERATED, BREAKS)
|
||
|
||
|
||
def test_an_edited_line_replaces_its_generated_notes():
|
||
notes, _ = edits.merge(GENERATED, BREAKS, edited(
|
||
(1, [note(4.2, pitch=7, kind="*"), note(4.8, pitch=9)])))
|
||
line1 = [n for n in notes if n["line_index"] == 1]
|
||
assert [(n["start"], n["pitch"], n["kind"]) for n in line1] == [
|
||
(4.2, 7, "*"), (4.8, 9, ":")]
|
||
assert not any(n["low_confidence"] for n in line1)
|
||
assert [n["line_index"] for n in notes] == [0, 0, 1, 1, 2, 2]
|
||
|
||
|
||
def test_neighbours_give_way_to_an_edited_line():
|
||
# line 1 edited to run from 1.7s to 8.6s, over its neighbours' notes
|
||
notes, _ = edits.merge(GENERATED, BREAKS, edited(
|
||
(1, [note(1.7), note(8.2, dur=0.4)])))
|
||
by_line = {}
|
||
for n in notes:
|
||
by_line.setdefault(n["line_index"], []).append(
|
||
(round(n["start"], 2), round(n["start"] + n["duration"], 2)))
|
||
assert by_line[0] == [(1.0, 1.4), (1.5, 1.7)] # cut back to end at 1.7s
|
||
assert by_line[2] == [(8.6, 8.9)] # 8.0 note gone, 8.5 starts at 8.6
|
||
|
||
|
||
def test_breaks_beside_an_edited_line_move_and_the_rest_stay():
|
||
data = edited((2, [note(9.0), note(9.5)]))
|
||
_, breaks = edits.merge(GENERATED, BREAKS, data)
|
||
assert breaks[0] == 3.0 # lines 0-1: generated
|
||
assert breaks[1] == pytest.approx((4.9 + 9.0) / 2) # lines 1-2: re-centred
|
||
|
||
|
||
def test_an_edit_follows_its_line_when_lines_are_added_above(tmp_path):
|
||
data = edited((1, [note(4.0)]))
|
||
edits.save(tmp_path, data)
|
||
moved = edits.load(tmp_path, [["zero"]] + LYRICS)
|
||
assert list(moved["lines"]) == ["2"] and moved["lines"]["2"]["line_index"] == 2
|
||
assert moved["stale"] == []
|
||
assert json.loads((tmp_path / edits.EDITS_FILE).read_text())["lyrics"][0] == ["zero"]
|
||
|
||
|
||
def test_an_edit_whose_words_changed_is_set_aside(tmp_path):
|
||
edits.save(tmp_path, edited((1, [note(4.0)]), (2, [note(8.0)])))
|
||
changed = [LYRICS[0], ["three", "more"], LYRICS[2]]
|
||
data = edits.load(tmp_path, changed)
|
||
assert list(data["lines"]) == ["2"]
|
||
assert [s["words"] for s in data["stale"]] == [["three", "four"]]
|
||
|
||
|
||
def test_validate_notes_checks_and_orders():
|
||
assert [n["start"] for n in edits.validate_notes([note(2.0), note(1.0)])] == [1.0, 2.0]
|
||
for bad in (note(1.0, kind="X"), note(1.0, dur=0), note(-1.0), note(1.0, text=" "),
|
||
{"start": 1.0}):
|
||
with pytest.raises(ValueError):
|
||
edits.validate_notes([bad])
|
||
|
||
|
||
def test_saving_no_edits_removes_the_file(tmp_path):
|
||
edits.save(tmp_path, edited((0, [note(1.0)])))
|
||
data = edits.load(tmp_path, LYRICS)
|
||
edits.clear_line(data, 0)
|
||
edits.save(tmp_path, data)
|
||
assert not (tmp_path / edits.EDITS_FILE).exists()
|
||
|
||
|
||
def _write_generated(work, notes, warnings):
|
||
(work / "notes_sec.json").write_text(json.dumps(
|
||
{"notes": notes, "breaks": BREAKS, "warnings": warnings}))
|
||
|
||
|
||
def test_finalize_without_edits_passes_warnings_through(tmp_path):
|
||
_write_generated(tmp_path, GENERATED, ["anything at all"])
|
||
final = edits.finalize(tmp_path, LYRICS)
|
||
assert final["warnings"] == ["anything at all"]
|
||
assert final["edited_lines"] == [] and final["stale"] == []
|
||
assert json.loads((tmp_path / edits.FINAL_FILE).read_text()) == final
|
||
|
||
|
||
def test_finalize_stops_reporting_a_line_the_user_fixed(tmp_path):
|
||
notes = [gen(1.0, 0), gen(4.0, 1, low=True), gen(8.0, 2)]
|
||
_write_generated(tmp_path, notes, ["1 low-confidence note near 0:04–0:04",
|
||
"missing lyrics near 0:20–0:25"])
|
||
edits.save(tmp_path, edited((1, [note(4.0)])))
|
||
final = edits.finalize(tmp_path, LYRICS)
|
||
assert final["warnings"] == ["missing lyrics near 0:20–0:25"]
|
||
assert final["edited_lines"] == [1]
|
||
|
||
|
||
def test_finalize_reports_stale_edits(tmp_path):
|
||
_write_generated(tmp_path, GENERATED, [])
|
||
edits.save(tmp_path, edited((1, [note(4.0)])))
|
||
final = edits.finalize(tmp_path, [LYRICS[0], ["other"], LYRICS[2]])
|
||
assert final["warnings"] == ["1 edited line no longer matches the lyrics; "
|
||
"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
|