Skip to content

feat(core): time signatures in the measure table, and the shared meter examples - #736

Merged
azfoo merged 4 commits into
v1-prereleasefrom
v1/214-timemap-time-signatures
Oct 14, 2026
Merged

azfoo merged 4 commits into
v1-prereleasefrom
v1/214-timemap-time-signatures

Conversation

@azfoo

@azfoo azfoo commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

Gives each row of the measure table its meter and time signature, and adds the shared meter examples: beat timelines with the meters and quarter stamps that #621's code (beat units and time signatures) gives them.

Builds on #732 (copy units.py again), now in v1-prerelease, whose tuples let a row that holds its meter be hashed.

What changed

  • table.py: MeasureRow gains meter (the MeasureMeter that meters_of computes for it), time_signature, ts_numerator (a Fraction, exact), ts_denominator, assumed, ambiguous and conflicting. build_rows gives each row its meter, and the row computes the other six from it when it is made (init=False fields set in __post_init__), so they can't disagree with it; dataclasses.replace refuses them. Folded tables get them too.
  • format_time_signature(beat_values, denominator): the sum of the beat values when they are all equal (6/8); the values joined by +, as they fall in the bar, when they differ (2+2+3/8; three taps under 2+3 give 2+3+2/8); in brackets when the numerator isn't whole or holds a fraction ((4/3)/4, (1/2+1)/8).
  • shared_examples.json: 18 meter cases, laid out with one beat or one meter per line. Among them: the default unit, a pickup, 6/8 tapped in two (before and after its unit is set) and in six, 2+3, 1/2, a unit changing in mid-piece, a unit on a later beat, two units in one measure, an ambiguous fit, a pickup holding the end of a pattern (and one without a unit of its own), a numerator that isn't whole, no unit in the first measures, no unit at all, a unit on a later beat of the first measure, and an assumed unit in mid-piece. times_of is empty until the lookups add their cases.
  • test_timemap_shared.py: builds each case with docs.py and compares build_rows' time signatures, beat values and flags with the file, and checks the file's layout and that it holds the cases the spec asks for. The quarter-stamp comparison is skipped, naming TimeLineAnnotator/project-management#215 (quarter stamps and distances), which removes the skip; its body assumes TimeMap.qstamp(t), and Make restoring state a lot faster #215 may adapt it.
  • A row's fractions aren't JSON numbers. dataclasses.asdict(row) still works, but json.dumps now needs default=str, which writes them as text ("3/2"), as the shared examples do. The test that turns a row into JSON does that.

The core and #621 agree on every case: no case needed a difference reported.

How the expected values were computed

The beats of each case were chosen by hand. The expected meters and quarter stamps come from #621's own code at 07cb6d4f (BeatTimeline.measure_meters and quarter_stamps), never written by hand: the script below loads each case as TiLiA loads a file (create_timeline with the components, so deserialize_components and _ensure_first_beat_unit run), and writes the values into the file. #621 has no text form of a time signature, so time_signature is #621's beat values and denominator written by format_time_signature, which the tests check against the table of TimeLineAnnotator/project-management#193 (how the index shows time signatures) separately.

It ran in a detached worktree of 07cb6d4f, which it left unchanged, with this branch's environment (tilia came from that worktree, which the script asserts):

cd <worktree of desktop at 07cb6d4f>
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 fill_shared_examples.py
fill_shared_examples.py
"""Fills the expected values of shared_examples.json's meter cases from
desktop#621's own code: BeatTimeline.measure_meters and quarter_stamps.

Each case's beats are loaded as TiLiA loads a file: the beats, then their beat
units, then beats_in_measure and measure_numbers from the marks, through
Timelines.create_timeline(BeatTimeline, components, ...), which runs
deserialize_components and so _ensure_first_beat_unit, as opening a file does.

Run it with the app's test fixtures (which keep TiLiA's settings in a temporary
store), from a checkout of feat/beat-unit at 07cb6d4f, in an environment where
tilia_core is installed:

    cd <checkout of desktop at 07cb6d4f>
    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 <this file>

#621 has no text form of a time signature, so `time_signature` is #621's beat
values and denominator written by tilia_core's format_time_signature (FR-022).
"""

import json
import os
from pathlib import Path

import tilia
from tilia.requests import Get, get
from tilia.timelines.beat.timeline import BeatTimeline
from tilia_core.timemap.table import format_time_signature

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


