feedBack/lib/notation.py
2026-06-16 18:47:13 +02:00

341 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Notation format vocabulary and wire helpers.
The canonical notation payload for a sloppak arrangement is a per-arrangement
``notation_<id>.json`` file referenced from the arrangement entry in
``manifest.yaml`` via the ``notation:`` sub-key (see
``docs/sloppak-spec.md`` §5).
This module is the source of truth for:
- the closed vocabulary of valid clef identifiers and note durations,
- a permissive validator used by both the sloppak loader and importers,
- wire helpers that normalise measure data for WS streaming.
The schema is intentionally extensible: unknown fields round-trip through
the loader so a newer sloppak can still render on an older client that just
doesn't have visuals for the new field. Validation is strict only on the
required top-level shape (``version``, ``staves``, ``measures`` types).
Analogous to ``lib/drums.py`` for the drum tab format.
"""
from __future__ import annotations
import logging
import math
log = logging.getLogger("slopsmith.lib.notation")
# ── Vocabulary ────────────────────────────────────────────────────────────────
# Current ``version`` written by importers. Readers MUST accept any version
# they recognise; an unknown version is logged at DEBUG and passed through
# (forward-compat per sloppak-spec Principle IV).
SCHEMA_VERSION: int = 1
# Closed set of alphaTab clef identifiers.
# G2 — treble clef (standard guitar, violin, flute, …)
# F4 — bass clef (piano left hand, bass, cello, …)
# C3 — alto clef (viola)
# C4 — tenor clef (cello upper register, trombone)
# neutral — unpitched / percussion staff
CLEFS: set[str] = {"G2", "F4", "C3", "C4", "neutral"}
# Valid note duration denominators (integer powers of 2, up to 32nd note).
# 1=whole, 2=half, 4=quarter, 8=eighth, 16=sixteenth, 32=thirty-second.
DURATIONS: set[int] = {1, 2, 4, 8, 16, 32}
# Typed grace-note vocabulary (beat field ``grace``).
# "a" — acciaccatura: slashed grace, steals time from the PREVIOUS note
# (MusicXML <grace slash="yes">).
# "p" — appoggiatura: unslashed grace, steals time from the FOLLOWING note
# (MusicXML <grace>).
GRACE_TYPES: set[str] = {"a", "p"}
# Forced stem directions (note field ``stem``). Omit to let the renderer
# decide (MusicXML <stem>).
STEM_DIRECTIONS: set[str] = {"up", "down"}
# Dynamics vocabulary (beat field ``dyn``).
DYNAMICS: set[str] = {"ppp", "pp", "p", "mp", "mf", "f", "ff", "fff"}
# ── Schema validation ─────────────────────────────────────────────────────────
def validate_notation(data: object) -> tuple[bool, str]:
"""Light schema check for a parsed ``notation_<id>.json`` payload.
Returns ``(ok, reason)``. Permissive: unknown top-level fields pass
through unchanged. Strict only on the required top-level shape:
- ``data`` must be a JSON object (dict).
- ``data["version"]`` must be an int when present. A missing ``version``
key is accepted as ``SCHEMA_VERSION``. An unknown version value is
logged at DEBUG level and still accepted — forward-compat.
- ``data["staves"]`` must be a list.
- ``data["measures"]`` must be a list.
"""
if not isinstance(data, dict):
return False, "notation payload must be a JSON object"
# version — optional, must be int when present (bool subclasses int, reject)
ver = data.get("version", SCHEMA_VERSION)
if isinstance(ver, bool) or not isinstance(ver, int):
return False, "notation.version must be an integer"
if ver != SCHEMA_VERSION:
log.debug("notation: unknown schema version %r — passing through", ver)
# staves — required list
if "staves" not in data:
return False, "notation.staves is required"
if not isinstance(data["staves"], list):
return False, "notation.staves must be a list"
# measures — required list
if "measures" not in data:
return False, "notation.measures is required"
if not isinstance(data["measures"], list):
return False, "notation.measures must be a list"
return True, ""
# ── Wire helpers ──────────────────────────────────────────────────────────────
def _finite_float_wire(v: object, fallback: float = 0.0) -> float:
"""Return ``float(v)`` if finite, else ``fallback``.
Prevents NaN/Infinity from reaching the WS JSON encoder where they would
produce non-standard tokens that break browser ``JSON.parse`` calls.
"""
try:
f = float(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return fallback
return f if math.isfinite(f) else fallback
def measure_to_wire(measure: object) -> dict:
"""Normalise one measure dict for WS streaming.
- Returns a shallow copy of the measure; the input is not mutated.
- The measure-level ``t`` (start time) and ``tempo`` fields are guarded
against NaN/Infinity — non-finite values fall back to 0.0 so the
highway WS frame always carries valid JSON.
- Beat times (``beats[].t`` inside each voice of each staff) are rounded
to 3 decimal places and carry the same finite guard, matching the
precision used by ``drums.hit_to_wire``.
- Fields equal to their default value are NOT stripped here — that is the
file author's responsibility. The wire helper is pass-through with time
normalisation only.
- Malformed input (non-dict) returns an empty dict so callers can skip it.
"""
if not isinstance(measure, dict):
return {}
# Shallow-copy the top level so callers can't mutate our data.
out = dict(measure)
# Guard measure-level numeric fields against NaN/Infinity.
# ``t`` — measure start time in seconds; ``tempo`` — BPM at this measure.
for _field in ("t", "tempo"):
if _field in out and isinstance(out[_field], (int, float)) and not isinstance(out[_field], bool):
out[_field] = _finite_float_wire(out[_field])
# Deep-round beat times inside staves → voices → beats → t.
# We copy each level we touch to avoid mutating the source.
staves_raw = out.get("staves")
if isinstance(staves_raw, dict):
staves_out: dict = {}
for staff_id, staff_data in staves_raw.items():
if not isinstance(staff_data, dict):
staves_out[staff_id] = staff_data
continue
staff_out = dict(staff_data)
voices_raw = staff_out.get("voices")
if isinstance(voices_raw, list):
voices_out = []
for voice in voices_raw:
if not isinstance(voice, dict):
voices_out.append(voice)
continue
voice_out = dict(voice)
beats_raw = voice_out.get("beats")
if isinstance(beats_raw, list):
beats_out = []
for beat in beats_raw:
if not isinstance(beat, dict):
beats_out.append(beat)
continue
beat_out = dict(beat)
t = beat_out.get("t")
if isinstance(t, (int, float)) and not isinstance(t, bool):
# Guard against NaN/Infinity: json.loads accepts
# them but they serialize to invalid JSON tokens
# over the highway WS, breaking clients.
beat_out["t"] = round(_finite_float_wire(t), 3)
beats_out.append(beat_out)
voice_out["beats"] = beats_out
voices_out.append(voice_out)
staff_out["voices"] = voices_out
staves_out[staff_id] = staff_out
out["staves"] = staves_out
return out
def measures_to_wire(measures: list[dict]) -> list[dict]:
"""Vectorised ``measure_to_wire``.
Drops entries that round-trip as empty dicts (i.e. malformed non-dict
entries). Preserves source order — does NOT sort by time.
"""
out: list[dict] = []
for m in measures:
w = measure_to_wire(m)
if w:
out.append(w)
return out
# ── Notation → flat notes (for editors / falling-note renderers) ──────────────
def _beat_written_seconds(beat: dict, qn: float) -> float:
"""Written duration of one beat in seconds at quarter-note length ``qn``,
honouring dots (``n`` dots → ``2 1/2ⁿ`` × base) and tuplets (``tu`` =
``[actual, normal]`` → scale by ``normal/actual``, e.g. a ``[3, 2]`` triplet
sounds ⅔ as long)."""
dur = beat.get("dur") or 4
dot = beat.get("dot") or 0
dot_mult = (2.0 - 0.5 ** dot) if isinstance(dot, int) and dot > 0 else 1.0
tu_mult = 1.0
tu = beat.get("tu")
if isinstance(tu, (list, tuple)) and len(tu) == 2:
try:
actual, normal = float(tu[0]), float(tu[1])
if actual > 0 and normal > 0:
tu_mult = normal / actual
except (TypeError, ValueError):
pass
try:
return qn * (4.0 / float(dur)) * dot_mult * tu_mult
except (TypeError, ValueError, ZeroDivisionError):
return qn
def notation_to_notes(notation: object) -> list[dict]:
"""Flatten a notation payload to ``[{"t", "midi", "sus"}, ...]`` sorted by
``(t, midi)`` — absolute onset seconds, absolute MIDI pitch, and a sounding
duration in seconds.
The inverse direction of the lifter: turns the staves/measures/voices/beats
structure back into a flat note list a piano-roll editor or a falling-note
highway can consume. Semantics:
- **Sustain = the beat's written duration** at the local tempo (honouring
dots and tuplet ``tu`` ratios). This is the note's notated length, so it
neither stretches a short note across a following gap nor truncates a note
that rings past a later onset. Exact when the source emits tuplet ``tu``
(e.g. gp2notation) or quantised durations (the wire-lifter); a source that
omits ``tu`` for tuplets — the current MusicXML importer — yields the
printed note length, with onsets still exact (upstream follow-up).
- **Tempo carries forward** across measures (a measure without a ``tempo``
field inherits the last seen one; default 120 BPM).
- **Tied continuations fold** into the originating note's sustain (summing
written durations across beats/barlines).
- **Grace** beats and **rests** are skipped (rests also end any held notes).
"""
if not isinstance(notation, dict):
return []
# Pass 1 — gather each voice's beats in order across measures, carrying the
# written-duration fallback (seconds at that measure's tempo) per beat.
voice_timelines: dict[tuple, list[dict]] = {}
last_tempo = 120.0
for measure in notation.get("measures") or []:
if not isinstance(measure, dict):
continue
tempo = measure.get("tempo")
if tempo:
try:
v = float(tempo)
if v > 0:
last_tempo = v
except (TypeError, ValueError):
pass
qn = 60.0 / last_tempo if last_tempo > 0 else 0.5
staves = measure.get("staves")
if not isinstance(staves, dict):
continue # permissive: skip a malformed-but-loadable measure
for staff_id, staff in staves.items():
if not isinstance(staff, dict):
continue
voices = staff.get("voices")
if not isinstance(voices, list):
continue
for vi, voice in enumerate(voices):
if not isinstance(voice, dict):
continue
voice_id = voice.get("v", vi)
if not isinstance(voice_id, (int, str)):
voice_id = vi # unhashable/odd id → fall back to position
tl = voice_timelines.setdefault((staff_id, voice_id), [])
beats = voice.get("beats")
if not isinstance(beats, list):
continue
for beat in beats:
if not isinstance(beat, dict):
continue
try:
t = float(beat.get("t", 0.0))
except (TypeError, ValueError):
t = 0.0
bn = beat.get("notes")
tl.append({
"t": t,
"written": _beat_written_seconds(beat, qn),
"grace": bool(beat.get("grace")),
"rest": bool(beat.get("rest")),
"notes": bn if isinstance(bn, list) else [],
})
# Pass 2 — per voice, sustain = the beat's written duration; fold tied
# continuations into the open note for that pitch.
out: list[dict] = []
for tl in voice_timelines.values():
# Tie folding assumes chronological beats; a hand-written file (or a
# future editor reorder) may not be ordered. Stable sort keeps
# same-onset (chord) beats in source order.
tl.sort(key=lambda b: b["t"])
opens: dict[int, dict] = {}
for b in tl:
if b["grace"]:
continue
dur_secs = b["written"]
if b["rest"]:
opens.clear()
continue
present: set[int] = set()
for n in b["notes"]:
if not isinstance(n, dict):
continue
try:
midi = int(n.get("midi"))
except (TypeError, ValueError):
continue
if not 0 <= midi <= 127:
continue
present.add(midi)
if n.get("tied") and midi in opens:
opens[midi]["sus"] = round(opens[midi]["sus"] + dur_secs, 4)
else:
note = {"t": round(b["t"], 4), "midi": midi, "sus": round(dur_secs, 4)}
out.append(note)
opens[midi] = note
for p in [p for p in opens if p not in present]:
del opens[p]
out.sort(key=lambda n: (n["t"], n["midi"]))
return out