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
19 changes: 17 additions & 2 deletions packages/tilia-core/src/tilia_core/timemap/_rows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from tilia_core import tla
from tilia_core.timemap.units import parse_units, validate_denominator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -93,19 +94,33 @@ def _dict(component: tla.Component | None, key: str) -> dict[str, Any]:


def _beat_unit(timeline: tla.Timeline, component: tla.Component) -> tla.BeatUnit | None:
"""The unit set on a beat; None, with a warning, when it can't be read."""
value = component.attrs.get("beat_unit")
if value is None:
return None
try:
return tla.BeatUnit(
unit: tla.BeatUnit | None = tla.BeatUnit(
denominator=value["denominator"],
units=value["units"],
assumed=bool(value.get("assumed", False)),
)
except (KeyError, TypeError):
unit = None
if unit is None or not _readable(unit):
logger.warning(
"Timeline %s: beat %s has a beat unit that can't be read; ignored.",
"Timeline %s: beat %s has a beat unit that can't be read (%.60r); ignored.",
timeline.id,
component.id,
value,
)
return None
return unit


def _readable(unit: tla.BeatUnit) -> bool:
# What desktop#621 refuses to create, and so drops from a file it loads.
return (
isinstance(unit.units, str)
and parse_units(unit.units).is_valid
and validate_denominator(unit.denominator)
)
94 changes: 94 additions & 0 deletions packages/tilia-core/src/tilia_core/timemap/meters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Each measure's meter, from the beat units set on its beats.

The rules are those of desktop#621 (beat units and time signatures): its
`BeatTimeline._compute_measure_meters`, moved, with `units.py` beside it.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from fractions import Fraction

from tilia_core import tla
from tilia_core.timemap._rows import RowIn
from tilia_core.timemap.units import (
DEFAULT_DENOMINATOR,
DEFAULT_UNITS,
MeasureMeter,
fit_units,
is_fit_ambiguous,
parse_units,
)


@dataclass(frozen=True)
class _BeatUnit:
"""A beat unit as desktop#621's component holds it, with its beat's id as its id.

Its units are parsed once for each call of `meters_of`.
"""

id: str
denominator: int
unit_values: tuple[Fraction, ...]
assumed: bool


def meters_of(rows: Sequence[RowIn]) -> list[MeasureMeter]:
"""
The meter of every measure, from the beat units in effect: the loop of
desktop#621's `BeatTimeline._compute_measure_meters`. The first unit set
in a measure governs it and is carried forward; any other in that measure
makes it conflicting. `rows_of` has already left out the units that can't
be read, so the unit before such a unit carries on.

Before the first unit, the measures take its denominator and units,
assumed; with no unit at all, a measure of N taps is N/4, assumed. Either
way measure 1 starts a unit, but none has a `beat_unit_id`, since no unit
sits on their beats. Only here does this differ from #621's loop, which
never meets such a timeline: #621's loader keeps a unit in the first
measure (`_ensure_first_beat_unit`), copied from the first unit or the
default.
"""
first = next((_parsed(*unit) for row in rows for unit in row.units), None)
if first is None:
opening = DEFAULT_DENOMINATOR, tuple(parse_units(DEFAULT_UNITS).units)
else:
opening = first.denominator, first.unit_values

meters = []
governing: _BeatUnit | None = None
for index, row in enumerate(rows):
beat_count = len(row.beat_ids)
in_measure = [_parsed(beat_id, unit) for beat_id, unit in row.units]
if in_measure:
governing = in_measure[0]

if governing is None:
denominator, units = opening[0], list(opening[1])
else:
denominator, units = governing.denominator, list(governing.unit_values)

meters.append(
MeasureMeter(
denominator=denominator,
units=units,
beat_values=fit_units(units, beat_count),
beat_unit_id=governing.id if governing else None,
starts_here=bool(in_measure) or index == 0,
is_ambiguous=is_fit_ambiguous(units, beat_count),
is_conflicting=len(in_measure) > 1,
is_assumed=governing is None or governing.assumed,
)
)
return meters


def _parsed(beat_id: str, unit: tla.BeatUnit) -> _BeatUnit:
return _BeatUnit(
id=beat_id,
denominator=unit.denominator,
unit_values=tuple(parse_units(unit.units).units),
assumed=unit.assumed,
)
121 changes: 121 additions & 0 deletions packages/tilia-core/src/tilia_core/timemap/units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Beat units: what one tapped beat is worth in notation.

A beat unit is a denominator plus a units pattern, the number of
denominator units each tapped beat in a measure is worth. Units are written
as a "+"-separated list of whole numbers or fractions: "1", "3", "2+3",
"1/2", "1/3". The time signature of a measure is calculated from its beat
unit and number of tapped beats, and is never stored.
"""

from __future__ import annotations

from dataclasses import dataclass
from fractions import Fraction

DEFAULT_DENOMINATOR = 4
DEFAULT_UNITS = "1"
# Far above any denominator used in notation. Enforced wherever a beat unit
# is set, so the dialog and inspector, which can't show more, never meet one.
MAX_DENOMINATOR = 128

_UNIT_CHARACTERS = set("0123456789/")


def validate_denominator(value: int) -> bool:
return (
isinstance(value, int)
and not isinstance(value, bool)
and 1 <= value <= MAX_DENOMINATOR
)


@dataclass(frozen=True)
class UnitsParseResult:
units: list[Fraction]
error: str = ""

@property
def is_valid(self) -> bool:
return not self.error


def parse_units(text: str) -> UnitsParseResult:
"""Parses units text such as "2+3" or "1/3" into fractions."""
if not text.strip():
return UnitsParseResult([], "Enter at least one unit.")

units = []
for item in text.split("+"):
item = item.strip()
if not item:
return UnitsParseResult([], "Missing unit around '+'.")
not_a_fraction = UnitsParseResult(
[], f"'{item}' is not a whole number or fraction such as 1/2."
)
# Fraction() also accepts decimals, signs and exponents; units are
# written the way they are in notation.
if not set(item) <= _UNIT_CHARACTERS:
return not_a_fraction
try:
unit = Fraction(item)
except (ValueError, ZeroDivisionError):
return not_a_fraction
if unit <= 0:
return UnitsParseResult([], "Units must be greater than 0.")
units.append(unit)

return UnitsParseResult(units)


def format_units(units: list[Fraction]) -> str:
"""Writes units in canonical form, e.g. [2, 3] as "2+3"."""
return "+".join(str(unit) for unit in units)


def fit_units(units: list[Fraction], beat_count: int) -> list[Fraction]:
"""
The value of each of `beat_count` tapped beats in a measure. The pattern
is read from its start in every measure, pickups included: a shorter
measure takes its first entries and a longer one repeats it. A pickup
that holds the end of the pattern needs a beat unit of its own.
"""
return [units[i % len(units)] for i in range(beat_count)]


def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool:
"""
Whether the measure doesn't hold a whole number of patterns: the pattern
is not uniform and the beat count is not a multiple of its length. One
tap under 2+3 is read as 2, but could be a pickup worth 3.
"""
return len(set(units)) > 1 and beat_count % len(units) != 0


@dataclass(frozen=True)
class MeasureMeter:
"""
The meter a measure ends up with. `beat_unit_id` is the beat unit that
governs the measure, or None where the default applies. `is_assumed` is
True when its values weren't set by the user (see BeatUnit.assumed).
"""

denominator: int
units: list[Fraction]
beat_values: list[Fraction]
beat_unit_id: int | None
starts_here: bool
is_ambiguous: bool
is_conflicting: bool
is_assumed: bool

@property
def numerator(self) -> Fraction:
return sum(self.beat_values, Fraction(0))

@property
def time_signature(self) -> tuple[Fraction, int]:
return self.numerator, self.denominator

def quarter_length(self, beat_value: Fraction) -> Fraction:
return beat_value * 4 / self.denominator
Loading
Loading