mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Clean release snapshot
This commit is contained in:
+289
@@ -0,0 +1,289 @@
|
||||
"""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("slopsmith.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
|
||||
Reference in New Issue
Block a user