feedBack/lib/notation.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

341 lines
14 KiB
Python
Raw Permalink 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("feedBack.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