Skip to content
Merged
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
118 changes: 118 additions & 0 deletions packages/tilia-core/src/tilia_core/timemap/qstamps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Quarter stamps: each beat's position in quarter notes, and the conversion
between times and quarter stamps.

Moved from desktop#621's BeatTimeline (`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, over a beat timeline's beat times and its measures' meters instead of
the app's components. Stamps are exact inside, and floats where they meet times.
`beats_between`, which says which beats' lengths a span's quarter notes count,
is the core's own.
"""

from __future__ import annotations

import itertools
from bisect import bisect, bisect_left
from collections.abc import Sequence
from dataclasses import dataclass
from fractions import Fraction

from tilia_core.timemap.units import MeasureMeter


@dataclass(frozen=True)
class TimeStampTable:
"""
Per beat: its time, its quarter stamp and how many seconds a quarter note
lasts from it to the next beat, for converting between the two. Seconds
are the file's unit, which is quarter notes in a quarters file.
"""

times: tuple[float, ...]
stamps: tuple[float, ...]
seconds_per_quarter: tuple[float, ...]


def beat_lengths(meter: MeasureMeter) -> list[Fraction]:
"""How long each beat of a measure is, in quarter notes."""
return [meter.quarter_length(value) for value in meter.beat_values]


def quarter_stamps(lengths: Sequence[Fraction]) -> list[Fraction]:
"""
Each beat's position in quarter notes, from 0 at the first beat (pickup
included): the sum of the lengths of the beats before it, as Verovio's and
the Measure Map's "qstamp".
"""
stamps = [Fraction(0)] + list(itertools.accumulate(lengths[:-1]))
return stamps[: len(lengths)]


def build_time_stamp_table(
times: Sequence[float], lengths: Sequence[Fraction]
) -> TimeStampTable | None:
"""The table for beats at `times`, `lengths` quarter notes long; None with
fewer than two beats."""
if len(times) < 2:
return None
seconds_per_quarter = [
(times[index + 1] - times[index]) / float(lengths[index])
for index in range(len(times) - 1)
]
# The last beat has no next beat to measure against, so the tempo of
# the gap before it carries on past it. Its own length doesn't enter:
# that gap lasted the previous beat's length, not the last one's.
# (beats_between follows this rule too.)
seconds_per_quarter.append(seconds_per_quarter[-1])
return TimeStampTable(
times=tuple(times),
stamps=tuple(float(stamp) for stamp in quarter_stamps(lengths)),
seconds_per_quarter=tuple(seconds_per_quarter),
)


def quarter_stamp_by_time(table: TimeStampTable | None, time: float) -> float | None:
"""
The quarter stamp at `time`, interpolated between beats and
extrapolated beyond them. None with fewer than two beats.
"""
if table is None:
return None
index = max(0, min(bisect(table.times, time) - 1, len(table.times) - 1))
return (
table.stamps[index]
+ (time - table.times[index]) / table.seconds_per_quarter[index]
)


def beats_between(times: Sequence[float], start: float, end: float) -> range:
"""
The beats whose lengths the quarter notes between `start` and `end` count,
in either order, by index: each beat whose gap the span covers. Before the
first beat the first gap's tempo applies, and after the last beat the last
gap's, as `quarter_stamp_by_time` extrapolates. An end exactly on a beat
takes nothing from it, and a point takes its own gap. Empty with fewer than
two beats.
"""
if len(times) < 2:
return range(0)
start, end = sorted((start, end))
last = len(times) - 2 # the last beat's own length never enters
first = min(max(bisect(times, start) - 1, 0), last)
stop = min(max(bisect_left(times, end) - 1, first), last)
return range(first, stop + 1)


def time_by_quarter_stamp(table: TimeStampTable | None, stamp: float) -> float | None:
"""
The time of quarter stamp `stamp`, interpolated between beats and
extrapolated beyond them. None with fewer than two beats.
"""
if table is None:
return None
index = max(0, min(bisect(table.stamps, stamp) - 1, len(table.stamps) - 1))
return (
table.times[index]
+ (stamp - table.stamps[index]) * table.seconds_per_quarter[index]
)
40 changes: 30 additions & 10 deletions packages/tilia-core/src/tilia_core/timemap/table.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""A beat timeline's measure table: each measure's position, pass, movement,
start and end, meter and time signature."""
start and end, meter and time signature, and its start and length in quarter
notes."""

from __future__ import annotations

Expand All @@ -13,6 +14,7 @@
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.qstamps import beat_lengths
from tilia_core.timemap.units import MeasureMeter, format_units

# Float noise: two beats closer than this are at the same time, and a time this
Expand Down Expand Up @@ -78,29 +80,35 @@ class MeasureRow:
beat_ids: tuple[str, ...]
beats: int # the beats tapped
meter: MeasureMeter # desktop#621's, from the beat units in effect
qstamp: Fraction # its downbeat's position in quarter notes, from the first beat
# Computed from the meter, so nothing can make them disagree with it:
time_signature: str = field(init=False) # "6/8", "2+2+3/8", "(4/3)/4"
ts_numerator: Fraction = field(init=False) # the beat values' sum, however grouped
ts_denominator: int = field(init=False)
assumed: bool = field(init=False) # nobody set the unit that governs it
ambiguous: bool = field(init=False) # the taps don't hold whole patterns
conflicting: bool = field(init=False) # more than one unit is set on its beats
quarters: Fraction = field(init=False) # how long it lasts in quarter notes
source: str
force_display: bool
next: tuple[str, ...] | None
metadata: FrozenMetadata

def __post_init__(self) -> None:
meter = self.meter
numerator = meter.numerator
computed = {
"time_signature": format_time_signature(
meter.beat_values, meter.denominator
),
"ts_numerator": meter.numerator,
"ts_numerator": numerator,
"ts_denominator": meter.denominator,
"assumed": meter.is_assumed,
"ambiguous": meter.is_ambiguous,
"conflicting": meter.is_conflicting,
# The sum of its beats' lengths, as its time signature's numerator
# is the sum of their values.
"quarters": meter.quarter_length(numerator),
}
for name, value in computed.items():
object.__setattr__(self, name, value)
Expand Down Expand Up @@ -161,8 +169,9 @@ 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.
With more than one beat, the last beat lasts its own length at the tempo
of the gap before it, and the warning says the last measure ends one gap
after it; with one, it ends on the beat.
"""
reason, times = _checked(timeline)
if reason is not None or not times:
Expand Down Expand Up @@ -238,12 +247,13 @@ def build_rows_from(
if not rows:
return []
folded = is_folded(rows)
meters = meters_of(rows)
ends = [row.beat_times[0] for row in rows[1:]]
ends.append(_closing_barline(document, rows))
ends.append(_closing_barline(document, rows, meters))
passes: dict[int, int] = {}
movement = 1
qstamp = Fraction(0)
out: list[MeasureRow] = []
meters = meters_of(rows)
for index, (row, end, meter) in enumerate(zip(rows, ends, meters, strict=True)):
count = index + 1
if row.restart and count > 1: # the first row is in movement 1 anyway
Expand All @@ -268,12 +278,14 @@ def build_rows_from(
beat_ids=row.beat_ids,
beats=len(row.beat_ids),
meter=meter,
qstamp=qstamp,
source=row.source,
force_display=row.force_display,
next=row.next,
metadata=FrozenMetadata(row.metadata),
)
)
qstamp += out[-1].quarters
return out


Expand All @@ -300,14 +312,22 @@ def is_folded(rows: Sequence[RowIn]) -> bool:
return any(row.next is not None for row in rows)


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.
def _closing_barline(
document: tla.Document, rows: Sequence[RowIn], meters: Sequence[MeasureMeter]
) -> float:
# The last beat lasts its own length at the tempo of the gap before it, or
# until the end of the media if that comes sooner: the tempo at which the
# time map's quarter notes go on past it (FR-011, FR-051). When the last
# two beats are worth the same, that is the gap itself. No beats are added
# to the last measure.
last, gap = _last_gap([t for row in rows[-2:] for t in row.beat_times])
lengths = [length for meter in meters[-2:] for length in beat_lengths(meter)]
if len(lengths) > 1:
gap *= float(lengths[-1] / lengths[-2])
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 end # past the media, that length alone; find_time_map warns
return min(end, media_length)


Expand Down
Loading
Loading