Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
165 changes: 165 additions & 0 deletions packages/tilia-core/src/tilia_core/timemap/lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Bars back to times: the times of a bar and a fraction of it, one per time
through the bar, as TiLiA's import by measure asks for them.

A port of TiLiA's `BeatTimeline.get_time_by_measure`, which looks among every
pass's beats at once, with one time for each row the bar names instead
(TimeLineAnnotator/project-management#315):

- the fraction counts the row's own beats, and between two beats the time is
interpolated; a fraction that, at 3 decimals, is a beat's (0.333 of three
beats) is that beat, as TiLiA rounds;
- the fraction 1 is the next downbeat when the next bar is numbered one more,
and no time otherwise; as a segment's end, it is the row's end;
- past the last beat, the last row lasts as long as the row before it
(TiLiA's projection), not until FR-011's closing barline;
- as a segment's end, the fraction 0 also gives where each time through the
bar numbered just below ends (#321);
- a bar no row names gives no time.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Sequence

from tilia_core.timemap.table import MeasureRow, find_rows

logger = logging.getLogger(__name__)

DECIMALS = 3 # TiLiA rounds a bar number plus its fraction to this many


def times_of(
rows: Sequence[MeasureRow],
times: Sequence[float],
first: Sequence[int],
number: int | None = None,
fraction: float = 0.0,
*,
label: str | None = None,
pass_: int | None = None,
movement: int | None = None,
segment_end: bool = False,
) -> list[float]:
"""The times of `fraction` through the rows a bar names, in time order.

`rows` are a time map's rows in the timeline's order, `times` its beats,
and `first[j]` the index in `times` of row j's downbeat. The rows are
named as `find_rows` names them. A fraction outside 0 to 1 gives no time,
with a warning.
"""
if not 0.0 <= fraction <= 1.0: # NaN too
logger.warning(
"A fraction of a bar runs from 0 to 1, not %r; no time.", fraction
)
return []
named = find_rows(rows, number, label=label, pass_=pass_, movement=movement)
found = [
time
for row in named
if (time := _time_in(rows, times, first, row, fraction, segment_end))
is not None
]
if segment_end:
found += _ends_before(rows, named, fraction, pass_=pass_, movement=movement)
return _distinct(found)


def _time_in(
rows: Sequence[MeasureRow],
times: Sequence[float],
first: Sequence[int],
row: MeasureRow,
fraction: float,
segment_end: bool,
) -> float | None:
j = row.count - 1 # a time map keeps every row of its table
beat = _beat_at(row, fraction)
if beat == row.beats: # the fraction 1
if segment_end:
return _end(rows, j)
following = rows[j + 1] if j + 1 < len(rows) else None
if following is not None and following.number == row.number + 1:
return following.start
return None
if beat is not None:
return times[first[j] + beat]
position = fraction * row.beats
k = int(position) # below row.beats: a fraction this close to 1 is at 1
start = times[first[j] + k]
end = times[first[j] + k + 1] if k + 1 < row.beats else _end(rows, j)
if end is None:
return None
return start + (position - k) * (end - start)


def _beat_at(row: MeasureRow, fraction: float) -> int | None:
"""The beat (0-based) that `fraction` is at, rounded as TiLiA rounds;
`row.beats` for the fraction 1, None between beats."""
target = round(row.number + fraction, DECIMALS)
if target == row.number + 1:
return row.beats
for k in range(row.beats):
if target == round(row.number + k / row.beats, DECIMALS):
return k
return None


def _end(rows: Sequence[MeasureRow], j: int) -> float | None:
"""Where row j ends: the next downbeat, or past the last beat, as long
after its downbeat as the row before it lasted. None for a table of one
row, which has nothing to measure it by."""
if j + 1 < len(rows):
return rows[j].end
if j == 0:
return None
row = rows[j]
return row.start + (row.start - rows[j - 1].start)


def _ends_before(
rows: Sequence[MeasureRow],
named: Sequence[MeasureRow],
fraction: float,
*,
pass_: int | None,
movement: int | None,
) -> list[float]:
"""As a segment's end, the fraction 0 of bar N: where each time through
the bar numbered just below N ends, in the pass and movement asked for.

An end that leads into a bar N the query doesn't name is left out, so a
label or a pass gives only the ends that lead into the bars it names;
without them, every bar N is named.
"""
named_counts = {row.count for row in named}
ends: list[float] = []
for number in sorted({row.number for row in named if _beat_at(row, fraction) == 0}):
below = [
row.number
for row in rows
if row.number < number
and not row.cadenza
and (movement is None or row.movement == movement)
]
if not below:
continue
others = {row.count for row in find_rows(rows, number)} - named_counts
for before in find_rows(rows, max(below), pass_=pass_, movement=movement):
j = before.count - 1
if j + 1 < len(rows) and rows[j + 1].count in others:
continue
end = _end(rows, j)
if end is not None:
ends.append(end)
return ends


def _distinct(times: list[float]) -> list[float]:
"""In time order, without times as close as TiLiA's lookup counts the same."""
out: list[float] = []
for time in sorted(times):
if not out or not math.isclose(time, out[-1]):
out.append(time)
return out
5 changes: 4 additions & 1 deletion packages/tilia-core/src/tilia_core/timemap/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,11 @@ def find_rows(
*,
label: str | None = None,
pass_: int | None = None,
movement: int | None = None,
) -> list[MeasureRow]:
"""The rows a bar names: by number (every pass, cadenzas left out), by label
(cadenzas included), or both; `pass_` narrows them to one time through.
(cadenzas included), or both; `pass_` narrows them to one time through, and
`movement` to one movement, else every movement matches.
Nothing, when neither a number nor a label is given."""
if number is None and label is None:
return []
Expand All @@ -270,4 +272,5 @@ def find_rows(
and (label is None or row.label == label)
and (label is not None or not row.cadenza)
and (pass_ is None or row.pass_ == pass_)
and (movement is None or row.movement == movement)
]
43 changes: 43 additions & 0 deletions packages/tilia-core/src/tilia_core/timemap/timemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
from dataclasses import dataclass, field

from tilia_core import tla
from tilia_core.timemap import lookup
from tilia_core.timemap._rows import rows_of
from tilia_core.timemap.table import (
MeasureRow,
broken_row,
build_rows_from,
find_rows,
is_folded,
missing_time,
)
Expand Down Expand Up @@ -139,6 +141,47 @@ def positions(self, start: float, end: float) -> Positions | None:
movement=row.movement,
)

def find(
self,
number: int | None = None,
*,
label: str | None = None,
pass_: int | None = None,
movement: int | None = None,
) -> list[MeasureRow]:
"""The measures a bar names, in the timeline's order: by number (every
pass, cadenzas left out), by label (cadenzas included), or both;
narrowed to one pass, and to one movement, else every movement."""
return find_rows(
self._rows, number, label=label, pass_=pass_, movement=movement
)

def times_of(
self,
number: int | None = None,
fraction: float = 0.0,
*,
label: str | None = None,
pass_: int | None = None,
movement: int | None = None,
segment_end: bool = False,
) -> list[float]:
"""The times of `fraction` (0 to 1, counted in beats) through each
measure the bar names, in time order: one per pass, as an import by
measure asks for them (`lookup.times_of`). `segment_end` is for the
end of a span."""
return lookup.times_of(
self._rows,
self._times,
self._first,
number,
fraction,
label=label,
pass_=pass_,
movement=movement,
segment_end=segment_end,
)

def _snapped(self, t: float | None) -> float | None:
"""`t` moved onto the nearer beat slot, or the closing barline, when it
lies within the tolerance of one. None for a missing time."""
Expand Down
Loading
Loading