Skip to content

feat(core): find bars, and the times of a bar and a fraction of it - #752

Open
azfoo wants to merge 2 commits into
v1-prereleasefrom
v1/218-timemap-lookup
Open

azfoo wants to merge 2 commits into
v1-prereleasefrom
v1/218-timemap-lookup

Conversation

@azfoo

@azfoo azfoo commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

Finds bars by number, label, pass and movement, and gives the times of a bar and a fraction of it: a port of BeatTimeline.get_time_by_measure for imports by measure, with one time per pass (TimeLineAnnotator/project-management#315 (one time per pass)).

What changed

  • table.py: find_rows takes movement. Without one, every movement matches; with one, only that movement's rows.
  • TimeMap.find(number=None, *, label=None, pass_=None, movement=None): find_rows over the time map's rows.
  • TimeMap.times_of(number=None, fraction=0.0, *, label=None, pass_=None, movement=None, segment_end=False), in the new lookup.py. get_time_by_measure looks among every pass's beats at once. times_of gives one time for each measure that find names:
    • The fraction counts the measure's own beats, and between two beats the time is interpolated. A fraction that matches a beat's at 3 decimals is that beat, as TiLiA rounds: 0.333 is the second of three beats. Between beats the fraction is exact: a three-beat bar at 0.5 gives 2.5, where TiLiA's rounded beats give 2.5005 today.
    • The fraction 1 gives the next downbeat when the next bar is numbered one more, and otherwise no time. As a segment's end, it gives the measure's end.
    • Past the last beat, the last measure lasts as long as the measure before it in the timeline, as TiLiA projects it, not until the closing barline.
    • As a segment's end, the fraction 0 of bar N also gives where each time through the bar numbered just below N ends, in the pass and movement asked for, as is_segment_end does. An end that leads into a bar N the query doesn't name is left out. This is TimeLineAnnotator/project-management#321 (segment ends with a pass, label or movement)'s answer, option B. Without a pass, a label or a movement, it changes nothing.
    • A bar that no measure names gives no time. A fraction outside 0 to 1 gives no time, with a warning.
  • shared_examples.json: 31 times_of cases. The meter cases are unchanged.
    • 25 come from the app's tests that call get_time_by_measure on two beats or more: one case per assertion, with the test's times.
    • One is a segment's end on a downbeat, which dev gives today.
    • Five are Test permissions for building paper #315's cases, which give one time per pass. Dev gives other times for these until TimeLineAnnotator/project-management#318 (get_time_by_measure per pass) fixes it.
  • test_timemap_lookup.py: runs the shared cases and checks their layout, and tests labels, passes, movements, cadenzas and the rules above.

Two of the app's tests have no case. test_get_times_by_measure_smaller_than_min_measure_number uses a timeline with one beat, and the core has no time map for fewer than two beats. In test_get_times_by_measure_no_beats, TiLiA raises and the core has no time map, so neither gives a time.

How the expected times were checked

Each case's times come from the app's test, or from #315 for its five cases. check_times_of.py loads each case into dev's BeatTimeline as TiLiA loads a file: create_timeline with the beats as components, and beats_in_measure and measure_numbers from the marks, so deserialize_components runs. It then runs get_time_by_measure. On dev at a53083ba, 26 cases give the same times, and #315's five give exactly the times #315 lists for dev (for example, the last bar at 0.5 gives [6.25, 7.25, 8.33], not [7.0]). The script ran in a detached worktree of dev, with this branch's environment, and left it unchanged:

cd <worktree of desktop at dev>
SHARED_EXAMPLES=<this branch>/packages/tilia-core/tests/timemap/shared_examples.json \
ENVIRONMENT=test QT_QPA_PLATFORM=offscreen PYTHONDONTWRITEBYTECODE=1 \
<this branch>/.venv/Scripts/python.exe -m pytest -p tests.conftest -p no:cacheprovider -q -s check_times_of.py
check_times_of.py
"""Runs dev's BeatTimeline.get_time_by_measure on each times_of case of
shared_examples.json, loaded as TiLiA loads a file, and compares the times
with the case's expected ones (which come from the app's tests).

Each case's beats become a BeatTimeline through Timelines.create_timeline,
with the beats as components and beats_in_measure and measure_numbers from
the marks, so deserialize_components runs, as opening a file does. Dev has no
beat units; a case with one is refused.

Run it with the app's test fixtures (which keep TiLiA's settings in a
temporary store), from a checkout of dev, in an environment with the app's
dependencies:

    cd <checkout of desktop at dev>
    SHARED_EXAMPLES=<path to shared_examples.json> ENVIRONMENT=test \
    QT_QPA_PLATFORM=offscreen PYTHONDONTWRITEBYTECODE=1 \
    python -m pytest -p tests.conftest -p no:cacheprovider -q -s <this file>
"""

import json
import math
import os
from pathlib import Path

import tilia
from tilia.requests import Get, get
from tilia.timelines.beat.timeline import BeatTimeline

EXAMPLES = Path(os.environ["SHARED_EXAMPLES"])

# TimeLineAnnotator/project-management#315's cases: one time per pass, which
# dev gives only once #318 has fixed get_time_by_measure.
UNTIL_318 = {
    "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_times_of_cases(tls):
    # The app imported is the checkout's, not one installed elsewhere.
    assert Path(tilia.__file__).resolve().parent.parent == Path.cwd().resolve()

    examples = json.loads(EXAMPLES.read_text(encoding="utf-8"))
    differences = []
    for case in examples["times_of"]:
        timeline = load(tls, case["beats"])
        query = case["query"]
        times = timeline.get_time_by_measure(
            query["number"], query["fraction"], query["segment_end"]
        )
        tls.delete_timeline(timeline)
        same = len(times) == len(case["expected"]) and all(
            math.isclose(a, b) for a, b in zip(times, case["expected"])
        )
        print(f"{'ok  ' if same else 'DIFF'} {case['name']}: {times}")
        if not same:
            differences.append(case["name"])
    assert set(differences) <= UNTIL_318


def load(tls, beats):
    """A BeatTimeline with these beats, loaded as TiLiA loads a file."""
    assert all("beat_unit" not in beat for beat in beats)
    components = {get(Get.ID): {"kind": "BEAT", "time": beat["time"]} for beat in beats}

    starts = [i for i, beat in enumerate(beats) if beat.get("measure") is not None]
    beats_in_measure = [b - a for a, b in zip(starts, starts[1:] + [len(beats)])]
    measure_numbers = []
    for start in starts:
        previous = measure_numbers[-1] + 1 if measure_numbers else 1
        measure_numbers.append(beats[start]["measure"].get("number", previous))

    timeline = tls.create_timeline(
        BeatTimeline,
        components,
        beats_in_measure=beats_in_measure,
        measure_numbers=measure_numbers,
    )
    # TiLiA built what the case describes.
    assert [beat.time for beat in timeline] == [beat["time"] for beat in beats]
    assert timeline.beats_in_measure == beats_in_measure
    assert timeline.measure_numbers == measure_numbers
    return timeline
add_times_of_cases.py, which wrote the cases into the file in its layout
"""Writes the times_of cases into shared_examples.json, transcribed from the
app's tests of get_time_by_measure (tests/timelines/beat/test_beat_timeline.py
on dev). The meter cases are kept as they are; the file keeps its layout.

    python add_times_of_cases.py <path to shared_examples.json>
"""

import json
import sys
from pathlib import Path


def beats(times, marks):
    """Beats at `times`; `marks` maps a time to its measure mark."""
    out = []
    for t in times:
        beat = {"time": float(t)}
        if t in marks:
            beat["measure"] = marks[t]
        out.append(beat)
    return out


def bars(numbers_and_beats):
    """A beat every second from 1 s, in bars of (number, beats)."""
    out, t = [], 1
    for number, count in numbers_and_beats:
        for k in range(count):
            beat = {"time": float(t)}
            if k == 0:
                beat["measure"] = {"number": number}
            out.append(beat)
            t += 1
    return out


def case(name, beat_list, number, fraction, expected, segment_end=False):
    return {
        "name": name,
        "beats": beat_list,
        "query": {"number": number, "fraction": fraction, "segment_end": segment_end},
        "expected": [float(t) for t in expected],
    }


# test_get_times_by_measure_one_measure: beat_pattern [2], beats at 1 and 2.
ONE_MEASURE = beats([1, 2], {1: {"number": 1}})
# ..._one_measures_repeating_decimal: beat_pattern [3], beats at 1-4.
THREE_THEN_ONE = beats([1, 2, 3, 4], {1: {"number": 1}, 4: {}})
# ..._multiple_measures: beat_pattern [2], beats at 1-6.
THREE_BARS_OF_TWO = beats(range(1, 7), {1: {"number": 1}, 3: {}, 5: {}})
# ..._uneven_beats_time_at_beats: beat_pattern [4], beats at 1, 3, 7, 8, 9.
UNEVEN_AT = beats([1, 3, 7, 8, 9], {1: {"number": 1}, 9: {}})
# ..._fraction_is_1 and test_get_time_between_last_beat_of_measure_and_first_beat_of_next_measure:
# beat_pattern [2], beats at 1-3.
TWO_THEN_ONE = beats([1, 2, 3], {1: {"number": 1}, 3: {}})
# ..._uneven_beats_time_in_between_beats: beat_pattern [4], beats at 1, 3, 4, 5, 6.
UNEVEN_BETWEEN = beats([1, 3, 4, 5, 6], {1: {"number": 1}, 6: {}})
# ..._index_bigger_than_max_measure: beat_pattern [4], beats at 1 and 2.
TWO_BEATS = beats([1, 2], {1: {"number": 1}})
# test_get_times_by_fraction_in_repeat_measure_number: beat_pattern [2],
# beats at 1-12, measure numbers 1, 2, 3, 1, 2, 1.
REPEATS = beats(
    range(1, 13),
    {1: {"number": 1}, 3: {}, 5: {}, 7: {"number": 1}, 9: {}, 11: {"number": 1}},
)

CASES = [
    case("one bar: bar 1", ONE_MEASURE, 1, 0.0, [1]),
    case("three beats, then one: bar 1", THREE_THEN_ONE, 1, 0.0, [1]),
    case("three beats, then one: bar 1 at 0.333, its second beat", THREE_THEN_ONE, 1, 0.333, [2]),
    case("three beats, then one: bar 1 at 0.667, its third beat", THREE_THEN_ONE, 1, 0.667, [3]),
    case("three bars: bar 1", THREE_BARS_OF_TWO, 1, 0.0, [1]),
    case("three bars: bar 2", THREE_BARS_OF_TWO, 2, 0.0, [3]),
    case("three bars: bar 3", THREE_BARS_OF_TWO, 3, 0.0, [5]),
    case("uneven beats: bar 1 at 0", UNEVEN_AT, 1, 0.0, [1]),
    case("uneven beats: bar 1 at 0.25, its second beat", UNEVEN_AT, 1, 0.25, [3]),
    case("uneven beats: bar 1 at 0.5, its third beat", UNEVEN_AT, 1, 0.5, [7]),
    case("uneven beats: bar 1 at 0.75, its fourth beat", UNEVEN_AT, 1, 0.75, [8]),
    case("the fraction 1: the next bar's downbeat", TWO_THEN_ONE, 1, 1.0, [3]),
    case("uneven beats: bar 1 at 0.125, between its first two beats", UNEVEN_BETWEEN, 1, 0.125, [2]),
    case("uneven beats: bar 1 at 0.375, between its second and third", UNEVEN_BETWEEN, 1, 0.375, [3.5]),
    case("uneven beats: bar 1 at 0.625, between its third and fourth", UNEVEN_BETWEEN, 1, 0.625, [4.5]),
    case("between a bar's last beat and the next downbeat", TWO_THEN_ONE, 1, 0.75, [2.5]),
    case("a bar after the last: none", TWO_BEATS, 2, 0.0, []),
    case("a bar far after the last: none", TWO_BEATS, 999, 0.0, []),
    case("bar 1 played three times", REPEATS, 1, 0.0, [1, 7, 11]),
    case("bar 2 played twice, at 0.5", REPEATS, 2, 0.5, [4, 10]),
    case("bar 2 played twice, at 0.75", REPEATS, 2, 0.75, [4.5, 10.5]),
    case("bar 3 played once, at 0.5", REPEATS, 3, 0.5, [6]),
    case("bar 3 played once, at 0.75", REPEATS, 3, 0.75, [6.5]),
    case("the end of bar 3, no bar 4: none", REPEATS, 3, 1.0, []),
    case("the end of bar 3, no bar 4, as a segment's end", REPEATS, 3, 1.0, [7], segment_end=True),
    # As a segment's end, the fraction 0 also gives where the bar before ends,
    # each time through (dev gives 5 and 11).
    case("a segment's end on a downbeat: where bar 2 ends, each time", REPEATS, 3, 0.0, [5, 11], segment_end=True),
    # TimeLineAnnotator/project-management#315's five cases: one time per pass.
    case("the last bar, between its beats", bars([(1, 4), (2, 4)]), 2, 0.5, [7]),
    case(
        "passes of four and three beats",
        bars([(1, 4), (2, 4), (1, 3), (2, 4), (3, 2)]),
        1, 0.5, [3, 10.5],
    ),
    case("a bar that doesn't exist: none", bars([(1, 2), (2, 2), (4, 2), (5, 2)]), 3, 0.0, []),
    case(
        "the fraction 1: the downbeat after the bar, not every bar N+1's",
        bars([(1, 2), (2, 2), (3, 2), (2, 2), (3, 2), (4, 2)]),
        1, 1.0, [3],
    ),
    case("the last time through, past the last beat", REPEATS, 1, 0.75, [2.5, 8.5, 12.5]),
]


def dump(examples):
    """The file as contracts/shared-examples.md lays it out: a beat or a meter per line."""

    def one_line(value):
        return json.dumps(value, ensure_ascii=False)

    def lines(values, indent):
        return ",\n".join(" " * indent + one_line(value) for value in values)

    meters = [
        "    {\n"
        f'      "name": {one_line(case["name"])},\n'
        '      "beats": [\n'
        f"{lines(case['beats'], 8)}\n"
        "      ],\n"
        '      "expected": {\n'
        '        "meters": [\n'
        f"{lines(case['expected']['meters'], 10)}\n"
        "        ],\n"
        f'        "qstamps": {one_line(case["expected"]["qstamps"])}\n'
        "      }\n"
        "    }"
        for case in examples["meters"]
    ]
    times_of = [
        "    {\n"
        f'      "name": {one_line(case["name"])},\n'
        '      "beats": [\n'
        f"{lines(case['beats'], 8)}\n"
        "      ],\n"
        f'      "query": {one_line(case["query"])},\n'
        f'      "expected": {one_line(case["expected"])}\n'
        "    }"
        for case in examples["times_of"]
    ]
    return (
        "{\n"
        '  "meters": [\n' + ",\n".join(meters) + "\n  ],\n"
        '  "times_of": [\n' + ",\n".join(times_of) + "\n  ]\n"
        "}\n"
    )


if __name__ == "__main__":
    path = Path(sys.argv[1])
    before = path.read_text(encoding="utf-8")
    examples = json.loads(before)
    # The meter cases are written back exactly as they were.
    meters = dump({**examples, "times_of": []}).split('  "times_of"')[0]
    assert before.startswith(meters)
    examples["times_of"] = CASES
    text = dump(examples)
    assert json.loads(text) == examples
    path.write_text(text, encoding="utf-8", newline="\n")

Tests

  • uv sync --all-packages, then uv run --package tilia-core pytest packages/tilia-core -q: 889 passed, 21 skipped, 8 xfailed, on Python 3.11.
  • The same on Python 3.10 (uv run --isolated --python 3.10 ...): 892 passed, 18 skipped, 8 xfailed. The three tests that 3.11 skips need Unicode 13.0, which is Python 3.10's.
  • python scripts/check_packages.py on Python 3.14.6: all package checks passed.

Guarantees, and what removing their code did

Each guarantee was neutralised alone, the package's tests were run, and the code was restored. All 889 passed again afterwards.

Guarantee Code neutralised Result
One time per pass only the first measure named 12 failed
A fraction outside 0 to 1 gives no time, with a warning the check removed 3 failed
A fraction that matches a beat's at 3 decimals is that beat only the downbeat matched 3 failed
Between beats, the fraction is exact the fraction rounded to 3 decimals first 2 failed
Between beats, the fraction is exact TiLiA's interpolation from beats rounded to 3 decimals 3 failed
The fraction 1 needs the next bar to be numbered one more any next downbeat 2 failed
The fraction 1 as a segment's end is the measure's end segment_end ignored at 1 3 failed
Past the last beat, as long as the measure before it the closing barline instead 1 failed
One measure alone gives no time past its last beat its closing barline instead 1 failed
A bar no measure names gives no time the next bar's measures instead 5 failed
A segment's end at 0 gives where the bar below ends those ends left out 3 failed
... the bar numbered just below that the table has bar N−1 instead 1 failed
... in the pass and movement asked for every pass and movement 1 failed
... leaving out an end that leads into a bar N not named nothing left out 1 failed
find_rows: a movement narrows the rows to it the movement ignored 4 failed

Not claimed or tested: a cadenza at a segment's end on a downbeat. The bar below is chosen among measures that aren't cadenzas, and no test checks what that gives.

With the other open PRs

#733 (choosing the time map) and #747 (quarter stamps and distances) also change timemap.py and table.py. Here, timemap.py gains find and times_of and imports lookup and find_rows, and in table.py only find_rows changes. Whichever merges later rebases. __init__.py is unchanged. Once the others have landed, nothing new needs exporting: find and times_of are methods of TimeMap, which is exported already.

Part of TimeLineAnnotator/project-management#218

🤖 Generated with Claude Code

azfoo and others added 2 commits October 19, 2026 11:17
…by_measure

One case per assertion of the app's tests that call get_time_by_measure
on a timeline with two beats or more, with the times the tests expect.
Dev's get_time_by_measure, run on each case loaded as TiLiA loads a
file, gives the same times. The meter cases are unchanged.

Part of TimeLineAnnotator/project-management#218

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimeMap.find gives the measures a bar names, by number, label, pass and
now movement: find_rows takes a movement, and without one every
movement matches.

TimeMap.times_of ports BeatTimeline.get_time_by_measure, with one time
for each measure the bar names rather than one lookup among every
pass's beats at once:

- the fraction counts the measure's own beats, interpolated between
  two beats; a fraction that is a beat's at 3 decimals is that beat;
- the fraction 1 is the next downbeat when the next bar is numbered one
  more, and as a segment's end the measure's end;
- past the last beat, the last measure lasts as long as the one before
  it in the timeline, as TiLiA projects it;
- as a segment's end, the fraction 0 also gives where each time through
  the bar numbered just below ends, but for an end that leads into a
  bar the query doesn't name;
- a bar no measure names gives no time, and a fraction outside 0 to 1
  gives none, with a warning.

The shared examples gain the cases for these rules: an end on a
downbeat, the last bar, passes with different numbers of beats, a bar
that doesn't exist, the fraction 1, and the last time through a bar.

Part of TimeLineAnnotator/project-management#218

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant