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..96e152c77 --- /dev/null +++ b/packages/tilia-core/src/tilia_core/timemap/qstamps.py @@ -0,0 +1,118 @@ +"""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. +`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, bisect_left +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. + # (beats_between follows this rule too.) + 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 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 + 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..688019b60 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 @@ -13,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 @@ -78,6 +80,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 +88,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 +96,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) @@ -161,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 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: @@ -238,12 +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 @@ -268,12 +278,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 @@ -300,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/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index 5e486d5c1..2426ccd06 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 itertools +import logging import math 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, + beats_between, + build_time_stamp_table, + quarter_stamp_by_time, + time_by_quarter_stamp, +) from tilia_core.timemap.table import ( EPS, MeasureRow, @@ -23,6 +33,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 = ( @@ -30,6 +42,8 @@ "which positions don't handle yet" ) +UNITS = ("bar", "beat") # what distances and lengths are measured in + @dataclass(frozen=True) class Positions: @@ -61,7 +75,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 +99,26 @@ def __post_init__(self) -> None: for name, value in computed.items(): object.__setattr__(self, name, value) + @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. + + 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)] + table = build_time_stamp_table(self._times, lengths) + object.__setattr__(self, "_stamp_table", table) + return table + @property def start(self) -> float: """The first beat.""" @@ -138,6 +173,104 @@ 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. + + 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, 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 round(b - a, 6) + + def assumed_between(self, start: float, end: float) -> bool: + """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 lies within the tolerance of one. None for a missing time.""" @@ -177,6 +310,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..440292419 --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_distances.py @@ -0,0 +1,160 @@ +"""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 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 + + +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]) 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..7b8977804 --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_qstamps.py @@ -0,0 +1,597 @@ +"""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 +import threading +from fractions import Fraction as F +from time import perf_counter + +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 +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} + + +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, 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 + 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)): + difference = tm.qstamp(end) - tm.qstamp(start) + assert tm.quarters(start, end) == round(difference, 6) + + +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 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 not tm.assumed_between(3.95, 3.95) # a point just before it + assert not tm.assumed_between(6.0, 7.5) # bar 4 + # 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_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): 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 + 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) + # 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 + + +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", + [ + ((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 _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. + 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..f6710a95a 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_shared.py +++ b/packages/tilia-core/tests/timemap/test_timemap_shared.py @@ -85,16 +85,26 @@ 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 + ] + # 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) + )