feedBack/lib/drums.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

290 lines
13 KiB
Python

"""Drum kit vocabulary, presets, and drum_tab.json helpers.
The canonical drum payload in a sloppak is a top-level `drum_tab.json` file
referenced from `manifest.yaml` via the `drum_tab:` key (see
`docs/sloppak-spec.md` §5.3). This module is the source of truth for:
- the closed list of drum piece-ids that a `drum_tab.json` may reference,
- their default GM percussion MIDI notes and visual category,
- preset lane configurations for the drums plugin,
- a permissive validator + short-key wire helper used by both the writer
side (importers) and the reader side (sloppak loader + highway WS).
The schema is intentionally extensible: unknown piece-ids round-trip through
the loader so a newer sloppak can still play on an older client that just
doesn't have visuals for the new piece. Validation is strict only on the
top-level shape (`version`, `kit`, `hits` types).
"""
from __future__ import annotations
import logging
import math
log = logging.getLogger("feedBack.lib.drums")
# ── Piece vocabulary ──────────────────────────────────────────────────────────
#
# Each entry pins a closed piece-id to its default General MIDI percussion
# note(s), a category (kick/drum/cymbal — drives default shape rendering), and
# a default colour. The drums plugin reads this map on startup and uses the
# defaults to seed the user's lane configuration; users can override colours
# and shapes per lane in localStorage.
PIECES: dict[str, dict] = {
# Kick — full-width bar across all non-kick lanes.
"kick": {"midi": [35, 36], "category": "kick", "shape": "bar", "color": "#f59e0b"},
# Drums proper — rectangles. Toms ordered hi→floor.
"snare": {"midi": [38, 40], "category": "drum", "shape": "rect", "color": "#ef4444"},
"snare_xstick": {"midi": [37], "category": "drum", "shape": "rect_hatched", "color": "#dc2626"},
"tom_hi": {"midi": [50, 48], "category": "drum", "shape": "rect", "color": "#eab308"},
"tom_mid": {"midi": [47, 45], "category": "drum", "shape": "rect", "color": "#ca8a04"},
"tom_low": {"midi": [43], "category": "drum", "shape": "rect", "color": "#a16207"},
"tom_floor": {"midi": [41], "category": "drum", "shape": "rect", "color": "#854d0e"},
# Cymbals — circles. Open/closed hi-hat are distinct piece-ids, not a
# per-hit articulation flag, because hit detection must reject a
# closed-hat strike on an open-hat note (and vice versa).
"hh_closed": {"midi": [42], "category": "cymbal", "shape": "circle_filled", "color": "#22d3ee"},
"hh_open": {"midi": [46], "category": "cymbal", "shape": "circle_ring", "color": "#06b6d4"},
"hh_pedal": {"midi": [44], "category": "cymbal", "shape": "circle_small_x", "color": "#0891b2"},
# Stack — two cymbals stacked for a trashy/choked effect. GM has no
# standard for it; we reuse 30 (in GM's extended-percussion range,
# unused by real drum-kit MIDIs).
"stack": {"midi": [30], "category": "cymbal", "shape": "circle_jagged", "color": "#94a3b8"},
"crash_l": {"midi": [49], "category": "cymbal", "shape": "circle", "color": "#84cc16"},
"crash_r": {"midi": [57], "category": "cymbal", "shape": "circle", "color": "#65a30d"},
"splash": {"midi": [55], "category": "cymbal", "shape": "circle_small", "color": "#a3e635"},
"china": {"midi": [52], "category": "cymbal", "shape": "circle_jagged", "color": "#4d7c0f"},
"ride": {"midi": [51, 59], "category": "cymbal", "shape": "circle", "color": "#3b82f6"},
"ride_bell": {"midi": [53], "category": "cymbal", "shape": "circle_dot", "color": "#1d4ed8"},
# Bell cymbal — a small mounted bell, distinct from the ride's bell.
# No GM standard; we reuse 80 ("Mute Triangle"), unused in real
# drum-kit MIDIs.
"bell": {"midi": [80], "category": "cymbal", "shape": "circle_dot", "color": "#fde047"},
}
# Reverse map MIDI note → piece-id. First piece-id whose `midi` list contains
# the note wins (PIECES is iteration-ordered so the "preferred" piece-id for a
# shared MIDI is the one declared earlier). Built once at import time.
_MIDI_TO_PIECE: dict[int, str] = {}
for _pid, _meta in PIECES.items():
for _m in _meta["midi"]:
_MIDI_TO_PIECE.setdefault(_m, _pid)
def midi_to_piece(midi: int) -> str | None:
"""Return the canonical piece-id for a GM percussion MIDI note, or None
if the note isn't mapped (e.g. cowbell, tambourine — extensible later)."""
return _MIDI_TO_PIECE.get(int(midi))
def piece_to_default_midi(piece: str) -> list[int]:
"""Return the GM MIDI notes that map to `piece` by default. Empty list for
unknown piece-ids — callers should treat that as "unmapped" rather than
crashing, so a newer sloppak's unknown piece round-trips silently."""
entry = PIECES.get(piece)
return list(entry["midi"]) if entry else []
def piece_default_shape(piece: str) -> str:
"""Default rendering shape for a piece-id. `"rect"` fallback so an
unknown piece still draws something the user can see."""
entry = PIECES.get(piece)
return entry["shape"] if entry else "rect"
def piece_default_color(piece: str) -> str:
"""Default colour for a piece-id. Neutral grey fallback for unknown."""
entry = PIECES.get(piece)
return entry["color"] if entry else "#9ca3af"
def piece_category(piece: str) -> str:
"""Category (`kick`/`drum`/`cymbal`) — `"drum"` fallback for unknown."""
entry = PIECES.get(piece)
return entry["category"] if entry else "drum"
# ── Preset lane configurations ────────────────────────────────────────────────
#
# Each preset is a list of `lane` dicts. A lane carries:
# - `pieces`: list of piece-ids that route to this lane (multiple → shared)
# - `label`: short header text
# Visual fields (color, shape, weight) are optional; the renderer falls back
# to the per-piece defaults above. The drums plugin layers user customisation
# on top of these.
PRESET_RB4 = [
{"pieces": ["kick"], "label": "Ki"},
{"pieces": ["snare", "snare_xstick"], "label": "Sn"},
{"pieces": ["hh_closed", "hh_open", "hh_pedal"], "label": "HH"},
{"pieces": ["tom_hi", "tom_mid"], "label": "T"},
{"pieces": ["tom_low", "tom_floor"], "label": "FT"},
{"pieces": ["crash_l", "crash_r", "splash", "china", "stack"], "label": "Cr"},
{"pieces": ["ride", "ride_bell", "bell"], "label": "Ri"},
]
# 8-lane layout matching the legacy drums plugin v3 (HH / Sn / T1 / T2 / T3 /
# Cr / Ri / Ki) so existing sloppaks keep their familiar lane order when the
# rewrite ships.
PRESET_PHASESHIFT8 = [
{"pieces": ["hh_closed", "hh_open", "hh_pedal"], "label": "HH"},
{"pieces": ["snare", "snare_xstick"], "label": "Sn"},
{"pieces": ["tom_hi"], "label": "T1"},
{"pieces": ["tom_mid"], "label": "T2"},
{"pieces": ["tom_low", "tom_floor"], "label": "T3"},
{"pieces": ["crash_l", "crash_r", "splash", "china", "stack"], "label": "Cr"},
{"pieces": ["ride", "ride_bell", "bell"], "label": "Ri"},
{"pieces": ["kick"], "label": "Ki"},
]
# One lane per piece-id — for users with a full e-kit who want every piece on
# its own column. Order roughly mirrors a physical kit left→right.
PRESET_EKIT_FULL = [
{"pieces": ["hh_pedal"], "label": "HH-p"},
{"pieces": ["hh_closed"], "label": "HH-c"},
{"pieces": ["hh_open"], "label": "HH-o"},
{"pieces": ["snare_xstick"], "label": "Sn-x"},
{"pieces": ["snare"], "label": "Sn"},
{"pieces": ["tom_hi"], "label": "T1"},
{"pieces": ["tom_mid"], "label": "T2"},
{"pieces": ["tom_low"], "label": "T3"},
{"pieces": ["tom_floor"], "label": "FT"},
{"pieces": ["stack"], "label": "Stk"},
{"pieces": ["crash_l"], "label": "Cr-L"},
{"pieces": ["splash"], "label": "Sp"},
{"pieces": ["china"], "label": "Ch"},
{"pieces": ["ride"], "label": "Ri"},
{"pieces": ["ride_bell"], "label": "Ri-B"},
{"pieces": ["bell"], "label": "Bl"},
{"pieces": ["crash_r"], "label": "Cr-R"},
{"pieces": ["kick"], "label": "Ki"},
]
PRESETS: dict[str, list[dict]] = {
"rb4": PRESET_RB4,
"phase_shift_8": PRESET_PHASESHIFT8,
"ekit_full": PRESET_EKIT_FULL,
}
# ── drum_tab.json schema helpers ──────────────────────────────────────────────
# Default velocity when a hit omits `v`. Matches spec §5.3 ("v is optional,
# defaults to 100 — keeps simple charts terse").
DEFAULT_VELOCITY = 100
# Current `version` written by importers. Readers MUST accept any version they
# recognise; an unknown version is logged at DEBUG level on every call to
# validate_drum_tab() and the payload is still passed
# through (per Principle IV, additive evolution).
SCHEMA_VERSION = 1
def validate_drum_tab(data: object) -> tuple[bool, str]:
"""Light schema check for a parsed `drum_tab.json` payload.
Returns `(ok, reason)`. Accepts both `version: 1` (current) and absent
`version` (treat as 1) for forward-compat with hand-edited tabs.
`hits[]` is required and must be a list; individual hits are NOT
validated here — per-hit filtering happens in `hit_to_wire()` /
`hits_to_wire()` at WS-stream time, so a single malformed hit cannot
disqualify the whole tab.
"""
if not isinstance(data, dict):
return False, "drum_tab payload must be a JSON object"
hits = data.get("hits")
if not isinstance(hits, list):
return False, "drum_tab.hits must be a list"
kit = data.get("kit", [])
if kit is not None and not isinstance(kit, list):
return False, "drum_tab.kit must be a list (or omitted)"
ver = data.get("version", SCHEMA_VERSION)
if isinstance(ver, bool) or not isinstance(ver, int):
return False, "drum_tab.version must be an integer"
if ver != SCHEMA_VERSION:
log.debug("drum_tab: unknown schema version %r — passing through", ver)
return True, ""
def hit_to_wire(hit: dict) -> dict | None:
"""Normalise one hit dict into the short-key wire form streamed by
`/ws/highway/{filename}`. Returns None on a malformed hit (missing `t`
or `p`) so the loader can drop just that entry without aborting the
whole tab.
Wire keys (all optional except `t`, `p`):
t float seconds required, monotonic
p string piece-id required, free-form (validated against PIECES
by the client; unknown ids render as `"rect"`)
v int 1-127 velocity (omitted when absent; client defaults
to DEFAULT_VELOCITY)
g bool ghost note
f bool flam
k float seconds cymbal-choke tail duration
"""
if not isinstance(hit, dict):
return None
t_raw = hit.get("t")
if isinstance(t_raw, bool):
return None
try:
t = float(t_raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
if not math.isfinite(t):
return None
p = hit.get("p")
if not isinstance(p, str) or not p:
return None
out: dict = {"t": round(t, 3), "p": p}
v = hit.get("v")
if not isinstance(v, bool) and isinstance(v, (int, float)) and math.isfinite(v) and 1 <= int(v) <= 127:
out["v"] = int(v)
if bool(hit.get("g")):
out["g"] = True
if bool(hit.get("f")):
out["f"] = True
k = hit.get("k")
if not isinstance(k, bool) and isinstance(k, (int, float)) and math.isfinite(k) and k > 0:
out["k"] = round(float(k), 3)
return out
def hits_to_wire(hits: list[dict]) -> list[dict]:
"""Vectorised `hit_to_wire` — drops malformed entries, sorts by time."""
out: list[dict] = []
for h in hits:
w = hit_to_wire(h)
if w is not None:
out.append(w)
out.sort(key=lambda h: h["t"])
return out
def normalise_kit(kit: list | None) -> list[dict]:
"""Normalise the `kit[]` legend: each entry becomes `{"id": str, "name":
str}`. Unknown piece-ids are kept (forward-compat) with a title-cased
fallback name. Returns an empty list for missing/empty kit (the client
will derive the kit from the union of `hits[].p` in that case)."""
if not isinstance(kit, list):
return []
out: list[dict] = []
seen: set[str] = set()
for entry in kit:
if not isinstance(entry, dict):
continue
pid = entry.get("id")
if not isinstance(pid, str) or not pid or pid in seen:
continue
seen.add(pid)
name = entry.get("name")
if not isinstance(name, str) or not name:
name = pid.replace("_", " ").title()
out.append({"id": pid, "name": name})
return out