def test_fill_expected_values(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"))
    for case in examples["meters"]:
        timeline = load(tls, case["beats"])
        case["expected"] = {
            "meters": [meter(m) for m in timeline.measure_meters],
            "qstamps": [str(stamp) for stamp in timeline.quarter_stamps],
        }
        tls.delete_timeline(timeline)

    text = dump(examples)
    assert json.loads(text) == examples
    EXAMPLES.write_text(text, encoding="utf-8", newline="\n")


def load(tls, beats):
    """A BeatTimeline with these beats, loaded as TiLiA loads a file."""
    beat_ids = [get(Get.ID) for _ in beats]
    components = {
        beat_id: {"kind": "BEAT", "time": beat["time"]}
        for beat_id, beat in zip(beat_ids, beats)
    }
    for beat_id, beat in zip(beat_ids, beats):
        if (unit := beat.get("beat_unit")) is not None:
            components[get(Get.ID)] = {
                "kind": "BEAT_UNIT",
                "beat_id": beat_id,
                "denominator": unit["denominator"],
                "units": unit["units"],
                "assumed": unit.get("assumed", False),
            }

    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,
    )
    # #621 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


def meter(m):
    return {
        "time_signature": format_time_signature(m.beat_values, m.denominator),
        "beat_values": [str(value) for value in m.beat_values],
        "assumed": m.is_assumed,
        "ambiguous": m.is_ambiguous,
        "conflicting": m.is_conflicting,
    }


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)

    cases = [
        "    {\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"]
    ]
    return (
        "{\n"
        '  "meters": [\n' + ",\n".join(cases) + "\n  ],\n"
        f'  "times_of": {one_line(examples["times_of"])}\n'
        "}\n"
    )

Because the cases with no unit on their first beat (no unit in the first measures, no unit at all, and a unit on a later beat of the first measure) depend on _ensure_first_beat_unit, the app's half of these examples has to load each case the same way. Building it beat by beat, with put_beat_unit_on_beat afterwards, would leave the default unit on the first beat: the first two would read it instead of a copy of the first unit, and the third would read conflicting.

