Skip to content

feat(core): quarter stamps, distances and lengths on the time map - #747

Open
azfoo wants to merge 3 commits into
v1-prereleasefrom
v1/215-timemap-qstamps
Open

azfoo wants to merge 3 commits into
v1-prereleasefrom
v1/215-timemap-qstamps

Conversation

@azfoo

@azfoo azfoo commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Claude

The time map learns quarter notes, and distances and lengths in bars and beats.

What changed

Quarter stamps (first and third commits). tilia_core/timemap/qstamps.py moves the quarter stamps of #621 (beat units and time signatures) out of its BeatTimeline, at 0ee8ea45: beat_quarter_lengths, _build_quarter_stamps, _build_time_stamp_table, get_quarter_stamp_by_time and get_time_by_quarter_stamp, with the same rules.

  • A beat lasts its value × 4 / denominator quarter notes. Each beat's stamp is the sum of the lengths before it, from 0.
  • Times and stamps convert by interpolating between beats and extrapolating beyond them with the nearest gap's tempo. Past the last beat, that is the last gap's tempo, not the last beat's own length.
  • The stamps are exact Fractions inside and floats where they meet times, so at a beat TimeMap.qstamp gives float() of the exact stamp, as feat: beat patterns, beat units, time signatures and quarter stamps #621 does.

MeasureRow gains two exact fields, which a folded table's rows get too:

  • qstamp, its downbeat's stamp;
  • quarters, its length, computed from the meter like the time signature.

TimeMap gains four methods:

  • qstamp(t) and time_at_qstamp(q);
  • quarters(start, end): the difference of two qstamps, rounded to a millionth as length() is;
  • assumed_between(start, end): whether an assumed meter went into quarters(start, end).

To answer that, qstamps.py gains beats_between, which gives the beats whose lengths a span's quarter notes count. It sits beside the conversions and extrapolates the same way:

  • past the last beat, the last gap's beat counts, which is the bar before's when the last bar has a single tap;
  • an end before the start covers the bars in between;
  • times aren't snapped, and an end exactly on a beat takes nothing from that beat.

The map builds its stamp table the first time a qstamp is asked for, not when the map itself is built. The index reads every file's positions and never asks for qstamps, and exact sums over a long piece take a while. The table is cached in the instance's __dict__ rather than with functools.cached_property, which on Python 3.10 and 3.11 holds one lock shared by every instance.

On a synthetic 93,000-beat timeline the map takes 0.55 s to build, against 0.44 s on v1-prerelease (best of 7, Python 3.11). The difference comes from the rows' exact qstamp and quarters. Building the stamp table with the map made it 0.73 s.

Distances and lengths (second commit). TimeMap.position(t, unit), length(start, end, unit) and unit_seconds(t, unit), with unit either "bar" or "beat", port the prototype's position, length, beat_seconds and bar_seconds (ql_timemap.py).

  • A position runs from 0 at the first beat. A bar counts 1 whatever its metre, and the fraction inside a bar counts beats. The closing barline ends the last bar.
  • A length is the difference of two positions, rounded to a millionth, and None when either end is off the map. An end on the closing barline is on the map; a start there isn't.
  • unit_seconds is in the file's unit.
  • Both ends are snapped first, as they are for positions.
  • Any other unit gives None and logs an error. The prototype counts beats for any unit other than "bar", but no file can give another unit: only a caller can.

Tests

  • test_timemap_qstamps.py ports feat: beat patterns, beat units, time signatures and quarter stamps #621's test_quarter_stamps.py case for case, and adds the spec's User Story 3, scenarios 1–3 and 5.
    • One test runs a grid of spans, with and without a one-tap last bar. Wherever assumed_between says no, giving the assumed bar another meter must leave quarters() unchanged.
    • Another test checks that the first qstamps on two maps don't wait for each other.
  • test_timemap_distances.py adds scenarios 4 and 6: eight bars at any tempo, a 3/4 bar inside 4/4, how long a beat and a bar last, the end of the media, and a quarters file.
  • test_timemap_shared.py no longer skips test_qstamps. It compares the quarter stamps of all 18 shared examples, at every beat and on every row, without changing any expected value.
  • test_timemap_prototype.py: the 7 tests marked needs_distances pass unchanged, so the mark and its definition are gone.

Open question

The last beat's length when its value differs from the beat before's (an uneven pattern, or a last bar under a unit of its own) is open as TimeLineAnnotator/project-management#325. Until it is answered:

For such a last bar, quarters(row.start, row.end) differs from the row's quarters (1.5 against 1 under 2+3 with a one-tap last bar). No migrated file has such a last beat.

