mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
* 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>
341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""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
|