diff --git a/packages/tilia-core/src/tilia_core/timemap/lookup.py b/packages/tilia-core/src/tilia_core/timemap/lookup.py new file mode 100644 index 000000000..7def7b508 --- /dev/null +++ b/packages/tilia-core/src/tilia_core/timemap/lookup.py @@ -0,0 +1,165 @@ +"""Bars back to times: the times of a bar and a fraction of it, one per time +through the bar, as TiLiA's import by measure asks for them. + +A port of TiLiA's `BeatTimeline.get_time_by_measure`, which looks among every +pass's beats at once, with one time for each row the bar names instead +(TimeLineAnnotator/project-management#315): + +- the fraction counts the row's own beats, and between two beats the time is + interpolated; a fraction that, at 3 decimals, is a beat's (0.333 of three + beats) is that beat, as TiLiA rounds; +- the fraction 1 is the next downbeat when the next bar is numbered one more, + and no time otherwise; as a segment's end, it is the row's end; +- past the last beat, the last row lasts as long as the row before it + (TiLiA's projection), not until FR-011's closing barline; +- as a segment's end, the fraction 0 also gives where each time through the + bar numbered just below ends (#321); +- a bar no row names gives no time. +""" + +from __future__ import annotations + +import logging +import math +from collections.abc import Sequence + +from tilia_core.timemap.table import MeasureRow, find_rows + +logger = logging.getLogger(__name__) + +DECIMALS = 3 # TiLiA rounds a bar number plus its fraction to this many + + +def times_of( + rows: Sequence[MeasureRow], + times: Sequence[float], + first: Sequence[int], + number: int | None = None, + fraction: float = 0.0, + *, + label: str | None = None, + pass_: int | None = None, + movement: int | None = None, + segment_end: bool = False, +) -> list[float]: + """The times of `fraction` through the rows a bar names, in time order. + + `rows` are a time map's rows in the timeline's order, `times` its beats, + and `first[j]` the index in `times` of row j's downbeat. The rows are + named as `find_rows` names them. A fraction outside 0 to 1 gives no time, + with a warning. + """ + if not 0.0 <= fraction <= 1.0: # NaN too + logger.warning( + "A fraction of a bar runs from 0 to 1, not %r; no time.", fraction + ) + return [] + named = find_rows(rows, number, label=label, pass_=pass_, movement=movement) + found = [ + time + for row in named + if (time := _time_in(rows, times, first, row, fraction, segment_end)) + is not None + ] + if segment_end: + found += _ends_before(rows, named, fraction, pass_=pass_, movement=movement) + return _distinct(found) + + +def _time_in( + rows: Sequence[MeasureRow], + times: Sequence[float], + first: Sequence[int], + row: MeasureRow, + fraction: float, + segment_end: bool, +) -> float | None: + j = row.count - 1 # a time map keeps every row of its table + beat = _beat_at(row, fraction) + if beat == row.beats: # the fraction 1 + if segment_end: + return _end(rows, j) + following = rows[j + 1] if j + 1 < len(rows) else None + if following is not None and following.number == row.number + 1: + return following.start + return None + if beat is not None: + return times[first[j] + beat] + position = fraction * row.beats + k = int(position) # below row.beats: a fraction this close to 1 is at 1 + start = times[first[j] + k] + end = times[first[j] + k + 1] if k + 1 < row.beats else _end(rows, j) + if end is None: + return None + return start + (position - k) * (end - start) + + +def _beat_at(row: MeasureRow, fraction: float) -> int | None: + """The beat (0-based) that `fraction` is at, rounded as TiLiA rounds; + `row.beats` for the fraction 1, None between beats.""" + target = round(row.number + fraction, DECIMALS) + if target == row.number + 1: + return row.beats + for k in range(row.beats): + if target == round(row.number + k / row.beats, DECIMALS): + return k + return None + + +def _end(rows: Sequence[MeasureRow], j: int) -> float | None: + """Where row j ends: the next downbeat, or past the last beat, as long + after its downbeat as the row before it lasted. None for a table of one + row, which has nothing to measure it by.""" + if j + 1 < len(rows): + return rows[j].end + if j == 0: + return None + row = rows[j] + return row.start + (row.start - rows[j - 1].start) + + +def _ends_before( + rows: Sequence[MeasureRow], + named: Sequence[MeasureRow], + fraction: float, + *, + pass_: int | None, + movement: int | None, +) -> list[float]: + """As a segment's end, the fraction 0 of bar N: where each time through + the bar numbered just below N ends, in the pass and movement asked for. + + An end that leads into a bar N the query doesn't name is left out, so a + label or a pass gives only the ends that lead into the bars it names; + without them, every bar N is named. + """ + named_counts = {row.count for row in named} + ends: list[float] = [] + for number in sorted({row.number for row in named if _beat_at(row, fraction) == 0}): + below = [ + row.number + for row in rows + if row.number < number + and not row.cadenza + and (movement is None or row.movement == movement) + ] + if not below: + continue + others = {row.count for row in find_rows(rows, number)} - named_counts + for before in find_rows(rows, max(below), pass_=pass_, movement=movement): + j = before.count - 1 + if j + 1 < len(rows) and rows[j + 1].count in others: + continue + end = _end(rows, j) + if end is not None: + ends.append(end) + return ends + + +def _distinct(times: list[float]) -> list[float]: + """In time order, without times as close as TiLiA's lookup counts the same.""" + out: list[float] = [] + for time in sorted(times): + if not out or not math.isclose(time, out[-1]): + out.append(time) + return out diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index 56ad100a2..243039f12 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -257,9 +257,11 @@ def find_rows( *, label: str | None = None, pass_: int | None = None, + movement: int | None = None, ) -> list[MeasureRow]: """The rows a bar names: by number (every pass, cadenzas left out), by label - (cadenzas included), or both; `pass_` narrows them to one time through. + (cadenzas included), or both; `pass_` narrows them to one time through, and + `movement` to one movement, else every movement matches. Nothing, when neither a number nor a label is given.""" if number is None and label is None: return [] @@ -270,4 +272,5 @@ def find_rows( and (label is None or row.label == label) and (label is not None or not row.cadenza) and (pass_ is None or row.pass_ == pass_) + and (movement is None or row.movement == movement) ] diff --git a/packages/tilia-core/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index ca2b0e079..1c9414b17 100644 --- a/packages/tilia-core/src/tilia_core/timemap/timemap.py +++ b/packages/tilia-core/src/tilia_core/timemap/timemap.py @@ -14,11 +14,13 @@ from dataclasses import dataclass, field from tilia_core import tla +from tilia_core.timemap import lookup from tilia_core.timemap._rows import rows_of from tilia_core.timemap.table import ( MeasureRow, broken_row, build_rows_from, + find_rows, is_folded, missing_time, ) @@ -139,6 +141,47 @@ def positions(self, start: float, end: float) -> Positions | None: movement=row.movement, ) + def find( + self, + number: int | None = None, + *, + label: str | None = None, + pass_: int | None = None, + movement: int | None = None, + ) -> list[MeasureRow]: + """The measures a bar names, in the timeline's order: by number (every + pass, cadenzas left out), by label (cadenzas included), or both; + narrowed to one pass, and to one movement, else every movement.""" + return find_rows( + self._rows, number, label=label, pass_=pass_, movement=movement + ) + + def times_of( + self, + number: int | None = None, + fraction: float = 0.0, + *, + label: str | None = None, + pass_: int | None = None, + movement: int | None = None, + segment_end: bool = False, + ) -> list[float]: + """The times of `fraction` (0 to 1, counted in beats) through each + measure the bar names, in time order: one per pass, as an import by + measure asks for them (`lookup.times_of`). `segment_end` is for the + end of a span.""" + return lookup.times_of( + self._rows, + self._times, + self._first, + number, + fraction, + label=label, + pass_=pass_, + movement=movement, + segment_end=segment_end, + ) + 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/shared_examples.json b/packages/tilia-core/tests/timemap/shared_examples.json index b2c9f510f..ffe4f044e 100644 --- a/packages/tilia-core/tests/timemap/shared_examples.json +++ b/packages/tilia-core/tests/timemap/shared_examples.json @@ -372,5 +372,453 @@ } } ], - "times_of": [] + "times_of": [ + { + "name": "one bar: bar 1", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0} + ], + "query": {"number": 1, "fraction": 0.0, "segment_end": false}, + "expected": [1.0] + }, + { + "name": "three beats, then one: bar 1", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0}, + {"time": 4.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.0, "segment_end": false}, + "expected": [1.0] + }, + { + "name": "three beats, then one: bar 1 at 0.333, its second beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0}, + {"time": 4.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.333, "segment_end": false}, + "expected": [2.0] + }, + { + "name": "three beats, then one: bar 1 at 0.667, its third beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0}, + {"time": 4.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.667, "segment_end": false}, + "expected": [3.0] + }, + { + "name": "three bars: bar 1", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0} + ], + "query": {"number": 1, "fraction": 0.0, "segment_end": false}, + "expected": [1.0] + }, + { + "name": "three bars: bar 2", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0} + ], + "query": {"number": 2, "fraction": 0.0, "segment_end": false}, + "expected": [3.0] + }, + { + "name": "three bars: bar 3", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0} + ], + "query": {"number": 3, "fraction": 0.0, "segment_end": false}, + "expected": [5.0] + }, + { + "name": "uneven beats: bar 1 at 0", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 7.0}, + {"time": 8.0}, + {"time": 9.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.0, "segment_end": false}, + "expected": [1.0] + }, + { + "name": "uneven beats: bar 1 at 0.25, its second beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 7.0}, + {"time": 8.0}, + {"time": 9.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.25, "segment_end": false}, + "expected": [3.0] + }, + { + "name": "uneven beats: bar 1 at 0.5, its third beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 7.0}, + {"time": 8.0}, + {"time": 9.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.5, "segment_end": false}, + "expected": [7.0] + }, + { + "name": "uneven beats: bar 1 at 0.75, its fourth beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 7.0}, + {"time": 8.0}, + {"time": 9.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.75, "segment_end": false}, + "expected": [8.0] + }, + { + "name": "the fraction 1: the next bar's downbeat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 1.0, "segment_end": false}, + "expected": [3.0] + }, + { + "name": "uneven beats: bar 1 at 0.125, between its first two beats", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 4.0}, + {"time": 5.0}, + {"time": 6.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.125, "segment_end": false}, + "expected": [2.0] + }, + { + "name": "uneven beats: bar 1 at 0.375, between its second and third", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 4.0}, + {"time": 5.0}, + {"time": 6.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.375, "segment_end": false}, + "expected": [3.5] + }, + { + "name": "uneven beats: bar 1 at 0.625, between its third and fourth", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 3.0}, + {"time": 4.0}, + {"time": 5.0}, + {"time": 6.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.625, "segment_end": false}, + "expected": [4.5] + }, + { + "name": "between a bar's last beat and the next downbeat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}} + ], + "query": {"number": 1, "fraction": 0.75, "segment_end": false}, + "expected": [2.5] + }, + { + "name": "a bar after the last: none", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0} + ], + "query": {"number": 2, "fraction": 0.0, "segment_end": false}, + "expected": [] + }, + { + "name": "a bar far after the last: none", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0} + ], + "query": {"number": 999, "fraction": 0.0, "segment_end": false}, + "expected": [] + }, + { + "name": "bar 1 played three times", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 1, "fraction": 0.0, "segment_end": false}, + "expected": [1.0, 7.0, 11.0] + }, + { + "name": "bar 2 played twice, at 0.5", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 2, "fraction": 0.5, "segment_end": false}, + "expected": [4.0, 10.0] + }, + { + "name": "bar 2 played twice, at 0.75", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 2, "fraction": 0.75, "segment_end": false}, + "expected": [4.5, 10.5] + }, + { + "name": "bar 3 played once, at 0.5", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 3, "fraction": 0.5, "segment_end": false}, + "expected": [6.0] + }, + { + "name": "bar 3 played once, at 0.75", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 3, "fraction": 0.75, "segment_end": false}, + "expected": [6.5] + }, + { + "name": "the end of bar 3, no bar 4: none", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 3, "fraction": 1.0, "segment_end": false}, + "expected": [] + }, + { + "name": "the end of bar 3, no bar 4, as a segment's end", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 3, "fraction": 1.0, "segment_end": true}, + "expected": [7.0] + }, + { + "name": "a segment's end on a downbeat: where bar 2 ends, each time", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 3, "fraction": 0.0, "segment_end": true}, + "expected": [5.0, 11.0] + }, + { + "name": "the last bar, between its beats", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0}, + {"time": 4.0}, + {"time": 5.0, "measure": {"number": 2}}, + {"time": 6.0}, + {"time": 7.0}, + {"time": 8.0} + ], + "query": {"number": 2, "fraction": 0.5, "segment_end": false}, + "expected": [7.0] + }, + { + "name": "passes of four and three beats", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0}, + {"time": 4.0}, + {"time": 5.0, "measure": {"number": 2}}, + {"time": 6.0}, + {"time": 7.0}, + {"time": 8.0}, + {"time": 9.0, "measure": {"number": 1}}, + {"time": 10.0}, + {"time": 11.0}, + {"time": 12.0, "measure": {"number": 2}}, + {"time": 13.0}, + {"time": 14.0}, + {"time": 15.0}, + {"time": 16.0, "measure": {"number": 3}}, + {"time": 17.0} + ], + "query": {"number": 1, "fraction": 0.5, "segment_end": false}, + "expected": [3.0, 10.5] + }, + { + "name": "a bar that doesn't exist: none", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {"number": 2}}, + {"time": 4.0}, + {"time": 5.0, "measure": {"number": 4}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 5}}, + {"time": 8.0} + ], + "query": {"number": 3, "fraction": 0.0, "segment_end": false}, + "expected": [] + }, + { + "name": "the fraction 1: the downbeat after the bar, not every bar N+1's", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {"number": 2}}, + {"time": 4.0}, + {"time": 5.0, "measure": {"number": 3}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 2}}, + {"time": 8.0}, + {"time": 9.0, "measure": {"number": 3}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 4}}, + {"time": 12.0} + ], + "query": {"number": 1, "fraction": 1.0, "segment_end": false}, + "expected": [3.0] + }, + { + "name": "the last time through, past the last beat", + "beats": [ + {"time": 1.0, "measure": {"number": 1}}, + {"time": 2.0}, + {"time": 3.0, "measure": {}}, + {"time": 4.0}, + {"time": 5.0, "measure": {}}, + {"time": 6.0}, + {"time": 7.0, "measure": {"number": 1}}, + {"time": 8.0}, + {"time": 9.0, "measure": {}}, + {"time": 10.0}, + {"time": 11.0, "measure": {"number": 1}}, + {"time": 12.0} + ], + "query": {"number": 1, "fraction": 0.75, "segment_end": false}, + "expected": [2.5, 8.5, 12.5] + } + ] } diff --git a/packages/tilia-core/tests/timemap/test_timemap_lookup.py b/packages/tilia-core/tests/timemap/test_timemap_lookup.py new file mode 100644 index 000000000..e440c3db4 --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_lookup.py @@ -0,0 +1,350 @@ +"""Finding bars, and the times of a bar and a fraction of it. + +The shared cases come from the app's tests of `get_time_by_measure`, which +the app's half runs too, and from TimeLineAnnotator/project-management#315, +which gives one time per pass. The tests after them are the core's own: +labels, passes, movements and cadenzas, which the app's format has no place +for. + +A case that passes on one side only means the two sides differ: fix one +side, never the expected values alone. +""" + +import json +import logging +from pathlib import Path + +import pytest + +from docs import beat_timeline, document +from tilia_core.timemap import time_map_for + +EXAMPLES = json.loads( + Path(__file__).with_name("shared_examples.json").read_text(encoding="utf-8") +) +TIMES_OF = [pytest.param(case, id=case["name"]) for case in EXAMPLES["times_of"]] + +# Every assertion of the app's tests that call get_time_by_measure on two +# beats or more, and #315's cases, by the names the file gives them. +REQUIRED = [ + # test_get_times_by_measure_one_measure + "one bar: bar 1", + # test_get_times_by_measure_one_measures_repeating_decimal + "three beats, then one: bar 1", + "three beats, then one: bar 1 at 0.333, its second beat", + "three beats, then one: bar 1 at 0.667, its third beat", + # test_get_times_by_measure_multiple_measures + "three bars: bar 1", + "three bars: bar 2", + "three bars: bar 3", + # test_get_times_by_measure_uneven_beats_time_at_beats + "uneven beats: bar 1 at 0", + "uneven beats: bar 1 at 0.25, its second beat", + "uneven beats: bar 1 at 0.5, its third beat", + "uneven beats: bar 1 at 0.75, its fourth beat", + # test_get_times_by_measure_fraction_is_1 + "the fraction 1: the next bar's downbeat", + # test_get_times_by_measure_uneven_beats_time_in_between_beats + "uneven beats: bar 1 at 0.125, between its first two beats", + "uneven beats: bar 1 at 0.375, between its second and third", + "uneven beats: bar 1 at 0.625, between its third and fourth", + # test_get_time_between_last_beat_of_measure_and_first_beat_of_next_measure + "between a bar's last beat and the next downbeat", + # test_get_times_by_measure_index_bigger_than_max_measure + "a bar after the last: none", + "a bar far after the last: none", + # test_get_times_by_fraction_in_repeat_measure_number + "bar 1 played three times", + "bar 2 played twice, at 0.5", + "bar 2 played twice, at 0.75", + "bar 3 played once, at 0.5", + "bar 3 played once, at 0.75", + "the end of bar 3, no bar 4: none", + "the end of bar 3, no bar 4, as a segment's end", + # what dev gives for an end on a downbeat, each time through + "a segment's end on a downbeat: where bar 2 ends, each time", + # #315 + "the last bar, between its beats", + "passes of four and three beats", + "a bar that doesn't exist: none", + "the fraction 1: the downbeat after the bar, not every bar N+1's", + "the last time through, past the last beat", +] + + +def test_the_file_holds_every_case(): + names = [case["name"] for case in EXAMPLES["times_of"]] + + assert [name for name in REQUIRED if name not in names] == [] + assert len(set(names)) == len(names) + + +@pytest.mark.parametrize("case", TIMES_OF) +def test_each_case_is_laid_out_as_the_contract_says(case): + times = [beat["time"] for beat in case["beats"]] + + assert case["beats"][0].get("measure") is not None + assert times == sorted(set(times)) + assert set(case["query"]) == {"number", "fraction", "segment_end"} + assert case["expected"] == sorted(case["expected"]) + + +@pytest.mark.parametrize("case", TIMES_OF) +def test_times_of(case): + timeline = beat_timeline(case["beats"]) + time_map = time_map_for(document(timeline), timeline.id) + query = case["query"] + + assert time_map.times_of( + query["number"], query["fraction"], segment_end=query["segment_end"] + ) == pytest.approx(case["expected"]) + + +def time_map(*bars): + """A beat every second from 1 s, in bars of (mark, beats); a mark is a + number, or a dict.""" + beats, t = [], 1.0 + for mark, count in bars: + mark = {"number": mark} if isinstance(mark, int) else mark + for k in range(count): + beats.append({"time": t, "measure": mark if k == 0 else None}) + t += 1.0 + timeline = beat_timeline(beats) + return time_map_for(document(timeline), timeline.id) + + +def restart(number): + return {"number": number, "restart": True} + + +# Two songs, each numbered from 1: bars from 1, 3, 5, then 7 and 9 s. +SONGS = ((1, 2), (2, 2), (3, 2), (restart(1), 2), (2, 2)) +# Bar 1 three times, from 1, 7 and 11 s (US6, scenario 1). +REPEATS = ((1, 2), (2, 2), (3, 2), (1, 2), (2, 2), (1, 2)) +# First and second endings: 10 11 12a 10 11 12b 13, two beats each. +ENDINGS = ( + (10, 2), + (11, 2), + ({"number": 12, "label": "12a"}, 2), + (10, 2), + (11, 2), + ({"number": 12, "label": "12b"}, 2), + (13, 2), +) +# A cadenza that shares bar 2's number: 1, 2, 2c, 3. +CADENZA = ( + (1, 2), + (2, 2), + ({"number": 2, "label": "2c", "cadenza": True}, 2), + (3, 2), +) + + +class TestFind: + def test_a_number_finds_it_in_every_movement(self): + rows = time_map(*SONGS).find(1) + + assert [(row.count, row.movement) for row in rows] == [(1, 1), (4, 2)] + + def test_a_movement_narrows_it_to_that_movement(self): + songs = time_map(*SONGS) + + assert [row.count for row in songs.find(1, movement=2)] == [4] + assert [row.count for row in songs.find(1, movement=1)] == [1] + assert [row.count for row in songs.find(label="2", movement=2)] == [5] + assert songs.find(3, movement=2) == [] + + def test_passes_count_again_in_each_movement(self): + songs = time_map(*SONGS) + + assert [row.count for row in songs.find(1, pass_=1)] == [1, 4] + assert [row.count for row in songs.find(1, pass_=1, movement=2)] == [4] + assert songs.find(1, pass_=2) == [] + + def test_a_movement_alone_names_no_bar(self): + assert time_map(*SONGS).find(movement=1) == [] + + def test_a_label_finds_that_measure_and_a_number_every_one(self): + endings = time_map(*ENDINGS) + + assert [row.count for row in endings.find(label="12a")] == [3] + assert [row.label for row in endings.find(12)] == ["12a", "12b"] + + def test_a_number_and_a_pass_find_one_measure(self): + assert [row.count for row in time_map(*REPEATS).find(1, pass_=2)] == [4] + + def test_a_cadenza_is_found_by_its_label(self): + cadenza = time_map(*CADENZA) + + assert [row.label for row in cadenza.find(2)] == ["2"] + assert [row.label for row in cadenza.find(label="2c")] == ["2c"] + + +class TestTimesOf: + def test_every_pass_of_a_bar(self): + # US6, scenario 1 + assert time_map(*REPEATS).times_of(1) == [1.0, 7.0, 11.0] + + def test_a_pass_gives_that_time_through(self): + repeats = time_map(*REPEATS) + + assert repeats.times_of(1, pass_=2) == [7.0] + assert repeats.times_of(2, 0.5, pass_=2) == [10.0] + assert repeats.times_of(1, pass_=4) == [] + + def test_the_fraction_counts_beats(self): + # US6, scenario 2: a four-beat bar at 0.5 is its third beat; + # between beats the time is interpolated. + bars = time_map((1, 4), (2, 4)) + + assert bars.times_of(1, 0.5) == [3.0] + assert bars.times_of(1, 0.375) == [2.5] + assert bars.times_of(1, 0.875) == [4.5] + + def test_a_label_gives_that_measure_and_a_number_every_one(self): + # US6, scenario 3 + endings = time_map(*ENDINGS) + + assert endings.times_of(label="12a") == [5.0] + assert endings.times_of(12) == [5.0, 11.0] + assert endings.times_of(12, label="12b") == [11.0] + + def test_a_number_and_a_pass_in_each_movement_or_in_one(self): + # US6, scenario 4 + songs = time_map(*SONGS) + + assert songs.times_of(1, pass_=1) == [1.0, 7.0] + assert songs.times_of(1, pass_=1, movement=2) == [7.0] + assert songs.times_of(2, 0.5, movement=1) == [4.0] + assert songs.times_of(3, movement=2) == [] + + def test_a_cadenza_has_no_pass_and_is_found_by_its_label(self): + # US6, scenario 4 + cadenza = time_map(*CADENZA) + + assert cadenza.times_of(2) == [3.0] + assert cadenza.times_of(label="2c") == [5.0] + assert cadenza.times_of(label="2c", pass_=1) == [] + + def test_neither_a_number_nor_a_label_gives_no_time(self): + assert time_map(*REPEATS).times_of(fraction=0.5) == [] + assert time_map(*SONGS).times_of(movement=2) == [] + + @pytest.mark.parametrize("fraction", [-0.1, 1.5, float("nan")]) + def test_a_fraction_outside_0_to_1_gives_no_time_and_a_warning( + self, fraction, caplog + ): + with caplog.at_level(logging.WARNING, logger="tilia_core.timemap.lookup"): + assert time_map(*REPEATS).times_of(1, fraction) == [] + + assert "from 0 to 1" in caplog.text + + def test_a_fraction_on_a_beat_at_3_decimals_is_that_beat(self): + # TiLiA rounds to 3 decimals, so 0.333 is the second of three beats + # and 1/3 is too; a fraction that rounds elsewhere is interpolated. + bars = time_map((1, 3), (2, 1)) + + assert bars.times_of(1, 0.333) == [2.0] + assert bars.times_of(1, 1 / 3) == [2.0] + assert bars.times_of(1, 0.3336) == [pytest.approx(2.0008)] + + def test_between_beats_the_fraction_is_exact(self): + # Not TiLiA's 2.5005 today, which interpolates from 0.333 (#315) + bars = time_map((1, 3), (2, 1)) + + assert bars.times_of(1, 0.5) == [2.5] + assert bars.times_of(1, 1 / 6) == [1.5] + + def test_a_bar_that_does_not_exist_gives_no_time(self): + bars = time_map((1, 2), (2, 2), (4, 2), (5, 2)) + + assert bars.times_of(3) == [] + assert bars.times_of(3, 0.5) == [] + assert bars.times_of(3, segment_end=True) == [] + + def test_the_fraction_1_needs_the_next_bar_to_be_n_plus_1(self): + repeats = time_map(*REPEATS) + + assert repeats.times_of(1, 1.0) == [3.0, 9.0] # the last bar 1 ends the piece + assert repeats.times_of(3, 1.0) == [] # bar 1 follows it + + def test_the_fraction_1_as_a_segments_end_is_the_bars_end(self): + repeats = time_map(*REPEATS) + + assert repeats.times_of(3, 1.0, segment_end=True) == [7.0] + # the last bar 1 ends the piece: it lasts as long as the bar 2 before it + assert repeats.times_of(1, 1.0, segment_end=True) == [3.0, 9.0, 13.0] + + def test_past_the_last_beat_the_bar_before_gives_the_length(self): + # The last bar lasts as long as the bar before it in the timeline, + # whatever its number (TiLiA's projection), not FR-011's closing + # barline: two beats, in a bar 4 s long. + bars = time_map((1, 4), ({"number": 7}, 2)) + + assert bars.times_of(7, 0.75) == [7.5] + assert bars.times_of(7, 1.0, segment_end=True) == [9.0] + assert bars.times_of(7, 1.0) == [] + + def test_one_bar_alone_gives_no_time_past_its_last_beat(self): + # Nothing to project from, as in TiLiA + bars = time_map((1, 2)) + + assert bars.times_of(1, 0.25) == [1.5] + assert bars.times_of(1, 0.75) == [] + assert bars.times_of(1, 1.0, segment_end=True) == [] + + def test_a_pickup(self): + bars = time_map((0, 1), (1, 4), (2, 4), (3, 2)) + + assert bars.times_of(0) == [1.0] + assert bars.times_of(0, 0.5) == [1.5] + assert bars.times_of(0, 1.0) == [2.0] + assert bars.times_of(0, 1.0, segment_end=True) == [2.0] + + def test_a_segments_end_on_an_inner_beat_is_that_beat(self): + assert time_map(*REPEATS).times_of(2, 0.5, segment_end=True) == [4.0, 10.0] + + +class TestASegmentsEndOnADownbeat: + """As a segment's end, the fraction 0 of bar N also gives where each time + through the bar numbered just below it ends: an end on a downbeat belongs + to the bar before. With a pass, a label or a movement, an end that leads + into a bar N the query doesn't name is left out + (TimeLineAnnotator/project-management#321).""" + + def test_where_the_bar_before_ends_each_time(self): + repeats = time_map(*REPEATS) + + assert repeats.times_of(3, segment_end=True) == [5.0, 11.0] + assert repeats.times_of(3) == [5.0] + + def test_the_first_bar_has_no_bar_before(self): + assert time_map(*REPEATS).times_of(1, segment_end=True) == [1.0, 7.0, 11.0] + + def test_the_bar_before_is_the_number_just_below_that_the_table_has(self): + bars = time_map((1, 2), (2, 2), (4, 2), (5, 2)) + + assert bars.times_of(4, segment_end=True) == [5.0] + # the second bar 2 leads to bar 5, and ends where it starts + cut = time_map((1, 2), (2, 2), (4, 2), (1, 2), (2, 2), (5, 2)) + + assert cut.times_of(4, segment_end=True) == [5.0, 11.0] + + def test_a_label_leaves_out_the_ends_that_lead_into_another_bar_n(self): + endings = time_map(*ENDINGS) + + assert endings.times_of(12, segment_end=True) == [5.0, 11.0] + assert endings.times_of(label="12b", segment_end=True) == [11.0] + assert endings.times_of(label="12a", segment_end=True) == [5.0] + + def test_a_pass_gives_the_bar_befores_end_in_that_pass(self): + repeats = time_map(*REPEATS) + + assert repeats.times_of(3, pass_=1, segment_end=True) == [5.0] + assert repeats.times_of(3, pass_=2, segment_end=True) == [] + + def test_a_movement_gives_the_bar_befores_ends_in_that_movement(self): + songs = time_map(*SONGS) + + assert songs.times_of(2, segment_end=True) == [3.0, 9.0] + assert songs.times_of(2, movement=2, segment_end=True) == [9.0]