Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eca899e
feat(core): choose a document's time map by role
azfoo Oct 19, 2026
f37a2a7
fix(core): make TimeMapFound's warnings a tuple, so it hashes
azfoo Oct 19, 2026
1452f3d
fix(core): one test for whether the media ends by the last beat
azfoo Oct 19, 2026
8f357bc
refactor(core): one lookup of a beat timeline for time_map_for and me…
azfoo Oct 19, 2026
ba8ade4
test(core): say what the media warning test checks
azfoo Oct 19, 2026
b410c0e
fix(core): give no rows for a table out of time order
azfoo Oct 19, 2026
9f6ea73
feat(core): warn about every beat timeline past the media
azfoo Oct 19, 2026
f2e1ddf
feat(core): let a role that is a list claim time_map
azfoo Oct 19, 2026
b7db78e
fix(core): sort an ordinal that isn't a whole number after the others
azfoo Oct 19, 2026
7e0b5bd
refactor(core): one chain of table checks (table_problem)
azfoo Oct 19, 2026
5ac2616
refactor(core): one EPS for snapping and the same-time check
azfoo Oct 19, 2026
46b9d08
test(core): check that TimeMapFound hashes by hashing it
azfoo Oct 19, 2026
bcdacdb
test(core): a table out of time order gets no media warning
azfoo Oct 19, 2026
b670fc8
fix(core): treat a media length the format doesn't allow as no length
azfoo Oct 15, 2026
404d43c
fix(core): leave the gap out of the media warning for a single beat
azfoo Oct 15, 2026
6903b60
feat(core): name the timelines that claim time_map, and log the warning
azfoo Oct 15, 2026
13d45ef
fix(core): type roles as holding lists too
azfoo Oct 15, 2026
ebba2d5
test(core): cite project-management issues in desktop's form
azfoo Oct 15, 2026
5edaaf8
fix(core): treat a media length too large for a float as no length
azfoo Oct 19, 2026
a0fe657
fix(core): treat a beat time too large for a float as no time
azfoo Oct 19, 2026
6ccc069
test(core): check the claimants' warning against its written text
azfoo Oct 19, 2026
7f7844a
test(core): a whole-number media length caps and warns
azfoo Oct 19, 2026
b651948
refactor(core): one pass over a table in media_warning
azfoo Oct 19, 2026
523ad1d
refactor(core): one rule for the gap after the last beat
azfoo Oct 19, 2026
b3e8d77
docs(core): say what table_problem's None does and doesn't promise
azfoo Oct 19, 2026
f71abfa
test(core): take the timeline factories directly, not through globals()
azfoo Oct 19, 2026
27ae7f7
test(core): wrap three comments at 88 columns
azfoo Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 133 additions & 4 deletions packages/tilia-core/src/tilia_core/timemap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,146 @@

from __future__ import annotations

import logging
from collections.abc import Mapping
from dataclasses import dataclass

from tilia_core import tla
from tilia_core.timemap.table import MeasureRow
from tilia_core.timemap.table import MeasureRow, build_rows, media_warning
from tilia_core.timemap.timemap import Positions, TimeMap, build_time_map

__all__ = ["MeasureRow", "Positions", "TimeMap", "time_map_for"]
__all__ = [
"MeasureRow",
"Positions",
"TimeMap",
"TimeMapFound",
"find_time_map",
"measure_rows",
"time_map_for",
"time_map_of",
]

logger = logging.getLogger(__name__)

TIME_MAP_ROLE = "time_map"


@dataclass(frozen=True)
class TimeMapFound:
"""A document's time map as `find_time_map` chose it, or why there is none."""

time_map: TimeMap | None
timeline_id: str | None # the chosen beat timeline, even when it gives no time map
guessed: bool # no timeline had the role time_map: the first beat timeline was taken
reason: str | None # why the chosen timeline gives no time map
warnings: tuple[str, ...] # several claim the role; a last beat past the media


