Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
512a53b
refactor: suppress beat measure recomputation through context managers
FelipeDefensor Sep 5, 2026
73094eb
fix: crop and clear leave a stale beat measure structure behind
FelipeDefensor Sep 5, 2026
f8be552
perf: stop recalculating beat measures twice on every deletion
FelipeDefensor Sep 5, 2026
1a8dfe3
fix: hash each timeline attribute once
azfoo Oct 2, 2026
b3daa31
docs: correct the console log level in TiliaLogger's docstring
azfoo Oct 2, 2026
682e75e
test: add a patch for integer input dialogs
azfoo Oct 2, 2026
daa2ec1
test: cover hand-set measures through crop and refill
azfoo Oct 2, 2026
1c1f234
refactor: share time signature glyphs between score and beat timelines
azfoo Oct 2, 2026
058bd9b
feat: inspector fields that apply when editing finishes
azfoo Oct 2, 2026
7d40b4b
refactor: route element repositioning through TimelineUI
azfoo Oct 2, 2026
a00f422
feat: let timelines.add.* take the values they would prompt for
azfoo Oct 2, 2026
726a675
refactor: take the saved-state baseline after every file load
azfoo Oct 2, 2026
0066890
feat: add parser for bracketed beat pattern syntax
azfoo Oct 2, 2026
565bef7
feat: beat patterns, beat units, time signatures and quarter stamps
azfoo Oct 2, 2026
308a66e
test: answer beat timeline prompts through their dialogs
azfoo Oct 2, 2026
d9d280a
fix: reject beat patterns the parser could not handle
FelipeDefensor Oct 7, 2026
b9a4f82
fix: keep the last gap's tempo past the last beat
FelipeDefensor Oct 7, 2026
fdd0bc3
fix: hash beat units in beat order, not creation order
FelipeDefensor Oct 7, 2026
083bff2
fix: cap beat unit denominators at 128 everywhere
FelipeDefensor Oct 7, 2026
88ea22a
refactor: share the time signature band logic between UI and CLI
FelipeDefensor Oct 7, 2026
b2a1e77
style: annotate the beat unit code added on this branch
FelipeDefensor Oct 7, 2026
5b931f7
style: type MeasureMeter.beat_unit_id as the string id it holds
azfoo Oct 9, 2026
060b2ab
fix: make MeasureMeter hashable
azfoo Oct 9, 2026
4d9acff
fix: keep quarter lengths exact for whole-number beat values
azfoo Oct 9, 2026
c8a39bb
fix: refuse units longer than 32 characters
azfoo Oct 9, 2026
273f7c0
fix: mark the first measure's copied beat unit as assumed
azfoo Oct 9, 2026
b7df5cc
fix: name the governing beat unit by the beat that carries it
azfoo Oct 14, 2026
c5c9356
fix: say that a unit is too long, not that it isn't a number
azfoo Oct 14, 2026
05b4184
fix: keep a MeasureMeter's units and beat values as tuples however it…
azfoo Oct 14, 2026
150861b
fix: make only a whole number exact in quarter_length
azfoo Oct 14, 2026
ec8f5d3
fix: refuse fractions in units with a denominator above 128
azfoo Oct 19, 2026
cec45da
fix: keep beat units on their own beats when duplicating a beat timeline
azfoo Oct 26, 2026
78c5983
feat: keep measures on the beats that start them
azfoo Oct 27, 2026
9fa44a7
feat: write time signatures and their taps in the beat pattern
azfoo Oct 27, 2026
8cbfd8f
feat: edit bars and time signatures in one dialog
azfoo Oct 27, 2026
68382c6
feat: edit time signatures, not beat units
azfoo Oct 28, 2026
b08bba4
fix: let a time signature set by hand carry on while tapping
azfoo Oct 28, 2026
11910af
fix: refuse a stray number or an oversized numerator in a pattern at …
azfoo Oct 28, 2026
a448d17
fix: give beats imported from CSV the pattern's time signatures
azfoo Oct 28, 2026
180410d
fix: edit tapped bars apart from the pattern, and other review fixes
azfoo Oct 28, 2026
3dac64d
fix: keep every bar's length when a beat is added or deleted
azfoo Oct 28, 2026
06ae068
refactor: move the bars table's rows out of the Qt dialog
azfoo Oct 29, 2026
59f0f0b
feat: set time signatures from the CLI, not beat units
azfoo Oct 29, 2026
ed75d2d
feat: import time signatures from CSV, not beat units
azfoo Oct 29, 2026
751adbd
feat: export time signatures to JSON, not beat units
azfoo Oct 29, 2026
0863b92
fix: add a beat before the first as anywhere else
azfoo Oct 29, 2026
a04ebbc
perf: place pasted beats once, and refresh once per beat edit
azfoo Oct 29, 2026
be40af5
fix: set a time signature until the time signature changes
azfoo Oct 29, 2026
8d1378f
docs: stop calling shorter measures pickups
azfoo Oct 29, 2026
9f4d282
fix: read the last measure from its beats, like any other
azfoo Oct 29, 2026
17ff7be
fix: carry a CSV time signature on over measures it doesn't fit
azfoo Oct 29, 2026
6afa32e
fix: replace every beat unit in a measure a time signature is set on
azfoo Oct 29, 2026
ec6c6d2
fix: import CSV times in any order
azfoo Oct 29, 2026
4084d2d
fix: tell an assumed time signature from a written one in the inspector
azfoo Oct 29, 2026
2a0f875
perf: set time signatures without relabelling every beat
azfoo Oct 29, 2026
b3f53c0
refactor: remove set_beat_unit
azfoo Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions tests/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,24 @@ def patch_ask_for_string_dialog(success: bool | list[bool], string: str | list[s
return_values = list(zip(string, success, strict=True))
with patch.object(QInputDialog, "getText", side_effect=return_values):
yield


@contextmanager
def patch_ask_for_int_dialog(success: bool | list[bool], number: int | list[int]):
"""Patches QInputDialog.getInt to return the specified success values and numbers.

Args:
success: Whether the dialog was successful. If a list, the success value for each iteration.
number: Number input by user. If a list of numbers, the input values for each iteration.
"""

if not isinstance(success, list):
success = [success]
if not isinstance(number, list):
number = [number]
else:
raise ValueError("number must be an int if success is a bool.")

return_values = list(zip(number, success, strict=True))
with patch.object(QInputDialog, "getInt", side_effect=return_values):
yield
135 changes: 135 additions & 0 deletions tests/parsers/csv/test_beats_from_csv.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pathlib import Path
from unittest.mock import mock_open, patch

import pytest

from tests.parsers.csv.common import assert_in_errors
from tilia.parsers.csv.beat import beats_from_csv
from tilia.settings import settings
Expand Down Expand Up @@ -128,3 +130,136 @@ def test_measure_number_on_every_beat_is_forced_once(beat_tlui):

# A measure forced once per beat would need a reset per beat.
assert not beat_tlui[4].label.isVisible()


def beat_unit_values(beat_tl):
return [
(beat_tl.get_component(u.beat_id).time, u.denominator, u.units)
for u in beat_tl.beat_units
]


class TestTimeSignatureColumn:
def test_rows_give_their_measures_time_signatures(self, beat_tl):
beat_tl.beat_pattern = [2]
data = "time,time_signature\n0,6/8\n1,\n2,5/8(3 2)\n3,\n4,2/4\n5,"

success, errors = _import_with_patch(beat_tl, data)

assert success
assert errors == []
assert [m.time_signature for m in beat_tl.measure_meters] == [
(6, 8),
(5, 8),
(2, 4),
]
assert beat_unit_values(beat_tl) == [(0, 8, "3"), (2, 8, "3+2"), (4, 4, "1")]

def test_a_time_signature_carries_on(self, beat_tl):
beat_tl.beat_pattern = [2]

_import_with_patch(beat_tl, "time,time_signature\n0,6/8\n1,\n2,\n3,")

assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)]