Tests run

  • uv sync --all-packages
  • uv run --package tilia-core pytest packages/tilia-core -q (Python 3.11): 878 passed, 3 skipped, 1 xfailed. The xfail is needs_find_time_map, which feat(core): choose a document's time map by role #733 (choosing the time map) removes.
  • uv run --isolated --python 3.10 --package tilia-core pytest packages/tilia-core -q: 881 passed, 1 xfailed.
  • python scripts/check_packages.py (Python 3.14): all package checks passed.

Guarantees, and what removing their code did

For each guarantee, its code was removed or neutralised, the time map's tests (tests/timemap: 400 tests and 1 xfail, on Python 3.11) were run, and the code was restored.

Guarantee Neutralised by Result
Stamps are summed exactly, then floated summing floats 2 failed: test_triplets_are_exact, shared "1/3 over four taps"
Past the last beat, the last gap's tempo the last beat's own length 1 failed: test_past_last_beat_keeps_last_gap_tempo
Before the first beat, extrapolated clamping the time to the first beat 2 failed
A beat lasts value × 4 / denominator lengths equal to the values 36 failed, 12 of the 18 shared cases among them
A row's qstamp is the sum of the measures before it dropping the running sum 27 failed, all 18 shared cases among them
A row's quarters is the sum of its beats' lengths quarters equal to the numerator 18 failed
Nothing computed reaches the file writing each row's qstamp onto its downbeat 1 failed: test_setting_a_beat_unit_changes_every_qstamp_after_it
A missing or non-finite time has no qstamp dropping the isfinite check 1 failed
The stamp table is built once, on first use not caching it 1 failed: test_conversions_reuse_one_table
Maps building their tables don't wait for each other functools.cached_property 1 failed: test_first_qstamps_on_two_maps_dont_wait_for_each_other
quarters() is rounded to a millionth not rounding 1 failed
assumed_between: past the last beat, the last gap's beat the last beat's own 2 failed, the one-tap grid among them
assumed_between: an end before the start covers the bars between not sorting the ends 2 failed, the two-tap grid among them
assumed_between: an end exactly on a beat takes nothing from it bisect for bisect_left 1 failed
assumed_between: not snapped, as qstamps aren't snapping both ends 3 failed, both grids among them
assumed_between: False for a missing time dropping the check 1 failed
A bar counts 1, its fraction counts its beats dividing by 4 4 failed
The closing barline ends the last bar dropping that branch 5 failed
A length's start on the closing barline is off the map treating it like an end 3 failed
Lengths are rounded to a millionth not rounding 1 failed
A length's end is snapped not snapping it 2 failed
unit_seconds of a bar runs from its downbeat to the next the beat's length instead 6 failed
unit_seconds snaps first not snapping 2 failed
An unknown unit gives None, logged accepting any unit 4 failed

Untested, or tested only in part:

  • The no-shared-lock test fails on Python 3.10 and 3.11 only. From 3.12, cached_property has no lock, so the test passes either way.
  • Two threads asking the same new map for its first qstamp may both build the table, and either one is kept. No test runs that.

Other open PRs on the same files

#733 (choosing the time map) changes table.py, timemap.py and test_timemap_prototype.py too. Lookups (TimeLineAnnotator/project-management#218) will change timemap.py and find_rows. Whichever merges later rebases. In test_timemap_prototype.py, this PR removes needs_distances and #733 removes needs_find_time_map, and each rewords the comment on the other's mark. Whoever rebases drops both marks and both comments.

__init__.py and its __all__ are left alone. Nothing new needs exporting once the others have landed: the new names are methods of TimeMap and fields of MeasureRow, which are already exported. qstamps.py's functions stay internal, as the contract lists none of them.

Part of TimeLineAnnotator/project-management#215

🤖 Generated with Claude Code

azfoo and others added 2 commits October 15, 2026 12:23
qstamps.py moves desktop#621's quarter stamps out of its BeatTimeline
(beat_quarter_lengths, _build_quarter_stamps, _build_time_stamp_table,
get_quarter_stamp_by_time and get_time_by_quarter_stamp, at 0ee8ea4), with
the same rules: a beat lasts its value x 4 / denominator in quarter notes,
each beat's stamp is the sum of the lengths before it from 0, and times and
stamps convert by interpolating between beats and extrapolating beyond them
with the tempo of the nearest gap. The stamps are exact inside and floats
where they meet times, so at a beat the time map gives float() of the exact
stamp, as #621 does.

Each MeasureRow gains qstamp, its downbeat's, and quarters, its length, both
exact Fractions; quarters is computed from the meter, like the time
signature. The rows of a folded table get them too, in the timeline's order.