Tests

  • uv sync --all-packages, then uv run --package tilia-core pytest packages/tilia-core -q: 531 passed, 18 skipped (the quarter-stamp comparisons) and 8 xfailed (the self-test's placeholders for later tasks), on Python 3.11.
  • The same on Python 3.10 (uv run --isolated --python 3.10 ...): the same counts.
  • python scripts/check_packages.py on Python 3.14.6: all package checks passed.

Guarantees, and what removing their code did

Each was neutralised alone, the package's tests were run, and the code was restored (531 passed again). The counts are for the head of the branch, 852a027a.

Guarantee Code neutralised Result
Each row has its own meter every row given the first row's meter 20 failed
Equal beat values: their sum (6/8) always joined by + 30 failed
Differing beat values: joined as they fall (2+3+2/8) always summed 15 failed
Brackets for a numerator that isn't whole or holds a fraction no brackets 10 failed
ts_numerator is exact a float 8 failed
ts_denominator is the meter's always 4 9 failed
assumed is the meter's always False 8 failed
ambiguous is the meter's always False 5 failed
conflicting is the meter's always False 4 failed
A row's time signature and flags can't disagree with its meter passed in beside the meter, as before aa0c84ed 1 failed
A row that holds its meter hashes meters_of gives beat_values as a list 14 failed
Before the first unit, its values, as #621's loader gives them the default unit instead 4 failed, one of them a shared example

With #733

#733 (choosing the time map) also changes table.py and test_timemap_table.py. This PR changes MeasureRow, the loop of build_rows_from and adds format_time_signature, and leaves _closing_barline, the line that calls it, and __init__.py alone. Whichever merges second rebases. Once both have landed, tilia_core.timemap.__all__ should also export format_time_signature, and Meter (MeasureMeter, the type of MeasureRow.meter), as the planned API lists them.

Part of TimeLineAnnotator/project-management#214

🤖 Generated with Claude Code

Base automatically changed from v1/247-units-copy to v1-prerelease October 14, 2026 14:15
azfoo and others added 2 commits October 14, 2026 16:15
build_rows gives each MeasureRow the meter meters_of computes for it, with
desktop#621's rules, and what follows from it: the time signature as text
and as numbers (ts_numerator, exact, and ts_denominator), and the flags
assumed, ambiguous and conflicting. format_time_signature writes the text:
the sum of the beat values when they are all equal (6/8), the values joined
by "+" as they fall in the bar when they differ (2+2+3/8), in brackets when
the numerator isn't whole or holds a fraction ((4/3)/4, (1/2+1)/8).

The rows of a folded table get their meters too. A row's fractions aren't
JSON numbers, so the test that turns a row into JSON writes them as text.

Part of TimeLineAnnotator/project-management#214

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

shared_examples.json holds 18 beat timelines with the meters and quarter
stamps that desktop#621's BeatTimeline gives them, at 07cb6d4, loaded as
TiLiA loads a file. Among them are a timeline whose first measures have no
beat unit, and one with no unit at all, which #621's loader gives a copy of
the first unit, or the default, assumed. The expected values were computed
by running #621's code, not written by hand; the time signature's text is
#621's beat values written by format_time_signature, since #621 has none.

test_timemap_shared.py compares build_rows' meters and time signatures with
the file, and checks its layout. Comparing the quarter stamps waits for the
time map's (TimeLineAnnotator/project-management#215), so that test is
skipped. times_of is empty until the lookups add their cases.

Part of TimeLineAnnotator/project-management#214

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azfoo
azfoo force-pushed the v1/214-timemap-time-signatures branch from f354be0 to 42e9a51 Compare October 14, 2026 15:48
@azfoo

azfoo commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

Rebased onto v1-prerelease now that #732 is in, so the PR holds only its own two commits (211174b4, 42e9a514); nothing in them changed. On the new base: uv run --package tilia-core pytest packages/tilia-core -q gives 530 passed, 18 skipped (the quarter-stamp comparisons) and 8 xfailed (the self-test's placeholders for later tasks), and python scripts/check_packages.py passes.

@azfoo azfoo left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude

A code-review run on 42e9a514, with each finding checked by hand. The same session wrote the PR, so this review may miss what the author missed. Four points to fix; four more were checked and need no change.

To fix

  1. The derived fields aren't tied to meter. time_signature, ts_numerator, ts_denominator, assumed, ambiguous and conflicting are init fields, copied from meter in build_rows_from. So dataclasses.replace(row, meter=other), or a row built by hand, can disagree with its own meter. Computing them in __post_init__, as field(init=False), keeps them as fields, so asdict and the index still get them, and nothing can make them disagree. That is how TimeMap computes its arrays from its rows.
  2. test_timemap_shared.py's docstring claims too much. The beat values, flags and qstamps come from #621's code. The time_signature text is #621's values written by format_time_signature, so the file can't check that function against itself; the test against #193's table does. The docstring should say so, as the PR description does.
  3. Three cases depend on loading each case as a file, not two. In "a unit on a later beat of the first measure", a timeline built beat by beat gets the default unit on its first beat, so measure 1 reads 4/4, conflicting, instead of 4/8. The app's half has to load the cases as the fill script does. WP9's spec says this for "the cases without a unit in their first measures"; it should say "without a unit on their first beat", and so should TimeLineAnnotator/project-management#220 (the app's half). Nothing changes in this PR.
  4. Short names for the project-management issues the tests cite: #193 in test_timemap_table.py, and #215 in the skip reason.

Checked; no change needed

  • json.dumps(dataclasses.asdict(row)) now needs default=str. This is deliberate and in the description, and WP9's spec now says so. The index stores ts_numerator as a real taken from the Fraction, not through JSON.
  • The skipped test_qstamps compares floats exactly. At a beat, #621's get_quarter_stamp_by_time returns float(stamp) exactly, so the comparison holds if #215 does the same. #215 adapts the placeholder when it removes the skip.
  • row.time_signature (text) and row.meter.time_signature (a tuple) share a name. Both names are fixed elsewhere: the row's is the index column's, and the meter's is #621's.
  • The expected values come from #621 at 07cb6d4f, not a17bbd83. This is right: #621 was rebased, so a17bbd83 is no longer on its branch, and the task was updated to 07cb6d4f.

azfoo and others added 2 commits October 14, 2026 18:20
time_signature, ts_numerator, ts_denominator, assumed, ambiguous and
conflicting were passed to MeasureRow beside its meter, so a row made with
dataclasses.replace(row, meter=...), or built by hand, could disagree with
its own meter. They are now init=False fields that __post_init__ computes
from the meter, as TimeMap computes its arrays from its rows: still fields,
so dataclasses.asdict gives them, and replace() refuses them.

Part of TimeLineAnnotator/project-management#214

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

The beat values, flags and quarter stamps in shared_examples.json come from
desktop#621's code; its time_signature text is #621's values written by
format_time_signature, which test_timemap_table.py checks against texts
written by hand. The docstring said every expected value came from #621.
The issues the tests cite get short names.

Part of TimeLineAnnotator/project-management#214

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

azfoo commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

The four points of the review above are done:

  1. The derived fields are computed from the meter in __post_init__ (aa0c84ed): init=False fields, so asdict still gives them and dataclasses.replace refuses them. The new test, test_a_row_s_time_signature_and_flags_are_computed_from_its_meter, failed before the change (1 failed) and passes after it.
  2. The shared test's docstring says the time_signature text is feat: beat patterns, beat units, time signatures and quarter stamps #621's values written by format_time_signature, checked against texts written by hand in test_timemap_table.py (852a027a).
  3. The spec and TimeLineAnnotator/project-management#220 (the app's half) now say that the three cases with no unit on their first beat need the app to load them as a file. The description here says the same. Nothing changed in the code for this one.
  4. Short names for the two project-management issues the tests cite (852a027a).

531 passed, 18 skipped and 8 xfailed on Python 3.10 and 3.11, and check_packages.py passes on 3.14. Every guarantee in the description was checked again on 852a027a, with one new row for point 1.

@azfoo
azfoo merged commit 1b920ca into v1-prerelease Oct 14, 2026
26 checks passed
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