def find_time_map(
document: tla.Document, roles: Mapping[str, str | list[str] | None] | None = None
) -> TimeMapFound:
"""The document's time map: its beat timeline whose role is time_map, else
its first beat timeline by ordinal, guessed.

`roles` gives each timeline's role by its id, as the caller resolved them
(the index: from the file and the corpus's manifest), and a timeline it
leaves out has none; without it, the roles are the file's. A role that is a
list claims time_map when it holds it. When several beat timelines have the
role, the first by ordinal wins, with a warning that names them. When the
chosen timeline gives no time map, no other stands in: the file has no
bars, and `reason` says why. A document without a beat timeline has no
time map, and nothing is wrong with it: no reason, no warning.

Every beat timeline whose last beat is at or after the end of the media
gets a warning too, with its id, the time map's or not. The warnings are
logged here, once for each call, and not when a table is built.
"""
beat_timelines = sorted(
(t for t in document.timelines.values() if t.kind == "beat"),
key=_by_ordinal,
)
if not beat_timelines:
return TimeMapFound(
time_map=None, timeline_id=None, guessed=False, reason=None, warnings=()
)
claiming = [t for t in beat_timelines if _claims_the_role(t, roles)]
warnings = [_several_claim(claiming)] if len(claiming) > 1 else []
timeline = (claiming or beat_timelines)[0]
guessed = not claiming
time_map, reason = build_time_map(document, timeline, guessed=guessed)
for beats in beat_timelines:
warning = media_warning(document, beats)
if warning is not None:
warnings.append(warning)
for warning in warnings:
logger.warning("%s.", warning)
return TimeMapFound(
time_map=time_map,
timeline_id=timeline.id,
guessed=guessed,
reason=reason,
warnings=tuple(warnings),
)


def _several_claim(claiming: list[tla.Timeline]) -> str:
ids = [timeline.id for timeline in claiming]
return (
f"timelines {', '.join(ids[:-1])} and {ids[-1]} claim the role time_map; "
f"{ids[0]}, the first by ordinal, is used"
)


def _by_ordinal(timeline: tla.Timeline) -> tuple[int, float]:
# An ordinal that isn't a whole number, which the reader doesn't refuse
# yet, comes after the others, in the document's order.
ordinal: object = timeline.ordinal
if isinstance(ordinal, int) and not isinstance(ordinal, bool):
return 0, ordinal
if isinstance(ordinal, float) and ordinal.is_integer():
return 0, ordinal
return 1, 0


def _claims_the_role(
timeline: tla.Timeline, roles: Mapping[str, str | list[str] | None] | None
) -> bool:
role = timeline.metadata.get("role") if roles is None else roles.get(timeline.id)
if isinstance(role, (list, tuple)):
return TIME_MAP_ROLE in role
return role == TIME_MAP_ROLE


def time_map_of(
document: tla.Document, roles: Mapping[str, str | list[str] | None] | None = None
) -> TimeMap | None:
"""The document's time map, chosen as `find_time_map` chooses it; None when it has none."""
return find_time_map(document, roles).time_map


def time_map_for(document: tla.Document, timeline_id: str) -> TimeMap | None:
"""The time map of the beat timeline `timeline_id`; None when it has none."""
timeline = document.timelines.get(timeline_id)
if timeline is None or timeline.kind != "beat":
timeline = _beat_timeline(document, timeline_id)
if timeline is None:
return None
time_map, _ = build_time_map(document, timeline)
return time_map


def measure_rows(document: tla.Document, timeline_id: str) -> list[MeasureRow]:
"""The measure table of the beat timeline `timeline_id`, in its order.

Also for a folded table, which has no time map. No rows while the table
has a problem (`table.table_problem`: a beat without a usable time, a row
whose beats don't start at its downbeat, or two beats at the same time or
out of time order), and none for a timeline that isn't a beat timeline.
"""
timeline = _beat_timeline(document, timeline_id)
if timeline is None:
return []
return build_rows(document, timeline)


def _beat_timeline(document: tla.Document, timeline_id: str) -> tla.Timeline | None:
timeline = document.timelines.get(timeline_id)
return timeline if timeline is not None and timeline.kind == "beat" else None
150 changes: 118 additions & 32 deletions packages/tilia-core/src/tilia_core/timemap/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@