def test_a_row_inside_a_measure_sets_that_measure(self, beat_tl):
beat_tl.beat_pattern = [2]

_import_with_patch(beat_tl, "time,time_signature\n0,\n1,6/8\n2,\n3,")

assert beat_unit_values(beat_tl) == [(0, 8, "3")]

def test_invalid_values_are_reported(self, beat_tl):
beat_tl.beat_pattern = [2]
data = "time,time_signature\n0,3/0\n1,\n2,4/4(2 1 1)\n3,\n4,x\n5,\n6,3/8\n7,"

success, errors = _import_with_patch(beat_tl, data)

assert_in_errors("'3/0' is not a valid time signature", errors)
assert_in_errors("It gives 3 taps, but the bar has 2.", errors)
assert_in_errors("'x' is not a valid time signature", errors)
# The first beat keeps its default time signature; only the valid row
# adds one.
assert beat_unit_values(beat_tl) == [(0, 4, "1"), (6, 8, "3/2")]

def test_a_time_signature_carries_on_over_other_numbers_of_taps(self, beat_tl):
data = "time,is_first_in_measure,time_signature\n0,True,6/8\n1,,\n2,True,\n3,,\n4,,"

_import_with_patch(beat_tl, data)

assert beat_tl.beats_in_measure == [2, 3]
assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)]

