From 5d29639d6a087a51756ad2b45258294882e3b6c6 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:22:50 +0200 Subject: [PATCH 1/6] feat(core): give the time map quarter stamps qstamps.py moves desktop#621's quarter stamps out of its BeatTimeline (beat_quarter_lengths, _build_quarter_stamps, _build_time_stamp_table, get_quarter_stamp_by_time and get_time_by_quarter_stamp, at 0ee8ea45), with the same rules: a beat lasts its value x 4 / denominator in quarter notes, each beat's stamp is the sum of the lengths before it from 0, and times and stamps convert by interpolating between beats and extrapolating beyond them with the tempo of the nearest gap. The stamps are exact inside and floats where they meet times, so at a beat the time map gives float() of the exact stamp, as #621 does. Each MeasureRow gains qstamp, its downbeat's, and quarters, its length, both exact Fractions; quarters is computed from the meter, like the time signature. The rows of a folded table get them too, in the timeline's order. TimeMap gains qstamp(t), time_at_qstamp(q), quarters(start, end) and assumed_between(start, end). It builds its stamp table the first time a qstamp is asked for, not with the map: the index reads every file's positions and never asks, and exact sums over a long piece take a while. assumed_between reads a span as positions do (snapped, the end point excluded), and a part off the map touches the measure at that end of it, whose tempo its stamps are extrapolated with. test_timemap_qstamps.py ports #621's test_quarter_stamps.py case for case, and adds the spec's scenarios. test_timemap_shared.py now compares every shared example's quarter stamps, at each beat and on each row. Part of TimeLineAnnotator/project-management#215 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/qstamps.py | 97 ++++ .../src/tilia_core/timemap/table.py | 14 +- .../src/tilia_core/timemap/timemap.py | 74 ++- .../tests/timemap/test_timemap_qstamps.py | 445 ++++++++++++++++++ .../tests/timemap/test_timemap_shared.py | 16 +- 5 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 packages/tilia-core/src/tilia_core/timemap/qstamps.py create mode 100644 packages/tilia-core/tests/timemap/test_timemap_qstamps.py diff --git a/packages/tilia-core/src/tilia_core/timemap/qstamps.py b/packages/tilia-core/src/tilia_core/timemap/qstamps.py new file mode 100644 index 000000000..c1885345d --- /dev/null +++ b/packages/tilia-core/src/tilia_core/timemap/qstamps.py @@ -0,0 +1,97 @@ +"""Quarter stamps: each beat's position in quarter notes, and the conversion +between times and quarter stamps. + +Moved from desktop#621's BeatTimeline (`beat_quarter_lengths`, +`_build_quarter_stamps`, `_build_time_stamp_table`, +`get_quarter_stamp_by_time` and `get_time_by_quarter_stamp`), with the same +rules, over a beat timeline's beat times and its measures' meters instead of +the app's components. Stamps are exact inside, and floats where they meet times. +""" + +from __future__ import annotations + +import itertools +from bisect import bisect +from collections.abc import Sequence +from dataclasses import dataclass +from fractions import Fraction + +from tilia_core.timemap.units import MeasureMeter + + +@dataclass(frozen=True) +class TimeStampTable: + """ + Per beat: its time, its quarter stamp and how many seconds a quarter note + lasts from it to the next beat, for converting between the two. Seconds + are the file's unit, which is quarter notes in a quarters file. + """ + + times: tuple[float, ...] + stamps: tuple[float, ...] + seconds_per_quarter: tuple[float, ...] + + +def beat_lengths(meter: MeasureMeter) -> list[Fraction]: + """How long each beat of a measure is, in quarter notes.""" + return [meter.quarter_length(value) for value in meter.beat_values] + + +def quarter_stamps(lengths: Sequence[Fraction]) -> list[Fraction]: + """ + Each beat's position in quarter notes, from 0 at the first beat (pickup + included): the sum of the lengths of the beats before it, as Verovio's and + the Measure Map's "qstamp". + """ + stamps = [Fraction(0)] + list(itertools.accumulate(lengths[:-1])) + return stamps[: len(lengths)] + + +def build_time_stamp_table( + times: Sequence[float], lengths: Sequence[Fraction] +) -> TimeStampTable | None: + """The table for beats at `times`, `lengths` quarter notes long; None with + fewer than two beats.""" + if len(times) < 2: + return None + seconds_per_quarter = [ + (times[index + 1] - times[index]) / float(lengths[index]) + for index in range(len(times) - 1) + ] + # The last beat has no next beat to measure against, so the tempo of + # the gap before it carries on past it. Its own length doesn't enter: + # that gap lasted the previous beat's length, not the last one's. + seconds_per_quarter.append(seconds_per_quarter[-1]) + return TimeStampTable( + times=tuple(times), + stamps=tuple(float(stamp) for stamp in quarter_stamps(lengths)), + seconds_per_quarter=tuple(seconds_per_quarter), + ) + + +def quarter_stamp_by_time(table: TimeStampTable | None, time: float) -> float | None: + """ + The quarter stamp at `time`, interpolated between beats and + extrapolated beyond them. None with fewer than two beats. + """ + if table is None: + return None + index = max(0, min(bisect(table.times, time) - 1, len(table.times) - 1)) + return ( + table.stamps[index] + + (time - table.times[index]) / table.seconds_per_quarter[index] + ) + + +def time_by_quarter_stamp(table: TimeStampTable | None, stamp: float) -> float | None: + """ + The time of quarter stamp `stamp`, interpolated between beats and + extrapolated beyond them. None with fewer than two beats. + """ + if table is None: + return None + index = max(0, min(bisect(table.stamps, stamp) - 1, len(table.stamps) - 1)) + return ( + table.times[index] + + (stamp - table.stamps[index]) * table.seconds_per_quarter[index] + ) diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index 8e5c91c83..ea5a762d2 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -1,5 +1,6 @@ """A beat timeline's measure table: each measure's position, pass, movement, -start and end, meter and time signature.""" +start and end, meter and time signature, and its start and length in quarter +notes.""" from __future__ import annotations @@ -78,6 +79,7 @@ class MeasureRow: beat_ids: tuple[str, ...] beats: int # the beats tapped meter: MeasureMeter # desktop#621's, from the beat units in effect + qstamp: Fraction # its downbeat's position in quarter notes, from the first beat # Computed from the meter, so nothing can make them disagree with it: time_signature: str = field(init=False) # "6/8", "2+2+3/8", "(4/3)/4" ts_numerator: Fraction = field(init=False) # the beat values' sum, however grouped @@ -85,6 +87,7 @@ class MeasureRow: assumed: bool = field(init=False) # nobody set the unit that governs it ambiguous: bool = field(init=False) # the taps don't hold whole patterns conflicting: bool = field(init=False) # more than one unit is set on its beats + quarters: Fraction = field(init=False) # how long it lasts in quarter notes source: str force_display: bool next: tuple[str, ...] | None @@ -92,15 +95,19 @@ class MeasureRow: def __post_init__(self) -> None: meter = self.meter + numerator = meter.numerator computed = { "time_signature": format_time_signature( meter.beat_values, meter.denominator ), - "ts_numerator": meter.numerator, + "ts_numerator": numerator, "ts_denominator": meter.denominator, "assumed": meter.is_assumed, "ambiguous": meter.is_ambiguous, "conflicting": meter.is_conflicting, + # The sum of its beats' lengths, as its time signature's numerator + # is the sum of their values. + "quarters": meter.quarter_length(numerator), } for name, value in computed.items(): object.__setattr__(self, name, value) @@ -242,6 +249,7 @@ def build_rows_from( ends.append(_closing_barline(document, rows)) passes: dict[int, int] = {} movement = 1 + qstamp = Fraction(0) out: list[MeasureRow] = [] meters = meters_of(rows) for index, (row, end, meter) in enumerate(zip(rows, ends, meters, strict=True)): @@ -268,12 +276,14 @@ def build_rows_from( beat_ids=row.beat_ids, beats=len(row.beat_ids), meter=meter, + qstamp=qstamp, source=row.source, force_display=row.force_display, next=row.next, metadata=FrozenMetadata(row.metadata), ) ) + qstamp += out[-1].quarters return out diff --git a/packages/tilia-core/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index 5e486d5c1..c075fd338 100644 --- a/packages/tilia-core/src/tilia_core/timemap/timemap.py +++ b/packages/tilia-core/src/tilia_core/timemap/timemap.py @@ -2,19 +2,29 @@ The map runs from the first beat to the closing barline, which it excludes. Every lookup is a bisection over arrays built once: the beat slots (the beats, -then the closing barline), each beat's measure, each measure's first beat, and -the downbeats. +then the closing barline), each beat's measure, each measure's first beat and +the downbeats, with the map; each beat's quarter stamp, the first time one is +asked for. """ from __future__ import annotations import bisect +import functools import itertools import math +from collections.abc import Sequence from dataclasses import dataclass, field from tilia_core import tla from tilia_core.timemap._rows import rows_of +from tilia_core.timemap.qstamps import ( + TimeStampTable, + beat_lengths, + build_time_stamp_table, + quarter_stamp_by_time, + time_by_quarter_stamp, +) from tilia_core.timemap.table import ( EPS, MeasureRow, @@ -61,7 +71,8 @@ class TimeMap: _times: tuple[float, ...] = field(repr=False) # the beats # Computed from the rows and the beats, so nothing can make them disagree: # the beat slots (the beats, then the closing barline), each beat's measure, - # each measure's first beat, and the downbeats. + # each measure's first beat, and the downbeats. Each beat's quarter stamp + # is computed from them too, when first asked for (`_stamps`). _slots: tuple[float, ...] = field(init=False, repr=False) _measure_of: tuple[int, ...] = field(init=False, repr=False) _first: tuple[int, ...] = field(init=False, repr=False) @@ -84,6 +95,15 @@ def __post_init__(self) -> None: for name, value in computed.items(): object.__setattr__(self, name, value) + @functools.cached_property + def _stamps(self) -> TimeStampTable | None: + """Each beat's quarter stamp, built the first time one is asked for: + the index, which reads every file's positions, never asks, and a long + piece's exact stamps take a while. Two threads asking at once build the + same table, and either is kept.""" + lengths = [length for row in self._rows for length in beat_lengths(row.meter)] + return build_time_stamp_table(self._times, lengths) + @property def start(self) -> float: """The first beat.""" @@ -138,6 +158,54 @@ def positions(self, start: float, end: float) -> Positions | None: movement=row.movement, ) + # Quarter notes: desktop#621's quarter stamps. Unlike positions, they + # aren't snapped, and they go on beyond the beats. + + def qstamp(self, t: float) -> float | None: + """`t`'s position in quarter notes, from 0 at the first beat: + interpolated between beats, and extrapolated beyond them with the tempo + of the nearest gap. At a beat, its exact stamp as a float. None for a + missing time.""" + if t is None or not math.isfinite(t): + return None + return quarter_stamp_by_time(self._stamps, t) + + def time_at_qstamp(self, q: float) -> float | None: + """The time at the quarter stamp `q`, read as `qstamp` reads them.""" + if q is None or not math.isfinite(q): + return None + return time_by_quarter_stamp(self._stamps, q) + + def quarters(self, start: float, end: float) -> float | None: + """How long a span lasts in quarter notes: the difference of its + qstamps. `assumed_between` says whether an assumed meter went into it.""" + a, b = self.qstamp(start), self.qstamp(end) + if a is None or b is None: + return None + return b - a + + def assumed_between(self, start: float, end: float) -> bool: + """Whether any measure the span from `start` to `end` touches is + assumed, so that a length in quarter notes can say "assumed meter".""" + return any(row.assumed for row in self._touched(start, end)) + + def _touched(self, start: float, end: float) -> Sequence[MeasureRow]: + """The measures a span touches, read as positions read it: snapped, the + end point excluded, and an end not after the start in the start's + measure. A part off the map touches the measure at that end of the + map, as its qstamps are extrapolated with the tempo there.""" + snapped_start, snapped_end = self._snapped(start), self._snapped(end) + if snapped_start is None or snapped_end is None: + return () + downbeats, last = self._downbeats, len(self._rows) - 1 + first = bisect.bisect_right(downbeats, snapped_start) - 1 + first = min(max(first, 0), last) + # The end point excluded: an end on a downbeat ends in the bar before, + # but never before the start's. + end_measure = bisect.bisect_left(downbeats, snapped_end) - 1 + end_measure = min(max(end_measure, first), last) + return self._rows[first : end_measure + 1] + def _snapped(self, t: float | None) -> float | None: """`t` moved onto the nearer beat slot, or the closing barline, when it lies within the tolerance of one. None for a missing time.""" diff --git a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py new file mode 100644 index 000000000..a24214e1d --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py @@ -0,0 +1,445 @@ +"""Quarter stamps: each beat's position in quarter notes, and the conversion +between times and quarter stamps. + +The first part ports desktop#621's tests/timelines/beat/test_quarter_stamps.py +(at 0ee8ea45) case for case: each class and test keeps its name, and builds the +beat timeline #621's `beat_tl` fixture builds, as a document. The second part +is the spec's User Story 3, scenarios 1-3 and 5. +""" + +from __future__ import annotations + +import dataclasses +import itertools +import json +from fractions import Fraction as F + +import pytest + +from docs import beat_timeline, document +from tilia_core import tla +from tilia_core.timemap import TimeMap, qstamps, time_map_for +from tilia_core.timemap import timemap as timemap_module +from tilia_core.timemap.table import build_rows + +# What TiLiA puts on the first beat, which #621's tests edit. +DEFAULT = {"denominator": 4, "units": "1", "assumed": True} + + +def _timeline(times, pattern=(4,), units=None) -> tla.Timeline: + """#621's `beat_tl` after `add_beats(times)` and `add_beat_unit`: a beat at + each time, in measures of `pattern`'s counts in turn, TiLiA's default unit + on the first beat, and `units` ({beat index: (denominator, units)}) set on + the beats, the first beat's replacing the default.""" + downbeats = set( + itertools.takewhile( + lambda i: i < len(times), + itertools.accumulate(itertools.cycle(pattern), initial=0), + ) + ) + marks = {i: {} for i in downbeats} | {0: {"number": 1}} + set_units = {0: DEFAULT} + for index, (denominator, text) in (units or {}).items(): + set_units[index] = {"denominator": denominator, "units": text} + return beat_timeline( + [ + {"time": float(t), "measure": marks.get(i), "beat_unit": set_units.get(i)} + for i, t in enumerate(times) + ] + ) + + +def _map(timeline: tla.Timeline, **kwargs) -> TimeMap: + time_map = time_map_for(document(timeline, **kwargs), timeline.id) + assert time_map is not None + return time_map + + +def _beat_times(timeline: tla.Timeline) -> list[float]: + return [ + timeline.components[beat].attrs["time"] + for row in timeline.measures.rows + for beat in row.beats + ] + + +def _stamps(timeline: tla.Timeline) -> list[float]: + """The quarter stamp of every beat, as the timeline's time map gives it.""" + time_map = _map(timeline) + return [time_map.qstamp(t) for t in _beat_times(timeline)] + + +def _floats(stamps) -> list[float]: + return [float(F(stamp)) for stamp in stamps] + + +def _set_time(timeline: tla.Timeline, index: int, time: float) -> None: + beat_id = [b for row in timeline.measures.rows for b in row.beats][index] + timeline.components[beat_id].attrs["time"] = time + + +# --- desktop#621's tests ---------------------------------------------------- + + +class TestBeatStamps: + def test_default_is_one_quarter_per_beat(self): + assert _stamps(_timeline(range(6))) == _floats([0, 1, 2, 3, 4, 5]) + + def test_dotted_quarter_beats(self): + timeline = _timeline(range(4), [2], {0: (8, "3")}) + + assert _stamps(timeline) == _floats([0, F(3, 2), 3, F(9, 2)]) + + def test_changing_beat_unit(self): + timeline = _timeline(range(6), [2], {2: (2, "1")}) + + assert _stamps(timeline) == _floats([0, 1, 2, 4, 6, 8]) + + def test_uneven_units(self): + timeline = _timeline(range(4), [2], {0: (8, "2+3")}) + + assert _stamps(timeline) == _floats([0, 1, F(5, 2), F(7, 2)]) + + def test_triplets_are_exact(self): + timeline = _timeline(range(7), [3], {0: (4, "1/3")}) + + assert _stamps(timeline)[6] == 2 + assert _stamps(timeline)[1] == float(F(1, 3)) + # Exact inside: the measure after two of triplets starts at 2, not at + # the float sum of six thirds. + rows = _map(timeline).measure_rows() + assert [row.qstamp for row in rows] == [0, 1, 2] + assert type(rows[2].qstamp) is F + + def test_pickup_starts_at_zero(self): + timeline = _timeline(range(3), [1, 2], {0: (4, "3+2")}) + + # One pickup tap, read from the start of 3+2, then a full bar. + assert _stamps(timeline) == _floats([0, 3, 6]) + + def test_stamps_follow_inserted_beats(self): + timeline = _timeline([0, 2, 3]) + assert _stamps(timeline) == _floats([0, 1, 2]) + + beat = tla.Component(id=tla.new_id(), kind="beat", attrs={"time": 1.0}) + timeline.components[beat.id] = beat + timeline.measures.rows[0].beats.insert(1, beat.id) + + # A time map is built from the document as it is (FR-004). + assert _stamps(timeline) == _floats([0, 1, 2, 3]) + + def test_of_beat(self): + timeline = _timeline(range(3)) + + assert _map(timeline).qstamp(_beat_times(timeline)[2]) == 2 + + +class TestTimeToStamp: + @pytest.fixture + def tl(self): + # Beats every 2 s, worth a dotted quarter each. + return _timeline([10, 12, 14, 16], [2], {0: (8, "3")}) + + @pytest.mark.parametrize( + "time, stamp", + [(10, 0), (11, 0.75), (12, 1.5), (15, 3.75), (16, 4.5), (18, 6), (8, -1.5)], + ) + def test_time_to_stamp(self, tl, time, stamp): + assert _map(tl).qstamp(time) == pytest.approx(stamp) + + @pytest.mark.parametrize( + "stamp, time", + [(0, 10), (0.75, 11), (1.5, 12), (3.75, 15), (4.5, 16), (6, 18), (-1.5, 8)], + ) + def test_stamp_to_time(self, tl, stamp, time): + assert _map(tl).time_at_qstamp(stamp) == pytest.approx(time) + + def test_round_trip(self, tl): + tm = _map(tl) + for time in [10.3, 12.7, 13.1, 16.9]: + stamp = tm.qstamp(time) + assert tm.time_at_qstamp(stamp) == pytest.approx(time) + + def test_uneven_beat_lengths(self): + tm = _map(_timeline([0, 1, 2], [2], {0: (4, "2+3")})) + + # Beat 0 lasts 2 quarters over 1 s; beat 1 lasts 3 quarters over 1 s. + assert tm.qstamp(0.5) == pytest.approx(1) + assert tm.qstamp(1.5) == pytest.approx(3.5) + + def test_past_last_beat_keeps_last_gap_tempo(self): + timeline = _timeline([0, 1, 2], [2], {0: (4, "2+3")}) + tm = _map(timeline) + + # The last gap lasts 3 quarters over 1 s. The last beat is worth 2 + # quarters, which must not change that tempo. + assert _stamps(timeline) == _floats([0, 2, 5]) + assert tm.qstamp(2.5) == pytest.approx(6.5) + assert tm.time_at_qstamp(6.5) == pytest.approx(2.5) + + @pytest.mark.parametrize("times", [[], [5]]) + def test_needs_two_beats(self, times): + # #621 gives None for either conversion. In the core such a timeline + # has no time map at all (FR-003), and the port's functions give None. + timeline = _timeline(times) + assert time_map_for(document(timeline), timeline.id) is None + + table = qstamps.build_time_stamp_table( + [float(t) for t in times], [F(1)] * len(times) + ) + assert table is None + assert qstamps.quarter_stamp_by_time(table, 5) is None + assert qstamps.time_by_quarter_stamp(table, 0) is None + + def test_follows_dragged_beat(self, tl): + _set_time(tl, 1, 13.0) + + assert _map(tl).qstamp(13) == pytest.approx(1.5) + + +class TestCaching: + def test_conversions_reuse_one_table(self, monkeypatch): + calls = [] + + def build(*args): + calls.append(args) + return qstamps.build_time_stamp_table(*args) + + monkeypatch.setattr(timemap_module, "build_time_stamp_table", build) + tm = _map(_timeline(range(100))) + # Built the first time a qstamp is asked for, not with the map. + assert calls == [] + for i in range(500): + stamp = tm.qstamp(i * 0.2) + tm.time_at_qstamp(stamp) + + assert len(calls) == 1 + + def test_table_is_rebuilt_after_a_change(self): + timeline = _timeline(range(4)) + before = _map(timeline) + assert before.qstamp(3) == pytest.approx(3) + + _set_time(timeline, 3, 4.0) + + # A map built after the change has it; one built before doesn't + # follow it (FR-004). + assert _map(timeline).qstamp(4) == pytest.approx(3) + assert before.qstamp(3) == pytest.approx(3) + + +# --- the spec's User Story 3 ------------------------------------------------ + + +def _tapped_example(*, six_eight: bool = True) -> tla.Timeline: + """The layout's tapped recording, a beat every second from 0 s: a one-beat + pickup, then bars 1, 2a, 1 and 2b in 3/4, and bar 3, a 6/8 tapped in two.""" + marks = { + 0: {"number": 0}, + 1: {}, + 4: {"label": "2a"}, + 7: {"number": 1}, + 10: {"label": "2b"}, + 13: {}, + } + beats = [{"time": float(t), "measure": marks.get(t)} for t in range(15)] + beats[0]["beat_unit"] = DEFAULT + if six_eight: + beats[13]["beat_unit"] = {"denominator": 8, "units": "3"} + return beat_timeline(beats) + + +def test_qstamps_of_the_tapped_example(): + # Spec, US3 scenario 1. + tm = _map(_tapped_example()) + rows = tm.measure_rows() + + assert tm.qstamp(0.0) == 0 + assert [(row.label, row.qstamp) for row in rows] == [ + ("0", 0), + ("1", 1), + ("2a", 4), + ("1", 7), + ("2b", 10), + ("3", 13), + ] + assert [row.quarters for row in rows] == [1, 3, 3, 3, 3, 3] + assert tm.qstamp(14.0) - tm.qstamp(13.0) == 1.5 + # The rows' stamps are the map's, at their downbeats, and exact. + assert [tm.qstamp(row.start) for row in rows] == [float(r.qstamp) for r in rows] + assert {type(row.qstamp) for row in rows} == {F} + assert {type(row.quarters) for row in rows} == {F} + + +def test_setting_a_beat_unit_changes_every_qstamp_after_it(): + # Spec, US3 scenario 2: the 6/8 bar without a unit of its own reads 2/4, + # assumed. Setting {8, "3"} on its downbeat changes every qstamp after it + # at once, and the file only by that beat unit. + timeline = _tapped_example(six_eight=False) + doc = document(timeline) + written = tla.canonical_bytes(doc) + before = time_map_for(doc, timeline.id) + assert before.measure_rows()[-1].time_signature == "2/4" + assert before.measure_rows()[-1].assumed + + six_eight = {"denominator": 8, "units": "3"} + downbeat = timeline.measures.rows[-1].id + timeline.components[downbeat].attrs["beat_unit"] = six_eight + after = time_map_for(doc, timeline.id) + + times = [0.0, 5.5, 12.0, 13.0, 13.5, 14.0, 14.5, 15.5] + assert [before.qstamp(t) for t in times] == [0, 5.5, 12, 13, 13.5, 14, 14.5, 15.5] + assert [after.qstamp(t) for t in times] == pytest.approx( + [0, 5.5, 12, 13, 13.75, 14.5, 15.25, 16.75] + ) + assert after.measure_rows()[-1].time_signature == "6/8" + assert after.measure_rows()[-1].quarters == 3 + # Nothing computed reaches the file: it changed by the beat unit alone. + rewritten = tla.canonical_bytes(doc) + assert rewritten != written + unchanged = _without(json.loads(rewritten), "beat_unit", six_eight) + assert unchanged == json.loads(written) + + +def _without(value, key, unwanted): + """`value` with every `key` whose value holds `unwanted`'s items taken out.""" + if isinstance(value, list): + return [_without(item, key, unwanted) for item in value] + if not isinstance(value, dict): + return value + return { + k: _without(v, key, unwanted) + for k, v in value.items() + if not (k == key and isinstance(v, dict) and unwanted.items() <= v.items()) + } + + +def test_qstamps_between_and_beyond_the_beats(): + # Spec, US3 scenario 3, on the tapped example: between two beats a qstamp + # is interpolated; beyond the last beat it is extrapolated with the last + # gap's tempo, and before the first with the first gap's; a qstamp converts + # back to the same time. + tm = _map(_tapped_example()) + + assert tm.qstamp(2.25) == 2.25 # quarter notes, a beat every second + assert tm.qstamp(13.5) == 13.75 # dotted quarters + # The last gap (13 to 14 s) lasts 1.5 quarters: so does every second after + # 14 s, past the closing barline (15 s) too. + assert tm.qstamp(15.0) == 16.0 and tm.qstamp(20.0) == 23.5 + # Before the first beat: the first gap's tempo, a quarter a second. Bar 0 + # has no position there (the prototype), but a qstamp (#621). + assert tm.qstamp(-2.0) == -2.0 + assert tm.positions(-2.0, -2.0) is None + for t in (-2.0, 0.0, 2.25, 6.6, 13.5, 14.0, 15.0, 20.0): + assert tm.time_at_qstamp(tm.qstamp(t)) == pytest.approx(t) + + +def test_a_missing_time_has_no_qstamp(): + tm = _map(_tapped_example()) + + for t in (None, float("nan"), float("inf")): + assert tm.qstamp(t) is None + assert tm.time_at_qstamp(t) is None + assert tm.quarters(t, 3.0) is None and tm.quarters(3.0, t) is None + assert not tm.assumed_between(t, 3.0) + + +def test_a_span_in_quarter_notes(): + # Spec, US3 scenario 5: a span's length in quarter notes is the difference + # of its qstamps. + tm = _map(_tapped_example()) + + assert tm.quarters(1.0, 4.0) == 3.0 # bar 1 + assert tm.quarters(13.0, 15.0) == 3.0 # the 6/8 bar + assert tm.quarters(12.5, 13.5) == 0.5 + 0.75 + assert tm.quarters(6.6, 6.6) == 0.0 + for start, end in ((0.0, 15.0), (2.3, 14.2), (-1.0, 20.0), (14.2, 2.3)): + assert tm.quarters(start, end) == tm.qstamp(end) - tm.qstamp(start) + + +def _assumed_map() -> TimeMap: + """Bars 1-4 of two taps each, a beat every second from 0 s: bars 1 and 2 + under a unit someone set, bar 3 under an assumed one, bar 4 set again.""" + beats = [ + {"time": float(t), "measure": {} if t % 2 == 0 else None} for t in range(8) + ] + beats[0]["measure"] = {"number": 1} + beats[0]["beat_unit"] = {"denominator": 4, "units": "1"} + beats[4]["beat_unit"] = {"denominator": 4, "units": "1", "assumed": True} + beats[6]["beat_unit"] = {"denominator": 8, "units": "3"} + return _map(beat_timeline(beats)) + + +def test_whether_a_span_touches_an_assumed_measure(): + # Spec, US3 scenario 5 (FR-043): whether any measure the span touches is + # assumed. Touched as positions read a span: snapped, the end point + # excluded, and a point, or an end not after the start, in its start's bar. + tm = _assumed_map() + assert [row.assumed for row in tm.measure_rows()] == [False, False, True, False] + + assert not tm.assumed_between(0.0, 4.0) # bars 1 and 2, ending on bar 3 + assert not tm.assumed_between(0.5, 4.08) # an end that snaps onto it + assert tm.assumed_between(0.5, 4.2) # into bar 3 + assert tm.assumed_between(3.5, 6.5) + assert tm.assumed_between(5.0, 5.0) # a point in bar 3 + assert tm.assumed_between(4.0, 4.0) # a point on its downbeat + assert tm.assumed_between(3.95, 3.95) # snapped onto it + assert not tm.assumed_between(6.0, 7.5) # bar 4 + assert tm.assumed_between(5.5, 2.0) # an end before the start: bar 3's + assert not tm.assumed_between(2.5, 1.5) + + +def test_parts_of_a_span_off_the_map_touch_the_measure_at_that_end(): + # Qstamps off the map are extrapolated with the tempo of the gap at that + # end (#621), so a span there touches the first or the last measure. + tm = _assumed_map() + + assert not tm.assumed_between(-5.0, -1.0) # bar 1's + assert not tm.assumed_between(7.5, 20.0) # bar 4's + assert tm.assumed_between(-5.0, 20.0) # every bar + # The tapped example's last bar, without its unit, reads 2/4, assumed. + assert _map(_tapped_example(six_eight=False)).assumed_between(16.0, 18.0) + assert not _map(_tapped_example()).assumed_between(16.0, 18.0) + + +@pytest.mark.parametrize( + "beat_unit, taps, quarters", + [ + ((8, "3"), 2, 3), + ((8, "2+3"), 3, F(7, 2)), # 2+3+2 eighths, ambiguous + ((4, "1/3"), 4, F(4, 3)), + ((8, "1/2+1"), 3, 1), # 1/2+1+1/2 eighths + ((2, "1"), 5, 10), + ], +) +def test_a_measure_lasts_the_sum_of_its_beats(beat_unit, taps, quarters): + # FR-050: a measure's length in quarter notes is the sum of its beats', + # and the next measure starts there. + timeline = _timeline(range(taps + 2), [taps, 2], {0: beat_unit}) + first, second = build_rows(document(timeline), timeline) + + assert first.quarters == quarters == sum(qstamps.beat_lengths(first.meter)) + assert second.qstamp == quarters + + +def test_rows_give_their_qstamps_as_text_in_json(): + timeline = _timeline(range(4), [2]) + first, second = build_rows(document(timeline), timeline) + + assert (first.qstamp, first.quarters) == (0, 2) + as_json = json.loads(json.dumps(dataclasses.asdict(second), default=str)) + assert (as_json["qstamp"], as_json["quarters"]) == ("2", "2") + + +def test_qstamps_are_computed_for_a_folded_table(): + # FR-013: a folded table has no time map, but its rows have their qstamps, + # in the timeline's order. + beats = [ + {"time": float(t), "measure": {} if t % 2 == 0 else None} for t in range(6) + ] + beats[0]["measure"] = {"number": 1, "next": ["a later measure"]} + timeline = beat_timeline(beats) + doc = document(timeline) + + assert time_map_for(doc, timeline.id) is None + assert [row.qstamp for row in build_rows(doc, timeline)] == [0, 2, 4] diff --git a/packages/tilia-core/tests/timemap/test_timemap_shared.py b/packages/tilia-core/tests/timemap/test_timemap_shared.py index 32ed1257f..525728c22 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_shared.py +++ b/packages/tilia-core/tests/timemap/test_timemap_shared.py @@ -85,16 +85,20 @@ def _meter(row): } -@pytest.mark.skip( - reason="the time map's qstamps come with " - "TimeLineAnnotator/project-management#215 (quarter stamps and distances), " - "which removes this skip" -) @pytest.mark.parametrize("case", METERS) def test_qstamps(case): + # At a beat, the time map's qstamp is the exact stamp as a float, as #621's + # get_quarter_stamp_by_time gives it; the rows hold the downbeats' exactly. timeline = beat_timeline(case["beats"]) time_map = time_map_for(document(timeline), timeline.id) + expected = [Fraction(qstamp) for qstamp in case["expected"]["qstamps"]] assert [time_map.qstamp(beat["time"]) for beat in case["beats"]] == [ - float(Fraction(qstamp)) for qstamp in case["expected"]["qstamps"] + float(qstamp) for qstamp in expected + ] + downbeats = [ + k for k, beat in enumerate(case["beats"]) if beat.get("measure") is not None + ] + assert [row.qstamp for row in time_map.measure_rows()] == [ + expected[k] for k in downbeats ] From b851378812348bab9b0f0edcfc05367a74fd7cd3 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:22:52 +0200 Subject: [PATCH 2/6] feat(core): measure distances and lengths in bars and beats TimeMap gains position(t, unit), length(start, end, unit) and unit_seconds(t, unit), with unit "bar" or "beat": the prototype's position, length, beat_seconds and bar_seconds (ql_timemap.py). A position runs from 0 at the first beat, a bar counts 1 whatever its metre and the fraction inside it counts beats, and the closing barline ends the last bar. A length is the difference of two positions, rounded to a millionth, and None when either end is off the map; an end on the closing barline is on it, a start isn't. unit_seconds is in the file's unit. Both ends are snapped first, as for positions. A unit other than "bar" or "beat" gives None and logs an error, rather than counting beats as the prototype does: no file can give one, only a caller. The prototype's distance assertions in test_timemap_prototype.py now pass, unchanged, so their strict xfail mark is gone. test_timemap_distances.py adds the spec's scenarios: eight bars at any tempo, a 3/4 bar inside 4/4, how long a beat and a bar last, the media's end, and a quarters file. Part of TimeLineAnnotator/project-management#215 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/timemap.py | 71 ++++++++ .../tests/timemap/test_timemap_distances.py | 159 ++++++++++++++++++ .../tests/timemap/test_timemap_prototype.py | 23 +-- 3 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 packages/tilia-core/tests/timemap/test_timemap_distances.py diff --git a/packages/tilia-core/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index c075fd338..b0b686e13 100644 --- a/packages/tilia-core/src/tilia_core/timemap/timemap.py +++ b/packages/tilia-core/src/tilia_core/timemap/timemap.py @@ -12,6 +12,7 @@ import bisect import functools import itertools +import logging import math from collections.abc import Sequence from dataclasses import dataclass, field @@ -33,6 +34,8 @@ table_problem, ) +logger = logging.getLogger(__name__) + TOLERANCE = 0.1 # in the file's unit: a time this close to a beat is on it FOLDED = ( @@ -40,6 +43,8 @@ "which positions don't handle yet" ) +UNITS = ("bar", "beat") # what distances and lengths are measured in + @dataclass(frozen=True) class Positions: @@ -158,6 +163,64 @@ def positions(self, start: float, end: float) -> Positions | None: movement=row.movement, ) + # Distances and lengths in bars and beats: the prototype's position, + # length, beat_seconds and bar_seconds. + + def position(self, t: float, unit: str) -> float | None: + """Where `t` lies along the map in bars or beats (`unit` "bar" or + "beat"), from 0 at the first beat, in the timeline's order. + + A bar counts 1 whatever its metre, and inside a bar the fraction counts + beats. The closing barline is the end of the last bar. None elsewhere + off the map. + """ + if not _is_unit(unit): + return None + return self._position(self._snapped(t), unit, closing=True) + + def length(self, start: float, end: float, unit: str) -> float | None: + """How long a span lasts in bars or beats (`unit` "bar" or "beat"): the + distance between its two positions, rounded to a millionth, so that 8 + bars compares equal to 8. None when either end is off the map; an end + on the closing barline is on it, a start isn't.""" + if not _is_unit(unit): + return None + a = self._position(self._snapped(start), unit, closing=False) + b = self._position(self._snapped(end), unit, closing=True) + if a is None or b is None: + return None + return round(b - a, 6) + + def unit_seconds(self, t: float, unit: str) -> float | None: + """How long the beat or the bar holding `t` lasts (`unit` "beat" or + "bar"), in the file's unit despite the name. None off the map.""" + if not _is_unit(unit): + return None + located = self._locate(self._snapped(t)) + if located is None: + return None + slot = located[0] + if unit == "beat": + return self._slots[slot + 1] - self._slots[slot] + row = self._rows[self._measure_of[slot]] + return row.end - row.start + + def _position( + self, snapped: float | None, unit: str, *, closing: bool + ) -> float | None: + # The prototype's measure_pos or beat_pos; with `closing`, its position. + located = self._locate(snapped) + if located is None: + if closing and snapped is not None and abs(snapped - self.end) <= EPS: + return float(len(self._rows) if unit == "bar" else len(self._times)) + return None + slot, fraction = located + if unit == "beat": + return slot + fraction + measure = self._measure_of[slot] + beats = self._rows[measure].beats + return measure + (slot - self._first[measure] + fraction) / beats + # Quarter notes: desktop#621's quarter stamps. Unlike positions, they # aren't snapped, and they go on beyond the beats. @@ -245,6 +308,14 @@ def _is_downbeat(self, t: float) -> bool: return nearest <= self.tolerance + EPS +def _is_unit(unit: str) -> bool: + if unit in UNITS: + return True + # Not something a file can give: a caller's mistake, logged, not raised. + logger.error("A time map measures in 'bar' or 'beat', not %r.", unit) + return False + + def build_time_map( document: tla.Document, timeline: tla.Timeline, *, guessed: bool = False ) -> tuple[TimeMap | None, str | None]: diff --git a/packages/tilia-core/tests/timemap/test_timemap_distances.py b/packages/tilia-core/tests/timemap/test_timemap_distances.py new file mode 100644 index 000000000..bce94f1c4 --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_distances.py @@ -0,0 +1,159 @@ +"""Distances and lengths in bars and beats: where a time lies along the map, how +long a span lasts, and how long the beat and the bar holding a time last +(FR-040-FR-042). The prototype's own assertions are in +test_timemap_prototype.py; these are the spec's User Story 3, scenarios 4 and 6. +""" + +from __future__ import annotations + +import itertools +import logging +import math + +import pytest + +from docs import beat_timeline, document +from tilia_core import tla +from tilia_core.timemap import TimeMap, time_map_for + + +def _timeline(times: list[float], counts: list[int]) -> tla.Timeline: + """A beat at each time, in measures of `counts` beats, numbered from 1.""" + marks = {k: {} for k in itertools.accumulate(counts[:-1], initial=0)} + marks[0] = {"number": 1} + return beat_timeline( + [{"time": t, "measure": marks.get(k)} for k, t in enumerate(times)] + ) + + +def _map(times: list[float], counts: list[int], **kwargs) -> TimeMap: + timeline = _timeline(times, counts) + time_map = time_map_for(document(timeline, **kwargs), timeline.id) + assert time_map is not None + return time_map + + +def _accelerating(bars: int, beats: int = 4) -> list[float]: + """A beat at each time, every gap 3 % shorter than the one before.""" + gaps = [0.97**k for k in range(bars * beats - 1)] + return list(itertools.accumulate(gaps, initial=0.0)) + + +def test_eight_bars_last_eight_bars_whatever_the_tempo(): + # Spec, US3 scenario 4: from the downbeat of bar 1 to the downbeat of bar 9. + steady = _map([float(t) for t in range(40)], [4] * 10) + faster = _map(_accelerating(10), [4] * 10) + + for tm in (steady, faster): + bar_one, bar_nine = tm.measures()[0][0], tm.measures()[8][0] + assert tm.length(bar_one, bar_nine, "bar") == 8.0 + assert tm.length(bar_one, bar_nine, "beat") == 32.0 + assert tm.position(bar_nine, "bar") == 8.0 + # Halfway through a bar in beats is halfway in bars, whatever the seconds. + third_beat = _accelerating(10)[4 * 4 + 2] # of bar 5 + assert faster.position(third_beat, "bar") == 4.5 + + +# A 3/4 bar inside 4/4, a beat every second: bar 1 from 0 s, bar 2 (3/4) from +# 4 s, bar 3 from 7 s, the closing barline at 11 s. +THREE_FOUR_INSIDE = ([float(t) for t in range(11)], [4, 3, 4]) + + +def test_a_bar_counts_one_whatever_its_metre(): + # Spec, US3 scenario 4: across a 3/4 bar inside 4/4, a bar counts 1, and + # inside a bar the fraction counts beats. + tm = _map(*THREE_FOUR_INSIDE) + + assert [tm.position(t, "bar") for t in (0.0, 4.0, 7.0)] == [0.0, 1.0, 2.0] + assert tm.position(5.5, "bar") == 1.5 # halfway through the 3/4 bar + assert tm.position(2.0, "bar") == 0.5 # halfway through bar 1 + assert tm.length(4.0, 7.0, "bar") == 1.0 and tm.length(4.0, 7.0, "beat") == 3.0 + # From beat 3 of bar 1 to beat 3 of bar 3: two bars, seven beats. + assert tm.length(2.0, 9.0, "bar") == 2.0 and tm.length(2.0, 9.0, "beat") == 7.0 + # Two beats of bar 1 and one and a half of bar 2: half of each. + assert tm.length(2.0, 5.5, "bar") == 1.0 and tm.length(2.0, 5.5, "beat") == 3.5 + # The whole map: three bars of eleven beats. + assert tm.length(0.0, 11.0, "bar") == 3.0 and tm.length(0.0, 11.0, "beat") == 11.0 + + +def test_lengths_are_rounded_to_a_millionth(): + # 1.3 s is 0.3 of a beat-gap of 0.5 s after 1.0 s, which floats give as + # 0.6000000000000001 beats. + tm = _map([1.0 + 0.5 * i for i in range(8)], [4, 4]) + + assert (1.3 - 1.0) / 0.5 != 0.6 + assert tm.length(1.0, 1.3, "beat") == 0.6 + assert tm.length(1.0, 1.3, "bar") == 0.15 + + +def test_lengths_and_positions_off_the_map(): + tm = _map(*THREE_FOUR_INSIDE) + + # Before the first beat, beyond the tolerance, or after the closing barline. + assert tm.position(-0.5, "bar") is None and tm.position(11.5, "beat") is None + assert tm.length(-0.5, 4.0, "bar") is None + assert tm.length(4.0, 11.5, "bar") is None + # The closing barline ends the last bar, for an end but not for a start. + assert tm.position(11.0, "bar") == 3.0 and tm.position(11.05, "beat") == 11.0 + assert tm.length(11.0, 11.0, "bar") is None + # Within the tolerance before the first beat: on it. + assert tm.position(-0.05, "bar") == 0.0 and tm.length(-0.05, 4.0, "bar") == 1.0 + for t in (None, math.nan, math.inf): + assert tm.position(t, "bar") is None + assert tm.length(t, 4.0, "bar") is None and tm.length(0.0, t, "beat") is None + assert tm.unit_seconds(t, "beat") is None + + +def test_how_long_a_beat_and_a_bar_last(): + # Spec, US3 scenario 6: how long the beat and the bar holding a time last, + # in the file's unit (for WITHIN 1 beat). Bar 1's beats are uneven. + times = [0.0, 0.8, 1.8, 2.4, 3.0, 3.5, 4.0, 4.5] + tm = _map(times, [4, 4]) + + assert tm.unit_seconds(0.0, "beat") == 0.8 + assert tm.unit_seconds(2.0, "beat") == pytest.approx(0.6) + assert tm.unit_seconds(2.0, "bar") == 3.0 + assert tm.unit_seconds(3.2, "bar") == 2.0 # bar 2 ends at the closing barline + # Snapped first: 2.95 is on bar 2's downbeat. + assert tm.unit_seconds(2.95, "beat") == 0.5 and tm.unit_seconds(2.95, "bar") == 2.0 + # The last beat lasts as long as the gap before it (FR-011). + assert tm.unit_seconds(4.7, "beat") == 0.5 + # Off the map: nothing. + assert tm.unit_seconds(-1.0, "bar") is None and tm.unit_seconds(5.0, "beat") is None + + +def test_the_last_beat_ends_with_the_media(): + # FR-011: the last beat lasts until the end of the media, if that comes + # before the gap ends. + tm = _map([0.0, 1.0, 2.0, 3.0], [2, 2], media_length=3.4) + + assert tm.end == 3.4 + assert tm.unit_seconds(3.2, "beat") == pytest.approx(0.4) + assert tm.unit_seconds(3.2, "bar") == pytest.approx(1.4) + assert tm.position(3.2, "beat") == pytest.approx(3.5) + assert tm.position(3.4, "bar") == 2.0 and tm.length(3.4, 3.4, "bar") is None + + +def test_in_a_quarters_file_the_unit_is_quarter_notes(): + # unit_seconds is in the file's unit, despite its name: nothing converts. + times = [0.0, 1.5, 3.0, 4.5, 6.0] + tm = _map(times, [2, 3], time_unit="quarters", media_length=5.0) + + assert tm.unit_seconds(0.5, "beat") == 1.5 and tm.unit_seconds(0.5, "bar") == 3.0 + # The media's length is in seconds: it doesn't end a quarters file's map. + assert tm.end == 7.5 and tm.unit_seconds(6.2, "bar") == 4.5 + + +@pytest.mark.parametrize("unit", ["bars", "measure", "", None]) +def test_a_unit_other_than_bar_or_beat_gives_nothing(caplog, unit): + # Not something a corpus file can give: a caller's mistake, logged as an + # error rather than raised (desktop's CLAUDE.md), and never read as beats. + tm = _map(*THREE_FOUR_INSIDE) + + with caplog.at_level(logging.ERROR, logger="tilia_core.timemap.timemap"): + assert tm.position(5.5, unit) is None + assert tm.length(0.0, 5.5, unit) is None + assert tm.unit_seconds(5.5, unit) is None + + assert len(caplog.records) == 3 + assert all(repr(unit) in record.getMessage() for record in caplog.records) diff --git a/packages/tilia-core/tests/timemap/test_timemap_prototype.py b/packages/tilia-core/tests/timemap/test_timemap_prototype.py index 26d516d96..96809cdf4 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_prototype.py +++ b/packages/tilia-core/tests/timemap/test_timemap_prototype.py @@ -38,16 +38,6 @@ from tilia_core.timemap import TimeMap, find_time_map, time_map_for from tilia_core.timemap import timemap as timemap_module -# The prototype's TimeMap.position, length, beat_seconds and bar_seconds are the -# core's position, length and unit_seconds, which aren't written yet. The mark is -# strict: once the names exist, these tests fail until it is removed. -needs_distances = pytest.mark.xfail( - raises=AttributeError, - strict=True, - reason="TimeMap.position, length and unit_seconds come with " - "TimeLineAnnotator/project-management#215", -) - T44 = [1.0 + 0.5 * i for i in range(12)] # 4/4, bars 1-3: downbeats 1.0, 3.0, 5.0 @@ -113,8 +103,10 @@ def measures(time_map: TimeMap) -> list[tuple[float, float, int, int]]: return [(r.start, r.end, r.number, r.beats) for r in time_map.measure_rows()] -# Distances. The prototype's beat_pos and measure_pos are position(t, unit) -# everywhere but on the closing barline, where no assertion here looks. +# Distances. The prototype's position, length, beat_seconds and bar_seconds +# are the core's position, length and unit_seconds. Its beat_pos and +# measure_pos are position(t, unit) everywhere but on the closing barline, +# where no assertion here looks. def beat_pos(time_map: TimeMap, t: float) -> float | None: @@ -180,7 +172,6 @@ def test_four_four(): assert end_bar(tm, 7.2) is None -@needs_distances def test_four_four_distances(): tm = _map(T44, [4, 4, 4], [1, 2, 3]) @@ -251,7 +242,6 @@ def test_three_four_bar_inside_four_four(): assert measure_beats(tm, -1.0) is None and measure_beats(tm, 11.0) is None -@needs_distances def test_three_four_bar_inside_four_four_distances(): tm = _map(*THREE_FOUR_INSIDE) @@ -279,7 +269,6 @@ def test_pickup(): assert is_downbeat(tm, 0.0) # TiLiA's view: the pickup starts measure 0 -@needs_distances def test_pickup_distances(): tm = _map(*PICKUP) @@ -301,7 +290,6 @@ def test_repeated_number(): assert end_bar(tm, 4.0) == 2 and bar(tm, 9.5) == 3 and tm.end == 10.0 -@needs_distances def test_repeated_number_distances(): tm = _map(*REPEAT) @@ -320,7 +308,6 @@ def test_jump_in_the_numbering(): assert end_bar(tm, 4.0) == 42 -@needs_distances def test_jump_in_the_numbering_distances(): tm = _map(*JUMP) @@ -352,7 +339,6 @@ def test_after_the_last_beat(): assert end_bar(tm, 6.0) == 2 and end_bar(tm, 8.5) is None -@needs_distances def test_after_the_last_beat_distances(): tm = _map(*SHORT_LAST) @@ -393,7 +379,6 @@ def test_snapping(): assert beat(tm, 0.07) == 2.0 and beat(tm, 0.05) == 1.0 -@needs_distances def test_snapping_distances(): tm = _map(T44, [4, 4, 4], [1, 2, 3]) From 14e8b2f3a94c92f4c93226468dfb09c13ebfe8df Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:48:40 +0200 Subject: [PATCH 3/6] fix(core): flag the assumed meters a length in quarter notes counts assumed_between picked its measures as positions read a span: snapped, the end point excluded, an end before the start in the start's bar, and past the last beat the last bar. quarters() reads none of these ways, so a length could count an assumed meter that the flag missed: - past the last beat, quarters() extrapolates with the last gap's tempo, which is the bar before's when the last bar has a single tap; - an end before the start still counts every bar between the two; - an end that snaps onto a downbeat still counts the time after it. beats_between, beside the conversions in qstamps.py, now gives the beats whose lengths the quarter notes between two times count, extrapolating as quarter_stamp_by_time does, and assumed_between checks their measures. A test checks over a grid of spans, with and without a one-tap last bar, that wherever the flag says no, another meter for the assumed bar leaves quarters() unchanged. quarters() is rounded to a millionth, as length() is: under 1/3 units, two beats 8 quarter notes apart gave 7.999999999999999. The stamp table is cached in the instance's __dict__ rather than with functools.cached_property, which on Python 3.10 and 3.11 holds one lock for every instance, so maps building their first table waited for each other. Part of TimeLineAnnotator/project-management#215 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/qstamps.py | 23 +++- .../src/tilia_core/timemap/timemap.py | 58 ++++----- .../tests/timemap/test_timemap_qstamps.py | 117 ++++++++++++++---- 3 files changed, 142 insertions(+), 56 deletions(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/qstamps.py b/packages/tilia-core/src/tilia_core/timemap/qstamps.py index c1885345d..96e152c77 100644 --- a/packages/tilia-core/src/tilia_core/timemap/qstamps.py +++ b/packages/tilia-core/src/tilia_core/timemap/qstamps.py @@ -6,12 +6,14 @@ `get_quarter_stamp_by_time` and `get_time_by_quarter_stamp`), with the same rules, over a beat timeline's beat times and its measures' meters instead of the app's components. Stamps are exact inside, and floats where they meet times. +`beats_between`, which says which beats' lengths a span's quarter notes count, +is the core's own. """ from __future__ import annotations import itertools -from bisect import bisect +from bisect import bisect, bisect_left from collections.abc import Sequence from dataclasses import dataclass from fractions import Fraction @@ -61,6 +63,7 @@ def build_time_stamp_table( # The last beat has no next beat to measure against, so the tempo of # the gap before it carries on past it. Its own length doesn't enter: # that gap lasted the previous beat's length, not the last one's. + # (beats_between follows this rule too.) seconds_per_quarter.append(seconds_per_quarter[-1]) return TimeStampTable( times=tuple(times), @@ -83,6 +86,24 @@ def quarter_stamp_by_time(table: TimeStampTable | None, time: float) -> float | ) +def beats_between(times: Sequence[float], start: float, end: float) -> range: + """ + The beats whose lengths the quarter notes between `start` and `end` count, + in either order, by index: each beat whose gap the span covers. Before the + first beat the first gap's tempo applies, and after the last beat the last + gap's, as `quarter_stamp_by_time` extrapolates. An end exactly on a beat + takes nothing from it, and a point takes its own gap. Empty with fewer than + two beats. + """ + if len(times) < 2: + return range(0) + start, end = sorted((start, end)) + last = len(times) - 2 # the last beat's own length never enters + first = min(max(bisect(times, start) - 1, 0), last) + stop = min(max(bisect_left(times, end) - 1, first), last) + return range(first, stop + 1) + + def time_by_quarter_stamp(table: TimeStampTable | None, stamp: float) -> float | None: """ The time of quarter stamp `stamp`, interpolated between beats and diff --git a/packages/tilia-core/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index b0b686e13..2426ccd06 100644 --- a/packages/tilia-core/src/tilia_core/timemap/timemap.py +++ b/packages/tilia-core/src/tilia_core/timemap/timemap.py @@ -10,11 +10,9 @@ from __future__ import annotations import bisect -import functools import itertools import logging import math -from collections.abc import Sequence from dataclasses import dataclass, field from tilia_core import tla @@ -22,6 +20,7 @@ from tilia_core.timemap.qstamps import ( TimeStampTable, beat_lengths, + beats_between, build_time_stamp_table, quarter_stamp_by_time, time_by_quarter_stamp, @@ -100,14 +99,25 @@ def __post_init__(self) -> None: for name, value in computed.items(): object.__setattr__(self, name, value) - @functools.cached_property + @property def _stamps(self) -> TimeStampTable | None: """Each beat's quarter stamp, built the first time one is asked for: the index, which reads every file's positions, never asks, and a long - piece's exact stamps take a while. Two threads asking at once build the - same table, and either is kept.""" + piece's exact stamps take a while. + + Kept in the instance's __dict__, without a lock, so that maps don't + wait for each other (functools.cached_property holds one lock for every + instance on Python 3.10 and 3.11). Two threads asking a new map at once + may both build its table; the tables are the same, and either is kept. + """ + try: + return self.__dict__["_stamp_table"] + except KeyError: + pass lengths = [length for row in self._rows for length in beat_lengths(row.meter)] - return build_time_stamp_table(self._times, lengths) + table = build_time_stamp_table(self._times, lengths) + object.__setattr__(self, "_stamp_table", table) + return table @property def start(self) -> float: @@ -241,33 +251,25 @@ def time_at_qstamp(self, q: float) -> float | None: def quarters(self, start: float, end: float) -> float | None: """How long a span lasts in quarter notes: the difference of its - qstamps. `assumed_between` says whether an assumed meter went into it.""" + qstamps, rounded to a millionth as lengths in bars and beats are. + `assumed_between` says whether an assumed meter went into it.""" a, b = self.qstamp(start), self.qstamp(end) if a is None or b is None: return None - return b - a + return round(b - a, 6) def assumed_between(self, start: float, end: float) -> bool: - """Whether any measure the span from `start` to `end` touches is - assumed, so that a length in quarter notes can say "assumed meter".""" - return any(row.assumed for row in self._touched(start, end)) - - def _touched(self, start: float, end: float) -> Sequence[MeasureRow]: - """The measures a span touches, read as positions read it: snapped, the - end point excluded, and an end not after the start in the start's - measure. A part off the map touches the measure at that end of the - map, as its qstamps are extrapolated with the tempo there.""" - snapped_start, snapped_end = self._snapped(start), self._snapped(end) - if snapped_start is None or snapped_end is None: - return () - downbeats, last = self._downbeats, len(self._rows) - 1 - first = bisect.bisect_right(downbeats, snapped_start) - 1 - first = min(max(first, 0), last) - # The end point excluded: an end on a downbeat ends in the bar before, - # but never before the start's. - end_measure = bisect.bisect_left(downbeats, snapped_end) - 1 - end_measure = min(max(end_measure, first), last) - return self._rows[first : end_measure + 1] + """Whether an assumed meter went into `quarters(start, end)`, so that a + length in quarter notes can say "assumed meter": whether a measure that + holds a beat whose length it counts is assumed (`beats_between`). + False for a missing time.""" + if any(t is None or not math.isfinite(t) for t in (start, end)): + return False + beats = beats_between(self._times, start, end) + if not beats: + return False + first, last = self._measure_of[beats[0]], self._measure_of[beats[-1]] + return any(row.assumed for row in self._rows[first : last + 1]) def _snapped(self, t: float | None) -> float | None: """`t` moved onto the nearer beat slot, or the closing barline, when it diff --git a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py index a24214e1d..4212157b7 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py +++ b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py @@ -12,6 +12,7 @@ import dataclasses import itertools import json +import threading from fractions import Fraction as F import pytest @@ -346,7 +347,7 @@ def test_a_missing_time_has_no_qstamp(): def test_a_span_in_quarter_notes(): # Spec, US3 scenario 5: a span's length in quarter notes is the difference - # of its qstamps. + # of its qstamps, rounded to a millionth as lengths in bars and beats are. tm = _map(_tapped_example()) assert tm.quarters(1.0, 4.0) == 3.0 # bar 1 @@ -354,45 +355,61 @@ def test_a_span_in_quarter_notes(): assert tm.quarters(12.5, 13.5) == 0.5 + 0.75 assert tm.quarters(6.6, 6.6) == 0.0 for start, end in ((0.0, 15.0), (2.3, 14.2), (-1.0, 20.0), (14.2, 2.3)): - assert tm.quarters(start, end) == tm.qstamp(end) - tm.qstamp(start) + difference = tm.qstamp(end) - tm.qstamp(start) + assert tm.quarters(start, end) == round(difference, 6) -def _assumed_map() -> TimeMap: - """Bars 1-4 of two taps each, a beat every second from 0 s: bars 1 and 2 - under a unit someone set, bar 3 under an assumed one, bar 4 set again.""" - beats = [ - {"time": float(t), "measure": {} if t % 2 == 0 else None} for t in range(8) - ] - beats[0]["measure"] = {"number": 1} - beats[0]["beat_unit"] = {"denominator": 4, "units": "1"} - beats[4]["beat_unit"] = {"denominator": 4, "units": "1", "assumed": True} - beats[6]["beat_unit"] = {"denominator": 8, "units": "3"} - return _map(beat_timeline(beats)) - - -def test_whether_a_span_touches_an_assumed_measure(): - # Spec, US3 scenario 5 (FR-043): whether any measure the span touches is - # assumed. Touched as positions read a span: snapped, the end point - # excluded, and a point, or an end not after the start, in its start's bar. - tm = _assumed_map() +def test_quarters_are_rounded_to_a_millionth(): + # Under 1/3 units, a beat every second, the float stamps of two beats + # 8 quarter notes apart differ by 7.999999999999999. + tm = _map(_timeline(range(30), [3], {0: (4, "1/3")})) + + assert tm.qstamp(26.0) - tm.qstamp(2.0) != 8 + assert tm.quarters(2.0, 26.0) == 8.0 and tm.quarters(4.0, 7.0) == 1.0 + + +def _assumed_timeline(assumed_unit=None, *, beats=8) -> tla.Timeline: + """Bars of two taps, a beat every second from 0 s: bars 1 and 2 under a + unit someone set, bar 3 under an assumed one, bar 4 set again, a 6/8.""" + marks = {t: {} for t in range(0, beats, 2)} | {0: {"number": 1}} + units = { + 0: {"denominator": 4, "units": "1"}, + 4: {"denominator": 4, "units": "1", **(assumed_unit or {}), "assumed": True}, + 6: {"denominator": 8, "units": "3"}, + } + return beat_timeline( + [ + {"time": float(t), "measure": marks.get(t), "beat_unit": units.get(t)} + for t in range(beats) + ] + ) + + +def test_whether_an_assumed_meter_went_into_a_length_in_quarters(): + # Spec, US3 scenario 5 (FR-043): whether a measure holding a beat whose + # length the span's quarter notes count is assumed. Times aren't snapped, + # as qstamps aren't; an end exactly on a beat takes nothing from its gap. + tm = _map(_assumed_timeline()) assert [row.assumed for row in tm.measure_rows()] == [False, False, True, False] assert not tm.assumed_between(0.0, 4.0) # bars 1 and 2, ending on bar 3 - assert not tm.assumed_between(0.5, 4.08) # an end that snaps onto it - assert tm.assumed_between(0.5, 4.2) # into bar 3 + assert tm.assumed_between(0.5, 4.08) # 0.08 s into bar 3 + assert not tm.assumed_between(3.92, 3.98) # just before it assert tm.assumed_between(3.5, 6.5) assert tm.assumed_between(5.0, 5.0) # a point in bar 3 assert tm.assumed_between(4.0, 4.0) # a point on its downbeat - assert tm.assumed_between(3.95, 3.95) # snapped onto it + assert not tm.assumed_between(3.95, 3.95) # a point just before it assert not tm.assumed_between(6.0, 7.5) # bar 4 - assert tm.assumed_between(5.5, 2.0) # an end before the start: bar 3's + # An end before the start: the bars between, as the other way round. + assert tm.assumed_between(5.5, 2.0) and tm.assumed_between(7.5, 3.5) assert not tm.assumed_between(2.5, 1.5) -def test_parts_of_a_span_off_the_map_touch_the_measure_at_that_end(): +def test_off_the_map_the_gap_at_that_end_counts(): # Qstamps off the map are extrapolated with the tempo of the gap at that - # end (#621), so a span there touches the first or the last measure. - tm = _assumed_map() + # end (#621): before the first beat the first gap's, after the last beat + # the last gap's. + tm = _map(_assumed_timeline()) assert not tm.assumed_between(-5.0, -1.0) # bar 1's assert not tm.assumed_between(7.5, 20.0) # bar 4's @@ -400,6 +417,52 @@ def test_parts_of_a_span_off_the_map_touch_the_measure_at_that_end(): # The tapped example's last bar, without its unit, reads 2/4, assumed. assert _map(_tapped_example(six_eight=False)).assumed_between(16.0, 18.0) assert not _map(_tapped_example()).assumed_between(16.0, 18.0) + # A last bar of a single tap: the last gap is the assumed bar 3's. + one_tap = _map(_assumed_timeline(beats=7)) + assert [row.beats for row in one_tap.measure_rows()] == [2, 2, 2, 1] + assert one_tap.assumed_between(6.0, 6.9) + + +@pytest.mark.parametrize("beats", [8, 7], ids=["two taps", "one tap"]) +def test_a_meter_the_flag_leaves_out_never_changes_the_quarters(beats): + # Wherever assumed_between says no, another meter for the assumed bar 3 + # leaves quarters() as it was: the flag misses no meter that went in. + quarter = _map(_assumed_timeline(beats=beats)) + eighth = _map(_assumed_timeline({"denominator": 8}, beats=beats)) + times = [t / 4 for t in range(-8, 44)] + times += [t + d for t in range(beats) for d in (-0.05, 1e-9, 0.05)] + + for start, end in itertools.product(times, repeat=2): + if not quarter.assumed_between(start, end): + expected = eighth.quarters(start, end) + assert quarter.quarters(start, end) == pytest.approx(expected, abs=1e-6) + + +def test_first_qstamps_on_two_maps_dont_wait_for_each_other(monkeypatch): + # Each map builds its stamp table on first use, without a lock that every + # map shares, as functools.cached_property's is on Python 3.10 and 3.11. + started, release = threading.Event(), threading.Event() + + def slow_build(times, lengths): + if times[0] == 100.0: + started.set() + release.wait(5) + return qstamps.build_time_stamp_table(times, lengths) + + monkeypatch.setattr(timemap_module, "build_time_stamp_table", slow_build) + slow, quick = _map(_timeline([100, 101, 102])), _map(_timeline(range(3))) + building = threading.Thread(target=slow.qstamp, args=(100.5,)) + building.start() + try: + assert started.wait(5) + asking = threading.Thread(target=quick.qstamp, args=(0.5,)) + asking.start() + asking.join(2) + assert not asking.is_alive() + finally: + release.set() + building.join(5) + assert slow.qstamp(100.5) == 0.5 and quick.qstamp(0.5) == 0.5 @pytest.mark.parametrize( From 05e1a6bb699e57f7c5ba565311e67ad00354449a Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:49:59 +0200 Subject: [PATCH 4/6] fix(core): end the last bar after the last beat's own length The closing barline was one gap after the last beat, while the time map's quarter notes carry that gap's tempo on past it (desktop#621's rule). So a last beat worth more or less than the beat before had two lengths: its row's quarters, and the map's quarters to the barline. Under 2+3 with a one-tap last bar these were 1 and 1.5. The last beat now lasts its own length at the tempo of the gap before it, still capped at the end of the media. The row, the map and #621 agree. When the last two beats are worth the same, the barline is exactly where it was, one gap after the last beat: the ratio of the two lengths is exact, and 1 multiplies without rounding. The warning for a last beat past the media now says the last measure "ends one beat after it". Tests: #325's example, a last beat worth more than the one before, the media's cap, equal beats ending exactly one gap after, and every shared example's last bar lasting as many quarter notes in the map as in its row. Part of TimeLineAnnotator/project-management#215, answering TimeLineAnnotator/project-management#325 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/table.py | 28 +++++++++----- .../tests/timemap/test_timemap_choose.py | 4 +- .../tests/timemap/test_timemap_distances.py | 3 +- .../tests/timemap/test_timemap_qstamps.py | 37 +++++++++++++++++++ .../tests/timemap/test_timemap_shared.py | 6 +++ 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index ea5a762d2..03384c21d 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -14,6 +14,7 @@ from tilia_core import tla from tilia_core.timemap._rows import RowIn, rows_of from tilia_core.timemap.meters import meters_of +from tilia_core.timemap.qstamps import beat_lengths from tilia_core.timemap.units import MeasureMeter, format_units # Float noise: two beats closer than this are at the same time, and a time this @@ -168,8 +169,9 @@ def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None: """The warning, with the timeline's id, that its last beat is at or after the end of the media; None when it isn't, or when the table has no rows. - With more than one beat, the last measure ends one gap after the last - beat, and the warning says so; with one, it ends on the beat. + With more than one beat, the last measure ends one beat after the last + beat, its own length at the tempo before it, and the warning says so; + with one, it ends on the beat. """ reason, times = _checked(timeline) if reason is not None or not times: @@ -183,7 +185,7 @@ def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None: f"the end of the media ({media_length})" ) if gap > 0: - warning += "; the last measure ends one gap after it" + warning += "; the last measure ends one beat after it" return warning @@ -245,13 +247,13 @@ def build_rows_from( if not rows: return [] folded = is_folded(rows) + meters = meters_of(rows) ends = [row.beat_times[0] for row in rows[1:]] - ends.append(_closing_barline(document, rows)) + ends.append(_closing_barline(document, rows, meters)) passes: dict[int, int] = {} movement = 1 qstamp = Fraction(0) out: list[MeasureRow] = [] - meters = meters_of(rows) for index, (row, end, meter) in enumerate(zip(rows, ends, meters, strict=True)): count = index + 1 if row.restart and count > 1: # the first row is in movement 1 anyway @@ -310,14 +312,22 @@ def is_folded(rows: Sequence[RowIn]) -> bool: return any(row.next is not None for row in rows) -def _closing_barline(document: tla.Document, rows: Sequence[RowIn]) -> float: - # The last beat lasts as long as the gap before it, or until the end of the - # media if that comes sooner. No beats are added to the last measure. +def _closing_barline( + document: tla.Document, rows: Sequence[RowIn], meters: Sequence[MeasureMeter] +) -> float: + # The last beat lasts its own length at the tempo of the gap before it, or + # until the end of the media if that comes sooner: the tempo at which the + # time map's quarter notes go on past it (FR-011, FR-051). When the last + # two beats are worth the same, that is the gap itself. No beats are added + # to the last measure. last, gap = _last_gap([t for row in rows[-2:] for t in row.beat_times]) + lengths = [length for meter in meters[-2:] for length in beat_lengths(meter)] + if len(lengths) > 1: + gap *= float(lengths[-1] / lengths[-2]) end = last + gap media_length = _media_length(document) if media_length is None or _ends_by(media_length, last): - return end # past the media, the gap alone; find_time_map warns + return end # past the media, that length alone; find_time_map warns return min(end, media_length) diff --git a/packages/tilia-core/tests/timemap/test_timemap_choose.py b/packages/tilia-core/tests/timemap/test_timemap_choose.py index f9c426dba..a59c9473c 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_choose.py +++ b/packages/tilia-core/tests/timemap/test_timemap_choose.py @@ -266,7 +266,7 @@ def test_the_last_beat_at_or_after_the_end_of_the_media_is_returned_and_logged( (warning,) = found.warnings assert warning == ( f"Timeline {timeline.id}: the last beat (20.0) is at or after the end of " - f"the media ({media_length}); the last measure ends one gap after it" + f"the media ({media_length}); the last measure ends one beat after it" ) (record,) = caplog.records assert record.name == "tilia_core.timemap" @@ -386,7 +386,7 @@ def test_a_whole_number_media_length_caps_and_warns(): assert past.time_map.end == 8.0 assert past.warnings == ( f"Timeline {timeline.id}: the last beat (6.0) is at or after the end of the " - "media (6); the last measure ends one gap after it", + "media (6); the last measure ends one beat after it", ) diff --git a/packages/tilia-core/tests/timemap/test_timemap_distances.py b/packages/tilia-core/tests/timemap/test_timemap_distances.py index bce94f1c4..440292419 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_distances.py +++ b/packages/tilia-core/tests/timemap/test_timemap_distances.py @@ -116,7 +116,8 @@ def test_how_long_a_beat_and_a_bar_last(): assert tm.unit_seconds(3.2, "bar") == 2.0 # bar 2 ends at the closing barline # Snapped first: 2.95 is on bar 2's downbeat. assert tm.unit_seconds(2.95, "beat") == 0.5 and tm.unit_seconds(2.95, "bar") == 2.0 - # The last beat lasts as long as the gap before it (FR-011). + # The last beat lasts its own length at the tempo of the gap before + # it, here the gap itself (FR-011). assert tm.unit_seconds(4.7, "beat") == 0.5 # Off the map: nothing. assert tm.unit_seconds(-1.0, "bar") is None and tm.unit_seconds(5.0, "beat") is None diff --git a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py index 4212157b7..4c4e71492 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py +++ b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py @@ -465,6 +465,43 @@ def slow_build(times, lengths): assert slow.qstamp(100.5) == 0.5 and quick.qstamp(0.5) == 0.5 +def _uneven_ending(units: str, **kwargs) -> TimeMap: + """Two bars of two taps under `units` over 8, a beat every second, then a + last bar of one tap at 4 s, which reads the pattern's first entry.""" + return _map(_timeline(range(5), [2], {0: (8, units)}), **kwargs) + + +def test_a_last_beat_lasts_its_own_length_at_the_last_tempo(): + # TimeLineAnnotator/project-management#325's example: under 2+3, the beat + # at 3 s is worth 3 eighths and lasts 1 s, so an eighth takes 1/3 s; the + # last beat, worth 2, lasts 2/3 s. + tm = _uneven_ending("2+3") + last = tm.measure_rows()[-1] + + assert tm.end == pytest.approx(4 + 2 / 3) + assert (last.quarters, last.end) == (1, tm.end) + assert tm.quarters(last.start, last.end) == 1.0 + assert tm.qstamp(tm.end) == pytest.approx(float(last.qstamp + last.quarters)) + assert tm.qstamp(4.5) == 5.75 # desktop#621's tempo past the last beat + assert tm.positions(4.8, 4.8) is None # past the closing barline + # Worth more than the beat before: under 3+2, the last beat is worth 3 + # eighths after a beat of 2 that took 1 s, so it lasts 1.5 s. + longer = _uneven_ending("3+2") + assert longer.end == 5.5 and longer.quarters(4.0, 5.5) == 1.5 + # The end of the media still comes first. + assert _uneven_ending("2+3", media_length=4.5).end == 4.5 + + +def test_a_last_beat_worth_the_same_ends_one_gap_after_it(): + # When the last two beats are worth the same, the barline doesn't move: + # exactly one gap after the last beat, as before #325. Under 5/3, the gap + # through the tempo in floats would move this barline by a float's last + # place. + for units in ("3", "1", "1/3", "5/3"): + tm = _map(_timeline([0.0, 0.3, 0.5, 0.721, 0.94], [2], {0: (8, units)})) + assert tm.end == 0.94 + (0.94 - 0.721) + + @pytest.mark.parametrize( "beat_unit, taps, quarters", [ diff --git a/packages/tilia-core/tests/timemap/test_timemap_shared.py b/packages/tilia-core/tests/timemap/test_timemap_shared.py index 525728c22..f6710a95a 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_shared.py +++ b/packages/tilia-core/tests/timemap/test_timemap_shared.py @@ -102,3 +102,9 @@ def test_qstamps(case): assert [row.qstamp for row in time_map.measure_rows()] == [ expected[k] for k in downbeats ] + # The last bar lasts as many quarter notes in the map as in its row: + # TimeLineAnnotator/project-management#325 (a last beat's length). + last = time_map.measure_rows()[-1] + assert time_map.quarters(last.start, last.end) == pytest.approx( + float(last.quarters) + ) From 6f4410974ae5b19272aa8bf5f4b9d473772bc866 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:11:29 +0100 Subject: [PATCH 5/6] test(core): keep the running qstamp short under crafted beat units The rows' running qstamp is an exact sum, so beat units with large, distinct denominators made its denominator about 30 digits longer with each measure. Without the limit of 128 on a unit's denominator, 8,000 two-tap measures under the units 1/(10**29 + i) take tens of seconds to build their rows and quarter stamps. From about 250 measures on, a row's repr and json.dumps(dataclasses.asdict(row), default=str) raise ValueError: Python won't write an int of more than 4,300 digits as text. Two tests: that long table builds its rows and stamps within 3 s, and its last row's repr and JSON work, as they do on a table that uses every denominator a unit may have, 1/1 to 1/128. Without the limit in units.py both fail, as do the four refused-denominator cases in test_timemap_units.py. Part of TimeLineAnnotator/project-management#215 Co-Authored-By: Claude Opus 5.5 --- .../tests/timemap/test_timemap_qstamps.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py index 4c4e71492..7b8977804 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_qstamps.py +++ b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py @@ -14,6 +14,7 @@ import json import threading from fractions import Fraction as F +from time import perf_counter import pytest @@ -22,6 +23,7 @@ from tilia_core.timemap import TimeMap, qstamps, time_map_for from tilia_core.timemap import timemap as timemap_module from tilia_core.timemap.table import build_rows +from tilia_core.timemap.units import MAX_DENOMINATOR # What TiLiA puts on the first beat, which #621's tests edit. DEFAULT = {"denominator": 4, "units": "1", "assumed": True} @@ -531,6 +533,56 @@ def test_rows_give_their_qstamps_as_text_in_json(): assert (as_json["qstamp"], as_json["quarters"]) == ("2", "2") +def _crafted(measures: int) -> tla.Timeline: + """Measures of two taps, a beat every second, measure i's unit a quarter's + 1/(10**29 + i): as long as a unit may be written, each with a denominator + of its own.""" + units = {2 * i: (4, "1/" + str(10**29 + i)) for i in range(measures)} + return _timeline(range(2 * measures), [2], units) + + +def test_a_long_table_of_crafted_units_builds_quickly(): + # desktop#747's review: the rows' running qstamp is an exact sum, and each + # of these units made its denominator about 30 digits longer. 8,000 such + # measures took 10 s to build their rows, and 4 s more for the map's + # stamps. A unit's denominator is now at most 128, which refuses them + # (TimeLineAnnotator/project-management#247). + timeline = _crafted(8000) + doc = document(timeline) + + start = perf_counter() + rows = build_rows(doc, timeline) + time_map = time_map_for(doc, timeline.id) + assert time_map is not None and time_map.qstamp(rows[-1].start) is not None + assert perf_counter() - start < 3 + assert len(rows) == 8000 + + +def test_the_last_row_writes_its_qstamp_as_text(): + # Python refuses to write an int of more than 4,300 digits as text. From + # the 250th crafted measure on, a row's qstamp had a longer denominator, + # and its repr and the contract's JSON recipe raised (desktop#747's + # review). They work at the end of the long table, and on a table that + # uses every denominator a unit may have, 1/1 to 1/128. + every = {2 * i: (4, f"1/{i + 1}") for i in range(MAX_DENOMINATOR)} + crafted, every_denominator = ( + build_rows(document(timeline), timeline)[-1] + for timeline in ( + _crafted(8000), + _timeline(range(2 * MAX_DENOMINATOR), [2], every), + ) + ) + # Every unit was read: measure d's two beats are each worth 1/d quarter. + assert every_denominator.qstamp == sum(F(2, d) for d in range(1, MAX_DENOMINATOR)) + assert every_denominator.quarters == F(2, MAX_DENOMINATOR) + + for last in (crafted, every_denominator): + assert repr(last.qstamp) in repr(last) + as_json = json.loads(json.dumps(dataclasses.asdict(last), default=str)) + assert F(as_json["qstamp"]) == last.qstamp + assert F(as_json["quarters"]) == last.quarters + + def test_qstamps_are_computed_for_a_folded_table(): # FR-013: a folded table has no time map, but its rows have their qstamps, # in the timeline's order. From 1613e0eb0e0b932053e3ddbbfbfe2548828ad8bf Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:26:00 +0100 Subject: [PATCH 6/6] fix(core): keep "one gap after it" in the media warning The warning for a last beat at or after the end of the media goes back to saying the last measure "ends one gap after it", as it did before the last beat took its own length. Only the wording changes: the last beat still lasts its own length at the tempo of the gap before it, and media_warning's docstring now says both. The two tests in test_timemap_choose.py that check the warning's text follow. Part of TimeLineAnnotator/project-management#215 Co-Authored-By: Claude Opus 5.5 --- packages/tilia-core/src/tilia_core/timemap/table.py | 8 ++++---- packages/tilia-core/tests/timemap/test_timemap_choose.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index 03384c21d..688019b60 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -169,9 +169,9 @@ def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None: """The warning, with the timeline's id, that its last beat is at or after the end of the media; None when it isn't, or when the table has no rows. - With more than one beat, the last measure ends one beat after the last - beat, its own length at the tempo before it, and the warning says so; - with one, it ends on the beat. + With more than one beat, the last beat lasts its own length at the tempo + of the gap before it, and the warning says the last measure ends one gap + after it; with one, it ends on the beat. """ reason, times = _checked(timeline) if reason is not None or not times: @@ -185,7 +185,7 @@ def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None: f"the end of the media ({media_length})" ) if gap > 0: - warning += "; the last measure ends one beat after it" + warning += "; the last measure ends one gap after it" return warning diff --git a/packages/tilia-core/tests/timemap/test_timemap_choose.py b/packages/tilia-core/tests/timemap/test_timemap_choose.py index a59c9473c..f9c426dba 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_choose.py +++ b/packages/tilia-core/tests/timemap/test_timemap_choose.py @@ -266,7 +266,7 @@ def test_the_last_beat_at_or_after_the_end_of_the_media_is_returned_and_logged( (warning,) = found.warnings assert warning == ( f"Timeline {timeline.id}: the last beat (20.0) is at or after the end of " - f"the media ({media_length}); the last measure ends one beat after it" + f"the media ({media_length}); the last measure ends one gap after it" ) (record,) = caplog.records assert record.name == "tilia_core.timemap" @@ -386,7 +386,7 @@ def test_a_whole_number_media_length_caps_and_warns(): assert past.time_map.end == 8.0 assert past.warnings == ( f"Timeline {timeline.id}: the last beat (6.0) is at or after the end of the " - "media (6); the last measure ends one beat after it", + "media (6); the last measure ends one gap after it", )