from __future__ import annotations

import logging
import itertools
import math
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Any, NoReturn
from typing import Any, NoReturn, TypeGuard

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__)
# Float noise: two beats closer than this are at the same time, and a time this
# much beyond the tolerance is still on a beat.
EPS = 1e-6


class FrozenMetadata(dict[str, "str | tuple[str, ...]"]):
Expand Down Expand Up @@ -107,19 +109,82 @@ def __post_init__(self) -> None:
def build_rows(document: tla.Document, timeline: tla.Timeline) -> list[MeasureRow]:
"""The timeline's measure table, in its order; also for a folded table.

None while a beat has no usable time (`missing_time` says which), or while a
row's beats don't start at its downbeat (`broken_row`): rows built without
them would be wrong.
No rows while the table has a problem (`table_problem` says which): rows
built then would be wrong, with measures that end before they start.
"""
if missing_time(timeline) is not None or broken_row(timeline) is not None:
if table_problem(timeline) is not None:
return []
return build_rows_from(document, timeline, rows_of(timeline))


def table_problem(timeline: tla.Timeline) -> str | None:
"""Why the timeline's table can't give rows, as a reason; else None.

A beat without a usable time (`missing_time`), a row whose beats don't
start at its downbeat (`broken_row`), or two beats at the same time or out
of time order (`disorder`). None doesn't promise rows: a timeline without
a table, or with an empty one, has none, and no problem either.
"""
return _checked(timeline)[0]


def _checked(timeline: tla.Timeline) -> tuple[str | None, list[float]]:
# The table's problem, and the times of its beats in its order: none when it
# has a problem, so the times given are always usable.
reason = missing_time(timeline) or broken_row(timeline)
if reason is not None:
return reason, []
times = _times(timeline)
reason = disorder(times)
return reason, [] if reason is not None else times


def _times(timeline: tla.Timeline) -> list[float]:
# The times of the table's beats, in its order, once missing_time has
# found each of them usable.
rows = timeline.measures.rows if timeline.measures is not None else []
return [timeline.components[b].attrs["time"] for row in rows for b in row.beats]


def disorder(times: Sequence[float]) -> str | None:
"""The first two beats at the same time, or the first out of time order, as
a reason; else None."""
for a, b in itertools.pairwise(times):
if abs(b - a) < EPS:
return f"two beats at the same time ({a})"
if b < a:
return f"beats out of time order (at {b})"
return None


def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None:
"""The warning, with the timeline's id, that its last beat is at or after
the end of the media; None when it isn't, or when the table has no rows.

With more than one beat, the last measure ends one gap after the last
beat, and the warning says so; with one, it ends on the beat.
"""
reason, times = _checked(timeline)
if reason is not None or not times:
return None
last, gap = _last_gap(times)
media_length = _media_length(document)
if media_length is None or not _ends_by(media_length, last):
return None
warning = (
f"Timeline {timeline.id}: the last beat ({last}) is at or after "
f"the end of the media ({media_length})"
)
if gap > 0:
warning += "; the last measure ends one gap after it"
return warning


def missing_time(timeline: tla.Timeline) -> str | None:
"""The first beat without a usable time, as a reason naming its measure; else None.

A time is usable when it is a finite number: a missing, NaN or text time isn't.
A time is usable when it is a finite number: a missing, NaN or text time
isn't, nor a whole number too large for a float.
"""
if timeline.measures is None:
return None
Expand Down Expand Up @@ -147,12 +212,18 @@ def broken_row(timeline: tla.Timeline) -> str | None:


def _has_time(component: tla.Component | None) -> bool:
time = component.attrs.get("time") if component is not None else None
return (
isinstance(time, (int, float))
and not isinstance(time, bool)
and math.isfinite(time)
)
return component is not None and _is_number(component.attrs.get("time"))