@pytest.mark.parametrize(
"text, time_signatures",
[
("6/8(3 3)", [(6, 8), (9, 8), (6, 8)]),
("2", [(2, 4), (3, 4), (2, 4)]),
],
)
def test_a_time_signature_carries_on_over_measures_it_does_not_fit(
self, beat_tl, text, time_signatures
):
data = (
"time,is_first_in_measure,time_signature\n"
f"0,True,{text}\n1,,\n2,True,\n3,,\n4,,\n5,True,\n6,,"
)

success, errors = _import_with_patch(beat_tl, data)

assert errors == []
assert beat_tl.beats_in_measure == [2, 3, 2]
assert [m.time_signature for m in beat_tl.measure_meters] == time_signatures

def test_a_time_signature_replaces_the_patterns_later_ones(self, beat_tl):
beat_tl.beat_pattern = "2[2/4] 3/4"
rows = "\n".join(f"{time}," for time in range(1, 9))

_import_with_patch(beat_tl, f"time,time_signature\n0,6/8\n{rows}")

assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8)] * 4

def test_times_in_any_order(self, beat_tl):
beat_tl.beat_pattern = [2]
data = "time,time_signature\n4,3/4\n0,6/8\n1,\n2,\n3,\n5,"

success, errors = _import_with_patch(beat_tl, data)

assert errors == []
assert beat_tl.beats_in_measure == [2, 2, 2]
assert [m.time_signature for m in beat_tl.measure_meters] == [
(6, 8),
(6, 8),
(3, 4),
]

def test_a_number_alone_is_assumed(self, beat_tl):
beat_tl.beat_pattern = [2]

_import_with_patch(beat_tl, "time,time_signature\n0,2\n1,")

assert beat_unit_values(beat_tl) == [(0, 4, "1")]
assert beat_tl.beat_units[0].assumed

def test_without_the_column(self, beat_tl):
_import_with_patch(beat_tl, "time\n0\n1")

assert beat_unit_values(beat_tl) == [(0, 4, "1")]
assert beat_tl.beat_units[0].assumed

def test_values_from_the_file_are_not_assumed(self, beat_tl):
beat_tl.beat_pattern = [2]

_import_with_patch(beat_tl, "time,time_signature\n0,2/4\n1,")

assert beat_unit_values(beat_tl) == [(0, 4, "1")]
assert not beat_tl.beat_units[0].assumed


def test_times_alone_follow_the_patterns_time_signatures(beat_tl):
beat_tl.beat_pattern = "6/8(3 3)"

_import_with_patch(beat_tl, "time\n0\n1\n2\n3")

assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)]
assert not any(m.is_assumed for m in beat_tl.measure_meters)
2 changes: 1 addition & 1 deletion tests/parsers/csv/test_markers_from_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def test_markers_by_measure_from_csv_multiple_measures_with_number(
beat_tlui.create_beat(time=2)
beat_tlui.create_beat(time=3)

beat_tl.measure_numbers = [1, 1, 1]
beat_tl.set_measure_numbers([1, 1, 1])

beat_tl.recalculate_measures()

Expand Down
4 changes: 2 additions & 2 deletions tests/parsers/score/test_score_from_musicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def test_changing_attributes(score_tl, beat_tl, tmp_path):
beat_tl.set_data("beat_pattern", [1])
for i in range(6):
beat_tl.create_beat(i)
beat_tl.measure_numbers = [1, 3, 0, 2, 4, 5]
beat_tl.set_measure_numbers([1, 3, 0, 2, 4, 5])
beat_tl.recalculate_measures()

_import_with_patch(score_tl, beat_tl, example, tmp_path)
Expand Down Expand Up @@ -656,7 +656,7 @@ def test_notes_in_a_measure_the_beat_timeline_skips_are_not_imported(
beat_tl.set_data("beat_pattern", [1])
for i in range(4):
beat_tl.create_beat(i)
beat_tl.measure_numbers = [1, 3, 4, 5]
beat_tl.set_measure_numbers([1, 3, 4, 5])
beat_tl.recalculate_measures()

success, errors = _import_with_patch(
Expand Down
66 changes: 66 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,69 @@ def test_image_export_resize_rejected(self, qtui, monkeypatch, tmp_path):
commands.execute("file.export.img", image_path)

assert not image_path.exists()


class TestExportTimeSignatures:
@staticmethod
def export(tmp_path):
commands.execute("file.export.json", path=tmp_path / "test.json")
with open(tmp_path / "test.json", encoding="utf-8") as f:
return json.load(f)["timelines"][0]["components"]

def test_time_signatures_are_exported(self, tilia, beat_tlui, tmp_path):
for time in range(4):
commands.execute("media.seek", time)
commands.execute("timeline.beat.add")
commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="6/8(2 1 2 1)")

components = self.export(tmp_path)

assert [c["kind"] for c in components] == ["BEAT"] * 4 + ["TIME_SIGNATURE"]
assert components[-1] == {
"time": 0,
"measure": 1,
"time_signature": "6/8",
"taps": "2 1 2 1",
"assumed": False,
"kind": "TIME_SIGNATURE",
}

@pytest.mark.parametrize(
"pattern, beat_count, time_signature, exported",
[("2", 7, "6/8", ["6/8", "3/8"]), ("4", 10, "4/4", ["4/4", "2/4"])],
)
def test_an_unfinished_last_measure_is_exported_as_tapped(
self, tilia, beat_tlui, tmp_path, pattern, beat_count, time_signature, exported
):
commands.execute("timeline.beat.set_pattern", beat_tlui, pattern=pattern)
for time in range(beat_count):
commands.execute("media.seek", time)
commands.execute("timeline.beat.add")
timeline = beat_tlui.timeline
timeline.set_time_signature(time_signature, 0, timeline.measure_count)

components = self.export(tmp_path)

# As the timeline's labels show it.
assert [c["time_signature"] for c in components if "time_signature" in c] == (
exported
)

def test_one_per_change(self, tilia, beat_tlui, tmp_path):
commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="2[2/4] 3/4")
for time in range(9):
commands.execute("media.seek", time)
commands.execute("timeline.beat.add")

components = self.export(tmp_path)

time_signatures = [
(c["time"], c["measure"], c["time_signature"], c["assumed"])
for c in components
if c["kind"] == "TIME_SIGNATURE"
]
assert time_signatures == [
(0, 1, "2/4", False),
(4, 3, "3/4", False),
(7, 4, "2/4", False),
]
Loading
Loading