diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index 6d1dd4ac9..56ad100a2 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -1,15 +1,19 @@ -"""A beat timeline's measure table: each measure's position, pass, movement, start and end.""" +"""A beat timeline's measure table: each measure's position, pass, movement, +start and end, meter and time signature.""" from __future__ import annotations import logging import math from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field +from fractions import Fraction from typing import Any, NoReturn 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.units import MeasureMeter, format_units logger = logging.getLogger(__name__) @@ -71,11 +75,34 @@ class MeasureRow: end: float # the next downbeat, or the closing barline beat_ids: tuple[str, ...] beats: int # the beats tapped + meter: MeasureMeter # desktop#621's, from the beat units in effect + # 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 + ts_denominator: int = field(init=False) + 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 source: str force_display: bool next: tuple[str, ...] | None metadata: FrozenMetadata + def __post_init__(self) -> None: + meter = self.meter + computed = { + "time_signature": format_time_signature( + meter.beat_values, meter.denominator + ), + "ts_numerator": meter.numerator, + "ts_denominator": meter.denominator, + "assumed": meter.is_assumed, + "ambiguous": meter.is_ambiguous, + "conflicting": meter.is_conflicting, + } + for name, value in computed.items(): + object.__setattr__(self, name, value) + def build_rows(document: tla.Document, timeline: tla.Timeline) -> list[MeasureRow]: """The timeline's measure table, in its order; also for a folded table. @@ -145,7 +172,8 @@ def build_rows_from( passes: dict[int, int] = {} movement = 1 out: list[MeasureRow] = [] - for index, (row, end) in enumerate(zip(rows, ends, strict=True)): + 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 passes.clear() @@ -168,6 +196,7 @@ def build_rows_from( end=end, beat_ids=row.beat_ids, beats=len(row.beat_ids), + meter=meter, source=row.source, force_display=row.force_display, next=row.next, @@ -177,6 +206,24 @@ def build_rows_from( return out +def format_time_signature(beat_values: Sequence[Fraction], denominator: int) -> str: + """A measure's time signature as a musician reads it. + + The numerator is the sum of the beat values when they are all equal ("6/8"), + and the values joined by "+" when they differ, as they fall in the measure + ("2+2+3/8"; three taps under 2+3 give "2+3+2/8"). A numerator that isn't + whole, or holds a fraction, is in brackets: "(4/3)/4", "(1/2+1)/8". + """ + if len(set(beat_values)) > 1: + parts = list(beat_values) + else: + parts = [sum(beat_values, Fraction(0))] + numerator = format_units(parts) + if any(part.denominator != 1 for part in parts): + numerator = f"({numerator})" + return f"{numerator}/{denominator}" + + def is_folded(rows: Sequence[RowIn]) -> bool: """Whether the table lists the measures played next: a score in written order.""" return any(row.next is not None for row in rows) diff --git a/packages/tilia-core/tests/timemap/shared_examples.json b/packages/tilia-core/tests/timemap/shared_examples.json new file mode 100644 index 000000000..b2c9f510f --- /dev/null +++ b/packages/tilia-core/tests/timemap/shared_examples.json @@ -0,0 +1,376 @@ +{ + "meters": [ + { + "name": "the default unit", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1", "assumed": true}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0}, + {"time": 3.5}, + {"time": 4.0, "measure": {}}, + {"time": 4.5}, + {"time": 5.0} + ], + "expected": { + "meters": [ + {"time_signature": "4/4", "beat_values": ["1", "1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "4/4", "beat_values": ["1", "1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] + } + }, + { + "name": "a pickup", + "beats": [ + {"time": 0.0, "measure": {"number": 0}, "beat_unit": {"denominator": 4, "units": "1"}}, + {"time": 0.5, "measure": {}}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0} + ], + "expected": { + "meters": [ + {"time_signature": "1/4", "beat_values": ["1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "5", "6"] + } + }, + { + "name": "6/8 tapped in two, before its unit is set", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1", "assumed": true}}, + {"time": 0.5}, + {"time": 1.0, "measure": {}}, + {"time": 1.5} + ], + "expected": { + "meters": [ + {"time_signature": "2/4", "beat_values": ["1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "2/4", "beat_values": ["1", "1"], "assumed": true, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3"] + } + }, + { + "name": "6/8 tapped in two, after a 3/8 pickup", + "beats": [ + {"time": 0.0, "measure": {"number": 0}, "beat_unit": {"denominator": 8, "units": "3"}}, + {"time": 0.6, "measure": {}}, + {"time": 1.2}, + {"time": 1.8, "measure": {}}, + {"time": 2.4} + ], + "expected": { + "meters": [ + {"time_signature": "3/8", "beat_values": ["3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "3/2", "3", "9/2", "6"] + } + }, + { + "name": "6/8 tapped in six", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 8, "units": "1"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0}, + {"time": 2.5}, + {"time": 3.0, "measure": {}}, + {"time": 3.5}, + {"time": 4.0}, + {"time": 4.5}, + {"time": 5.0}, + {"time": 5.5} + ], + "expected": { + "meters": [ + {"time_signature": "6/8", "beat_values": ["1", "1", "1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["1", "1", "1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1/2", "1", "3/2", "2", "5/2", "3", "7/2", "4", "9/2", "5", "11/2"] + } + }, + { + "name": "2+3 over two taps", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 8, "units": "2+3"}}, + {"time": 0.5}, + {"time": 1.0, "measure": {}}, + {"time": 1.5} + ], + "expected": { + "meters": [ + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "5/2", "7/2"] + } + }, + { + "name": "1/2: eighths tapped in 2/4", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1/2"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0}, + {"time": 3.5}, + {"time": 4.0, "measure": {}}, + {"time": 4.5}, + {"time": 5.0} + ], + "expected": { + "meters": [ + {"time_signature": "2/4", "beat_values": ["1/2", "1/2", "1/2", "1/2"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2/4", "beat_values": ["1/2", "1/2", "1/2", "1/2"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "(3/2)/4", "beat_values": ["1/2", "1/2", "1/2"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1/2", "1", "3/2", "2", "5/2", "3", "7/2", "4", "9/2", "5"] + } + }, + { + "name": "a unit changing in mid-piece", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0}, + {"time": 3.5}, + {"time": 4.0, "measure": {}, "beat_unit": {"denominator": 8, "units": "3"}}, + {"time": 4.5}, + {"time": 5.0, "measure": {}}, + {"time": 5.5} + ], + "expected": { + "meters": [ + {"time_signature": "4/4", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "4/4", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "19/2", "11", "25/2"] + } + }, + { + "name": "a unit set on a later beat of its measure", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5, "measure": {}}, + {"time": 2.0}, + {"time": 2.5, "beat_unit": {"denominator": 8, "units": "2"}}, + {"time": 3.0, "measure": {}}, + {"time": 3.5}, + {"time": 4.0} + ], + "expected": { + "meters": [ + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["2", "2", "2"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["2", "2", "2"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "5", "6", "7", "8"] + } + }, + { + "name": "two units in one measure", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5, "beat_unit": {"denominator": 8, "units": "1"}}, + {"time": 3.0}, + {"time": 3.5, "beat_unit": {"denominator": 2, "units": "1"}}, + {"time": 4.0, "measure": {}}, + {"time": 4.5}, + {"time": 5.0}, + {"time": 5.5} + ], + "expected": { + "meters": [ + {"time_signature": "4/4", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "4/8", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": true}, + {"time_signature": "4/8", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "9/2", "5", "11/2", "6", "13/2", "7", "15/2"] + } + }, + { + "name": "an ambiguous fit: three taps under 2+3", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 8, "units": "2+3"}}, + {"time": 0.5}, + {"time": 1.0, "measure": {}}, + {"time": 1.5}, + {"time": 2.0} + ], + "expected": { + "meters": [ + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2+3+2/8", "beat_values": ["2", "3", "2"], "assumed": false, "ambiguous": true, "conflicting": false} + ], + "qstamps": ["0", "1", "5/2", "7/2", "5"] + } + }, + { + "name": "a pickup holding the end of a pattern", + "beats": [ + {"time": 0.0, "measure": {"number": 0}, "beat_unit": {"denominator": 8, "units": "3"}}, + {"time": 0.5, "measure": {}, "beat_unit": {"denominator": 8, "units": "2+3"}}, + {"time": 1.0}, + {"time": 1.5, "measure": {}}, + {"time": 2.0} + ], + "expected": { + "meters": [ + {"time_signature": "3/8", "beat_values": ["3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "3/2", "5/2", "4", "5"] + } + }, + { + "name": "a pickup under 2+3, without a unit of its own", + "beats": [ + {"time": 0.0, "measure": {"number": 0}, "beat_unit": {"denominator": 8, "units": "2+3"}}, + {"time": 0.5, "measure": {}}, + {"time": 1.0}, + {"time": 1.5, "measure": {}}, + {"time": 2.0} + ], + "expected": { + "meters": [ + {"time_signature": "2/8", "beat_values": ["2"], "assumed": false, "ambiguous": true, "conflicting": false}, + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "2+3/8", "beat_values": ["2", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "7/2", "9/2"] + } + }, + { + "name": "a numerator that isn't whole: 1/3 over four taps", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 4, "units": "1/3"}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0} + ], + "expected": { + "meters": [ + {"time_signature": "(4/3)/4", "beat_values": ["1/3", "1/3", "1/3", "1/3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "1/4", "beat_values": ["1/3", "1/3", "1/3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1/3", "2/3", "1", "4/3", "5/3", "2"] + } + }, + { + "name": "no unit in the first measures, and one in a later measure", + "beats": [ + {"time": 0.0, "measure": {"number": 1}}, + {"time": 0.5}, + {"time": 1.0, "measure": {}}, + {"time": 1.5}, + {"time": 2.0, "measure": {}, "beat_unit": {"denominator": 8, "units": "3"}}, + {"time": 2.5}, + {"time": 3.0, "measure": {}}, + {"time": 3.5} + ], + "expected": { + "meters": [ + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "3/2", "3", "9/2", "6", "15/2", "9", "21/2"] + } + }, + { + "name": "no unit at all", + "beats": [ + {"time": 0.0, "measure": {"number": 1}}, + {"time": 0.5}, + {"time": 1.0}, + {"time": 1.5, "measure": {}}, + {"time": 2.0}, + {"time": 2.5}, + {"time": 3.0, "measure": {}}, + {"time": 3.5} + ], + "expected": { + "meters": [ + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "2/4", "beat_values": ["1", "1"], "assumed": true, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + { + "name": "a unit on a later beat of the first measure", + "beats": [ + {"time": 0.0, "measure": {"number": 1}}, + {"time": 0.5, "beat_unit": {"denominator": 8, "units": "1"}}, + {"time": 1.0}, + {"time": 1.5}, + {"time": 2.0, "measure": {}}, + {"time": 2.5}, + {"time": 3.0}, + {"time": 3.5} + ], + "expected": { + "meters": [ + {"time_signature": "4/8", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "4/8", "beat_values": ["1", "1", "1", "1"], "assumed": false, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "1/2", "1", "3/2", "2", "5/2", "3", "7/2"] + } + }, + { + "name": "an assumed unit in mid-piece", + "beats": [ + {"time": 0.0, "measure": {"number": 1}, "beat_unit": {"denominator": 8, "units": "3"}}, + {"time": 0.5}, + {"time": 1.0, "measure": {}, "beat_unit": {"denominator": 4, "units": "1", "assumed": true}}, + {"time": 1.5}, + {"time": 2.0}, + {"time": 2.5, "measure": {}}, + {"time": 3.0}, + {"time": 3.5} + ], + "expected": { + "meters": [ + {"time_signature": "6/8", "beat_values": ["3", "3"], "assumed": false, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false}, + {"time_signature": "3/4", "beat_values": ["1", "1", "1"], "assumed": true, "ambiguous": false, "conflicting": false} + ], + "qstamps": ["0", "3/2", "3", "4", "5", "6", "7", "8"] + } + } + ], + "times_of": [] +} diff --git a/packages/tilia-core/tests/timemap/test_timemap_shared.py b/packages/tilia-core/tests/timemap/test_timemap_shared.py new file mode 100644 index 000000000..32ed1257f --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_shared.py @@ -0,0 +1,100 @@ +"""The shared examples: beat timelines with the meters and quarter stamps that +desktop#621's code gives them, for the core's tests and the app's. The beat +values, flags and quarter stamps are computed by running #621's code, never +written by hand. #621 has no text form of a time signature: `time_signature` +is its values written by format_time_signature, which this file can't check +against itself; test_timemap_table.py checks it against texts written by hand. + +A case that passes on one side only means the two copies of the rules differ: +fix one side, never the expected values alone. +""" + +import json +from fractions import Fraction +from pathlib import Path + +import pytest + +from docs import beat_timeline, document +from tilia_core.timemap import time_map_for +from tilia_core.timemap.table import build_rows + +EXAMPLES = json.loads( + Path(__file__).with_name("shared_examples.json").read_text(encoding="utf-8") +) +METERS = [pytest.param(case, id=case["name"]) for case in EXAMPLES["meters"]] + +# The cases the spec asks for, by the names the file gives them. +REQUIRED = [ + "the default unit", + "a pickup", + "6/8 tapped in two, before its unit is set", + "6/8 tapped in two, after a 3/8 pickup", + "6/8 tapped in six", + "2+3 over two taps", + "1/2: eighths tapped in 2/4", + "a unit changing in mid-piece", + "a unit set on a later beat of its measure", + "two units in one measure", + "an ambiguous fit: three taps under 2+3", + "a pickup holding the end of a pattern", + "a numerator that isn't whole: 1/3 over four taps", + "no unit in the first measures, and one in a later measure", + "no unit at all", +] + + +def test_the_file_holds_every_case_the_spec_asks_for(): + names = [case["name"] for case in EXAMPLES["meters"]] + + assert [name for name in REQUIRED if name not in names] == [] + assert len(set(names)) == len(names) + + +@pytest.mark.parametrize("case", METERS) +def test_each_case_is_laid_out_as_the_contract_says(case): + beats = case["beats"] + times = [beat["time"] for beat in beats] + + assert beats[0].get("measure") is not None + assert times == sorted(set(times)) + assert len(case["expected"]["meters"]) == sum( + beat.get("measure") is not None for beat in beats + ) + assert len(case["expected"]["qstamps"]) == len(beats) + + +@pytest.mark.parametrize("case", METERS) +def test_meters(case): + timeline = beat_timeline(case["beats"]) + rows = build_rows(document(timeline), timeline) + + assert [_meter(row) for row in rows] == [ + {**meter, "beat_values": [Fraction(value) for value in meter["beat_values"]]} + for meter in case["expected"]["meters"] + ] + + +def _meter(row): + return { + "time_signature": row.time_signature, + "beat_values": list(row.meter.beat_values), + "assumed": row.assumed, + "ambiguous": row.ambiguous, + "conflicting": row.conflicting, + } + + +@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): + timeline = beat_timeline(case["beats"]) + time_map = time_map_for(document(timeline), timeline.id) + + assert [time_map.qstamp(beat["time"]) for beat in case["beats"]] == [ + float(Fraction(qstamp)) for qstamp in case["expected"]["qstamps"] + ] diff --git a/packages/tilia-core/tests/timemap/test_timemap_table.py b/packages/tilia-core/tests/timemap/test_timemap_table.py index 6572c35d6..c5019ed11 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_table.py +++ b/packages/tilia-core/tests/timemap/test_timemap_table.py @@ -3,12 +3,16 @@ import json import logging import pickle +from fractions import Fraction as F import pytest from docs import beat_timeline, document, pickup_piece from tilia_core import tla -from tilia_core.timemap.table import build_rows, find_rows +from tilia_core.timemap import time_map_for +from tilia_core.timemap._rows import rows_of +from tilia_core.timemap.meters import meters_of +from tilia_core.timemap.table import build_rows, find_rows, format_time_signature def _timeline(marks: list[dict], beats: int = 2) -> tla.Timeline: @@ -283,9 +287,14 @@ def test_rows_can_be_pickled_copied_and_turned_into_dicts(): for protocol in range(pickle.HIGHEST_PROTOCOL + 1): assert pickle.loads(pickle.dumps(row, protocol)) == row, protocol assert copy.deepcopy(row) == row + assert hash(copy.deepcopy(row)) == hash(row) as_dict = dataclasses.asdict(row) assert as_dict["metadata"] == {"tags": ("a",), "key": "x"} - assert json.loads(json.dumps(as_dict))["metadata"] == {"tags": ["a"], "key": "x"} + # Fractions, exact, aren't JSON numbers: they go as text, "3/2". + as_json = json.loads(json.dumps(as_dict, default=str)) + assert as_json["metadata"] == {"tags": ["a"], "key": "x"} + assert as_json["ts_numerator"] == "2" + assert as_json["meter"]["beat_values"] == ["1", "1"] def test_a_timeline_without_measures_gives_no_rows(): @@ -335,3 +344,241 @@ def test_a_number_the_table_lacks_finds_nothing(self): def test_no_number_and_no_label_finds_nothing(self): assert find_rows(_rows(ENDINGS)) == [] assert find_rows(_rows(ENDINGS), pass_=1) == [] + + +# Time signatures: each row's meter, by desktop#621's rules. + +# What TiLiA puts on the first beat, and WP3's migration on an old file's. +DEFAULT = {"denominator": 4, "units": "1", "assumed": True} + + +def unit(denominator, units, assumed=False): + return {"denominator": denominator, "units": units, "assumed": assumed} + + +def taps(count, beat_unit=None, *, on_beat=1): + """The units set on a measure's `count` taps: `beat_unit` on beat `on_beat`.""" + units = [None] * count + units[on_beat - 1] = beat_unit + return units + + +def _metered_timeline(measures, first_number=1): + """One measure per list of the units set on its beats, a beat every second from 0.""" + beats = [] + for j, units in enumerate(measures): + for b, beat_unit in enumerate(units): + mark = None if b else ({"number": first_number} if j == 0 else {}) + beats.append( + {"time": float(len(beats)), "measure": mark, "beat_unit": beat_unit} + ) + return beat_timeline(beats) + + +def _metered(measures, first_number=1): + timeline = _metered_timeline(measures, first_number) + return build_rows(document(timeline), timeline) + + +def _quarters(row): + return [row.meter.quarter_length(value) for value in row.meter.beat_values] + + +@pytest.mark.parametrize( + "beat_unit, count, time_signature, ts_numerator, ts_denominator, ambiguous", + [ + (unit(8, "3"), 2, "6/8", 6, 8, False), + (unit(8, "1"), 7, "7/8", 7, 8, False), + (unit(8, "2+2+3"), 3, "2+2+3/8", 7, 8, False), + (unit(8, "2+3"), 3, "2+3+2/8", 7, 8, True), + (unit(4, "1/3"), 4, "(4/3)/4", F(4, 3), 4, False), + (unit(8, "1/2+1"), 2, "(1/2+1)/8", F(3, 2), 8, False), + ], +) +def test_a_time_signature_as_text_and_as_numbers( + beat_unit, count, time_signature, ts_numerator, ts_denominator, ambiguous +): + # The table of TimeLineAnnotator/project-management#193 (how the index shows + # time signatures). + (row,) = _metered([taps(count, beat_unit)]) + + assert row.time_signature == time_signature + assert (row.ts_numerator, row.ts_denominator) == (ts_numerator, ts_denominator) + assert type(row.ts_numerator) is F + assert type(row.ts_denominator) is int + assert row.ambiguous == ambiguous + + +@pytest.mark.parametrize( + "beat_values, denominator, text", + [ + # All equal: their sum, when it is whole. + ([3, 3], 8, "6/8"), + ([1] * 7, 8, "7/8"), + ([3], 8, "3/8"), + ([F(1, 2)] * 4, 4, "2/4"), + # They differ: joined by "+", as they fall in the bar. + ([2, 2, 3], 8, "2+2+3/8"), + ([2, 3, 2], 8, "2+3+2/8"), + ([3, 2], 8, "3+2/8"), + # A numerator that isn't whole, or holds a fraction: in brackets. + ([F(1, 3)] * 4, 4, "(4/3)/4"), + ([F(1, 2)] * 3, 4, "(3/2)/4"), + ([F(1, 2)], 2, "(1/2)/2"), + ([F(1, 2), 1], 8, "(1/2+1)/8"), + ([1, F(3, 2)], 4, "(1+3/2)/4"), + ], +) +def test_format_time_signature(beat_values, denominator, text): + assert format_time_signature([F(v) for v in beat_values], denominator) == text + + +def test_timemap_six_eight_in_two(): + # Spec, US2 scenarios 1 and 2: two taps with only the default beat unit + # read 2/4, assumed; with {8, "3"} on the downbeat they read 6/8, counted + # in two beats of a dotted quarter. + (tapped,) = _metered([taps(2, DEFAULT)]) + + assert tapped.time_signature == "2/4" + assert tapped.assumed + + timeline = _metered_timeline([taps(2, unit(8, "3"))]) + doc = document(timeline) + (row,) = build_rows(doc, timeline) + + assert row.time_signature == "6/8" + assert (row.ts_numerator, row.ts_denominator) == (6, 8) + assert not row.assumed + time_map = time_map_for(doc, timeline.id) + assert [time_map.positions(t, t).beat for t in (0.0, 1.0)] == [1, 2] + assert _quarters(row) == [F(3, 2), F(3, 2)] + + +def test_two_plus_three_on_two_taps_is_five_eighths(): + # Spec, US2 scenario 3. + (row,) = _metered([taps(2, unit(8, "2+3"))]) + + assert row.time_signature == "2+3/8" + assert (row.ts_numerator, row.ts_denominator) == (5, 8) + assert _quarters(row) == [1, F(3, 2)] + assert not row.ambiguous + + +def test_a_unit_on_a_later_beat_applies_from_its_measure_until_the_next(): + # Spec, US2 scenario 4: set on the third beat of measure 10. + measures = [ + taps(3, unit(4, "1")), + *[taps(3)] * 8, + taps(3, unit(8, "2"), on_beat=3), + taps(3), + taps(3, unit(4, "1")), + ] + rows = _metered(measures) + + assert [r.number for r in rows] == list(range(1, 13)) + assert [r.time_signature for r in rows] == ["3/4"] * 9 + ["6/8", "6/8", "3/4"] + assert rows[9].meter.beat_unit_id == rows[9].beat_ids[2] + assert rows[10].meter.beat_unit_id == rows[9].beat_ids[2] + + +def test_of_two_units_in_a_measure_the_first_governs_it_and_those_after(): + # Spec, US2 scenario 5. + second = taps(4, unit(8, "1"), on_beat=2) + second[3] = unit(2, "1") + rows = _metered([taps(4, unit(4, "1")), second, taps(4)]) + + assert [r.time_signature for r in rows] == ["4/4", "4/8", "4/8"] + assert [r.conflicting for r in rows] == [False, True, False] + assert rows[2].meter.beat_unit_id == rows[1].beat_ids[1] + + +def test_three_taps_under_two_plus_three_are_ambiguous(): + # Spec, US2 scenario 6. + (row,) = _metered([taps(3, unit(8, "2+3"))]) + + assert row.meter.beat_values == (2, 3, 2) + assert row.time_signature == "2+3+2/8" + assert (row.ts_numerator, row.ts_denominator) == (7, 8) + assert row.ambiguous + + +def test_a_pickup_of_one_tap_under_three_eighths(): + # Spec, US2 scenario 7. + rows = _metered([taps(1, unit(8, "3")), taps(2)], first_number=0) + + assert [(r.number, r.time_signature) for r in rows] == [(0, "3/8"), (1, "6/8")] + + +def test_a_numerator_that_isnt_whole(): + # Spec, US2 scenario 8: TiLiA shows "1 1/3" over 4. + (row,) = _metered([taps(4, unit(4, "1/3"))]) + + assert row.ts_numerator == F(4, 3) + assert row.time_signature == "(4/3)/4" + + +def test_each_row_has_its_meter_and_the_meter_s_flags(): + # The meter is meters_of's; the row's flags and numbers are its. + timeline = _metered_timeline( + [ + taps(2, unit(8, "2+3")), + taps(3), + [unit(4, "1"), None, unit(2, "1")], + taps(2, unit(8, "3", assumed=True)), + ] + ) + rows = build_rows(document(timeline), timeline) + + assert [r.meter for r in rows] == meters_of(rows_of(timeline)) + assert [(r.assumed, r.ambiguous, r.conflicting) for r in rows] == [ + (False, False, False), + (False, True, False), + (False, False, True), + (True, False, False), + ] + assert [(r.ts_numerator, r.ts_denominator) for r in rows] == [ + r.meter.time_signature for r in rows + ] + assert [r.time_signature for r in rows] == ["2+3/8", "2+3+2/8", "3/4", "6/8"] + + +DERIVED = ( + "time_signature", + "ts_numerator", + "ts_denominator", + "assumed", + "ambiguous", + "conflicting", +) + + +def test_a_row_s_time_signature_and_flags_are_computed_from_its_meter(): + # When the row is made, so nothing can make them disagree with the meter. + six_eight, three_four = _metered( + [taps(2, unit(8, "3")), [unit(4, "1", assumed=True), None, unit(2, "1")]] + ) + + moved = dataclasses.replace(six_eight, meter=three_four.meter) + + assert [getattr(moved, name) for name in DERIVED] == [ + "3/4", + 3, + 4, + True, + False, + True, + ] + for name in DERIVED: + # init=False fields: ValueError before Python 3.13, TypeError from it on + with pytest.raises((ValueError, TypeError)): + dataclasses.replace(six_eight, **{name: getattr(three_four, name)}) + + +def test_a_folded_table_has_its_time_signatures(): + # A folded table has no positions yet, but its rows' other values are computed. + timeline = _metered_timeline([taps(2, unit(8, "3")), taps(3)]) + timeline.measures.rows[1].next = [timeline.measures.rows[0].id] + rows = build_rows(document(timeline), timeline) + + assert [r.folded for r in rows] == [True, True] + assert [r.time_signature for r in rows] == ["6/8", "9/8"]