def _is_number(value: object) -> TypeGuard[int | float]:
"""A finite number, as JSON stores one: not a bool, NaN, an infinity, or a
whole number too large for a float, which JSON reads as an int."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
try:
return math.isfinite(value)
except OverflowError:
return False


def build_rows_from(
Expand All @@ -168,7 +239,7 @@ def build_rows_from(
return []
folded = is_folded(rows)
ends = [row.beat_times[0] for row in rows[1:]]
ends.append(_closing_barline(document, timeline.id, rows))
ends.append(_closing_barline(document, rows))
passes: dict[int, int] = {}
movement = 1
out: list[MeasureRow] = []
Expand Down Expand Up @@ -229,28 +300,43 @@ def is_folded(rows: Sequence[RowIn]) -> bool:
return any(row.next is not None for row in rows)


def _closing_barline(
document: tla.Document, timeline_id: str, rows: Sequence[RowIn]
) -> float:
def _closing_barline(document: tla.Document, rows: Sequence[RowIn]) -> float:
# The last beat lasts as long as the gap before it, or until the end of the
# media if that comes sooner. No beats are added to the last measure.
*before, last = [t for row in rows[-2:] for t in row.beat_times][-2:]
end = last + (last - before[0] if before else 0.0)
media_length = document.media_length
if media_length is None or document.time_unit != "seconds":
return end # the media's length is in seconds
if media_length <= last:
logger.warning(
"Timeline %s: the last beat (%s) is at or after the end of the media (%s); "
"the last measure ends one gap after it.",
timeline_id,
last,
media_length,
)
return end
last, gap = _last_gap([t for row in rows[-2:] for t in row.beat_times])
end = last + gap
media_length = _media_length(document)
if media_length is None or _ends_by(media_length, last):
return end # past the media, the gap alone; find_time_map warns
return min(end, media_length)


def _last_gap(times: Sequence[float]) -> tuple[float, float]:
# The last beat, and the gap before it, which the last measure lasts past
# it: none for a single beat. The closing barline and its warning share it.
return times[-1], (times[-1] - times[-2] if len(times) > 1 else 0.0)


def _media_length(document: tla.Document) -> float | None:
"""The media's length, when it caps the closing barline; else None.

It is in seconds, so it doesn't cap a quarters file. The format allows a
number above zero, or none; the reader doesn't check, so anything else
(text, a bool, NaN, infinity, a whole number too large for a float, zero
or less) counts as none.
"""
length: object = document.media_length
if document.time_unit != "seconds" or not _is_number(length):
return None
return length if length > 0 else None


def _ends_by(media_length: float, last_beat: float) -> bool:
# Whether the media ends at or before the last beat: one test for the
# closing barline and its warning, so they can't disagree.
return media_length <= last_beat


def find_rows(
rows: Sequence[MeasureRow],
number: int | None = None,
Expand Down
12 changes: 3 additions & 9 deletions packages/tilia-core/src/tilia_core/timemap/timemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@
from tilia_core import tla
from tilia_core.timemap._rows import rows_of
from tilia_core.timemap.table import (
EPS,
MeasureRow,
broken_row,
build_rows_from,
is_folded,
missing_time,
table_problem,
)

TOLERANCE = 0.1 # in the file's unit: a time this close to a beat is on it
EPS = 1e-6 # added to the tolerance, so float noise never decides whether a time is on a beat

FOLDED = (
"the table lists the measures played next (`next`), "
Expand Down Expand Up @@ -185,18 +184,13 @@ def build_time_map(
table = timeline.measures
if table is None or (not table.rows and timeline.components):
return None, "no measure table"
reason = missing_time(timeline) or broken_row(timeline)
reason = table_problem(timeline)
if reason is not None:
return None, reason
rows_in = rows_of(timeline)
times = [t for row in rows_in for t in row.beat_times]
if len(times) < 2:
return None, "fewer than two beats"
for a, b in itertools.pairwise(times):
if abs(b - a) < EPS:
return None, f"two beats at the same time ({a})"
if b < a:
return None, f"beats out of time order (at {b})"
if is_folded(rows_in):
return None, FOLDED
rows = build_rows_from(document, timeline, rows_in)
Expand Down
Loading
Loading