TimeMap gains qstamp(t), time_at_qstamp(q), quarters(start, end) and
assumed_between(start, end). It builds its stamp table the first time a
qstamp is asked for, not with the map: the index reads every file's
positions and never asks, and exact sums over a long piece take a while.
assumed_between reads a span as positions do (snapped, the end point
excluded), and a part off the map touches the measure at that end of it,
whose tempo its stamps are extrapolated with.

test_timemap_qstamps.py ports #621's test_quarter_stamps.py case for case,
and adds the spec's scenarios. test_timemap_shared.py now compares every
shared example's quarter stamps, at each beat and on each row.

Part of TimeLineAnnotator/project-management#215

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimeMap gains position(t, unit), length(start, end, unit) and
unit_seconds(t, unit), with unit "bar" or "beat": the prototype's position,
length, beat_seconds and bar_seconds (ql_timemap.py). A position runs from 0
at the first beat, a bar counts 1 whatever its metre and the fraction inside
it counts beats, and the closing barline ends the last bar. A length is the
difference of two positions, rounded to a millionth, and None when either end
is off the map; an end on the closing barline is on it, a start isn't.
unit_seconds is in the file's unit. Both ends are snapped first, as for
positions.

A unit other than "bar" or "beat" gives None and logs an error, rather than
counting beats as the prototype does: no file can give one, only a caller.

The prototype's distance assertions in test_timemap_prototype.py now pass,
unchanged, so their strict xfail mark is gone. test_timemap_distances.py
adds the spec's scenarios: eight bars at any tempo, a 3/4 bar inside 4/4, how
long a beat and a bar last, the media's end, and a quarters file.

Part of TimeLineAnnotator/project-management#215

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
assumed_between picked its measures as positions read a span: snapped, the
end point excluded, an end before the start in the start's bar, and past the
last beat the last bar. quarters() reads none of these ways, so a length
could count an assumed meter that the flag missed:
- past the last beat, quarters() extrapolates with the last gap's tempo,
  which is the bar before's when the last bar has a single tap;
- an end before the start still counts every bar between the two;
- an end that snaps onto a downbeat still counts the time after it.

beats_between, beside the conversions in qstamps.py, now gives the beats
whose lengths the quarter notes between two times count, extrapolating as
quarter_stamp_by_time does, and assumed_between checks their measures. A
test checks over a grid of spans, with and without a one-tap last bar, that
wherever the flag says no, another meter for the assumed bar leaves
quarters() unchanged.

quarters() is rounded to a millionth, as length() is: under 1/3 units, two
beats 8 quarter notes apart gave 7.999999999999999.

The stamp table is cached in the instance's __dict__ rather than with
functools.cached_property, which on Python 3.10 and 3.11 holds one lock for
every instance, so maps building their first table waited for each other.

Part of TimeLineAnnotator/project-management#215

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

azfoo commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Claude

A review of this PR before anyone else's found that assumed_between could miss an assumed meter that quarters() counts. f93e17d fixes it:

  • it now reads the beats quarters() counts (beats_between), not the bars positions read;
  • quarters() is rounded to a millionth, as length() is;
  • the stamp table no longer takes the lock that functools.cached_property shares across instances on Python 3.10 and 3.11.

The description is updated: what changed, the test counts, and the guarantee table.

One question is left for the plan: TimeLineAnnotator/project-management#325, the length of a last beat whose value differs from the beat before it. Its answer may change _closing_barline or the last tempo in qstamps.py.

azfoo added a commit that referenced this pull request Oct 19, 2026
Copied from tilia/timelines/beat/units.py (desktop#621 at e5da86a),
unchanged: the file is the same blob as there (2ef3be7). Since
0ee8ea4, the commit copied before, parse_units refuses a fraction
whose denominator, in lowest terms, is above MAX_DENOMINATOR (128), with
the message of any unreadable unit. Whole numbers are unchanged. Sums of
units, such as a row's ts_numerator and the running quarter stamps of
#747 (quarter stamps), then have denominators that divide one fixed
number, where units such as 1/(10**29 + i) made them grow without limit.
The new cases of #621's tests are ported.

Part of TimeLineAnnotator/project-management#247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
azfoo added a commit that referenced this pull request Oct 19, 2026
Copied from tilia/timelines/beat/units.py (desktop#621 at e5da86a),
unchanged: the file is the same blob as there (2ef3be7). Since
0ee8ea4, the commit copied before, parse_units refuses a fraction
whose denominator, in lowest terms, is above MAX_DENOMINATOR (128), with
the message of any unreadable unit. Whole numbers are unchanged. Sums of
units, such as a row's ts_numerator and the running quarter stamps of
#747 (quarter stamps), then have denominators that divide one fixed
number, where units such as 1/(10**29 + i) made them grow without limit.
The new cases of #621's tests are ported.

Part of TimeLineAnnotator/project-management#247

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