diff --git a/auth.py b/auth.py index bd685f3..c36999a 100644 --- a/auth.py +++ b/auth.py @@ -30,6 +30,8 @@ import time HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_DB = os.path.join(HERE, "godstrument_users.db") +FACTORY_DIR = os.path.join(HERE, "factory") # ✦ CANON — git-tracked, read-only patches that ship with the instrument +FACTORY_PREFIX = "✦ " # "✦ " — the mark of a factory dimension (also the read-only guard key) EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$") MIN_PW = 8 @@ -403,6 +405,42 @@ def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool: return cur.rowcount > 0 +# ---- ✦ CANON — factory dimensions (git-tracked, read-only, shared by everyone) --- +def list_factory() -> list[dict]: + """The shipped ✦ patches, newest-name-order stable. Silent if factory/ is absent.""" + out = [] + try: + for fn in sorted(os.listdir(FACTORY_DIR)): + if not fn.endswith(".json"): + continue + try: + with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f: + name = (json.load(f).get("name") or "").strip() + if name.startswith(FACTORY_PREFIX): + out.append({"name": name, "updated": 0, "factory": True}) + except (OSError, ValueError): + continue + except OSError: + pass + return out + + +def load_factory(name: str): + """Load a factory patch by its ✦-prefixed name. Guards against path traversal + (the name never touches the filesystem — we match on the JSON's own name field).""" + for fn in os.listdir(FACTORY_DIR) if os.path.isdir(FACTORY_DIR) else []: + if not fn.endswith(".json"): + continue + try: + with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f: + obj = json.load(f) + if (obj.get("name") or "").strip() == name: + return obj.get("data") + except (OSError, ValueError): + continue + return None + + # ---- HTTP dispatch (called by hub.py's request handler) --------------------- COOKIE = "gs_session" @@ -498,13 +536,17 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str, return 404, {"error": "not found"}, None if path == "/api/presets" and method == "GET": - return 200, {"presets": list_presets(uid, db)}, None + # ✦ CANON — the factory shelf first, then the player's own saved dimensions + return 200, {"presets": list_factory() + list_presets(uid, db)}, None if path.startswith("/api/presets/"): name = _unquote(path[len("/api/presets/"):]) + is_factory = name.startswith(FACTORY_PREFIX) if method == "GET": - data = load_preset(uid, name, db) + data = load_factory(name) if is_factory else load_preset(uid, name, db) return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None) + if is_factory: # ✦ factory dimensions are read-only — never write or delete them + return 403, {"error": "factory dimensions are read-only — save it under a new name"}, None if method in ("PUT", "POST"): return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None) if method == "DELETE": diff --git a/factory/quiet_arrival.json b/factory/quiet_arrival.json new file mode 100644 index 0000000..e1f1094 --- /dev/null +++ b/factory/quiet_arrival.json @@ -0,0 +1,57 @@ +{ + "name": "✦ quiet arrival", + "data": { + "routes": [ + { + "source": "astro.moon", + "dest": "echo.wet", + "amount": 0.6, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the moon opens the echo" + }, + { + "source": "weather.wind", + "dest": "echo.time", + "amount": 0.5, + "curve": "scurve", + "gate": null, + "quantize": null, + "label": "the wind whips the tape speed" + }, + { + "source": "sky.elev", + "dest": "pad.brightness", + "amount": 0.7, + "curve": "scurve", + "gate": null, + "quantize": null, + "label": "the sun brightens the pads" + }, + { + "source": "sun.speed", + "dest": "reverb.size", + "amount": 0.5, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the solar wind sizes the room" + } + ], + "tweaks": {}, + "seqs": {}, + "bpm": 64, + "groove": { + "amount": 1, + "preset": "straight", + "swing": 0, + "humanize": 0, + "velo": 0 + }, + "scale": { + "root": 5, + "name": "major" + } + } +} \ No newline at end of file diff --git a/factory/the_heartbeat.json b/factory/the_heartbeat.json new file mode 100644 index 0000000..a96c4ac --- /dev/null +++ b/factory/the_heartbeat.json @@ -0,0 +1,479 @@ +{ + "name": "✦ the heartbeat", + "data": { + "routes": [], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.95, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.7, + 0.8, + 0.8, + 0.8, + 0.75, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.snare": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 1, + 0 + ], + "vel": [ + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.4, + 0.7, + 0.4, + 0.6, + 0.5 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.ohat": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + } + }, + "bpm": 88, + "groove": { + "amount": 1, + "preset": "mpc 8-4", + "swing": 0.62, + "humanize": 0.12, + "velo": 0.18 + }, + "scale": { + "root": 0, + "name": "minor_pent" + }, + "kit": { + "kick": { + "tune": 0.44, + "punch": 0.6, + "decay": 0.55, + "duck": 0.5, + "level": 0.9, + "pan": 0.5 + } + } + } +} \ No newline at end of file diff --git a/factory/the_squeeze.json b/factory/the_squeeze.json new file mode 100644 index 0000000..51154ef --- /dev/null +++ b/factory/the_squeeze.json @@ -0,0 +1,376 @@ +{ + "name": "✦ the squeeze", + "data": { + "routes": [ + { + "source": "crypto.vel", + "dest": "squash", + "amount": 0.9, + "curve": "lin", + "gate": null, + "quantize": null, + "label": "the market pumps the master" + }, + { + "source": "quake.event", + "dest": "squash", + "amount": 0.7, + "curve": "exp3", + "gate": null, + "quantize": null, + "label": "a quake heaves the whole mix" + } + ], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.snare": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + } + }, + "bpm": 120, + "groove": { + "amount": 1, + "preset": "straight", + "swing": 0, + "humanize": 0.03, + "velo": 0.12 + }, + "scale": { + "root": 0, + "name": "minor_pent" + } + } +} \ No newline at end of file diff --git a/factory/the_wheel_within_the_wheel.json b/factory/the_wheel_within_the_wheel.json new file mode 100644 index 0000000..5db84bf --- /dev/null +++ b/factory/the_wheel_within_the_wheel.json @@ -0,0 +1,357 @@ +{ + "name": "✦ the wheel within the wheel", + "data": { + "routes": [], + "tweaks": {}, + "seqs": { + "drum.kick": { + "on": true, + "steps": [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 16 + }, + "drum.hat": { + "on": true, + "steps": [ + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 12 + }, + "drum.clap": { + "on": true, + "steps": [ + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "vel": [ + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8, + 0.8 + ], + "chance": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "rat": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "lock": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ], + "len": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "wlen": 10 + } + }, + "bpm": 96, + "groove": { + "amount": 1, + "preset": "swing 16", + "swing": 0.55, + "humanize": 0.05, + "velo": 0.1 + }, + "scale": { + "root": 0, + "name": "minor_pent" + } + } +} \ No newline at end of file diff --git a/test_factory.py b/test_factory.py new file mode 100644 index 0000000..7b91eda --- /dev/null +++ b/test_factory.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""test_factory.py — validate the ✦ CANON factory patches' SHAPE. + +Not a taste check (the human re-voices by ear) — this asserts every factory +patch loads cleanly through the client's migrateSeq expectations: 16-step seq +arrays, wlen in 1..16, sane bpm, valid route/groove/scale shape, and a +✦-prefixed name. Stdlib only; excluded from the deploy by the test_*.py filter. + + python3 test_factory.py +""" +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +FACTORY = os.path.join(HERE, "factory") +SCALES = {"chromatic", "major", "minor", "dorian", "phrygian", "lydian", + "mixolydian", "pentatonic", "minor_pent", "wholetone", "hirajoshi"} +SEQ_ARRAYS = ("steps", "vel", "chance", "rat", "lock", "len") + + +def check_seq(key, s, errs): + for a in SEQ_ARRAYS: + if a in s: + if not isinstance(s[a], list) or len(s[a]) != 16: + errs.append(f"{key}.{a} must be a 16-length array") + if "steps" not in s or not isinstance(s.get("steps"), list) or len(s["steps"]) != 16: + errs.append(f"{key}.steps missing/not-16 (migrateSeq would reject → dropped)") + if "wlen" in s and not (isinstance(s["wlen"], int) and 1 <= s["wlen"] <= 16): + errs.append(f"{key}.wlen must be an int 1..16, got {s.get('wlen')!r}") + if "on" in s and not isinstance(s["on"], (bool, int)): + errs.append(f"{key}.on must be boolean-ish") + + +def check_patch(name, p, errs): + if not isinstance(p.get("bpm"), (int, float)) or not (40 <= p["bpm"] <= 180): + errs.append(f"bpm must be 40..180, got {p.get('bpm')!r}") + for r in p.get("routes", []): + if not r.get("source") or not r.get("dest"): + errs.append("route missing source/dest") + if not isinstance(r.get("amount"), (int, float)): + errs.append(f"route {r.get('source')}→{r.get('dest')} amount not numeric") + for key, s in (p.get("seqs") or {}).items(): + check_seq(key, s, errs) + sc = p.get("scale") or {} + if sc and sc.get("name") not in SCALES: + errs.append(f"scale.name {sc.get('name')!r} not a known scale") + gr = p.get("groove") or {} + for k in ("swing", "humanize", "velo"): + if k in gr and not (0 <= gr[k] <= 1): + errs.append(f"groove.{k} out of 0..1") + + +def main(): + if not os.path.isdir(FACTORY): + print("FAIL: no factory/ dir"); sys.exit(1) + files = sorted(f for f in os.listdir(FACTORY) if f.endswith(".json")) + if len(files) < 4: + print(f"FAIL: expected ≥4 factory patches, found {len(files)}"); sys.exit(1) + total_errs = 0 + names = [] + for fn in files: + with open(os.path.join(FACTORY, fn), encoding="utf-8") as f: + obj = json.load(f) + name, data = obj.get("name", ""), obj.get("data") + errs = [] + if not name.startswith("✦ "): + errs.append(f"name {name!r} must start with '✦ '") + if not isinstance(data, dict): + errs.append("missing 'data' object") + else: + check_patch(name, data, errs) + names.append(name) + if errs: + total_errs += len(errs) + print(f" ✗ {fn}: " + "; ".join(errs)) + else: + nseq = len(data.get("seqs") or {}) + nroute = len(data.get("routes") or []) + print(f" ✓ {name} (bpm {data['bpm']}, {nseq} seqs, {nroute} routes)") + # the four the brief names + for want in ("✦ the heartbeat", "✦ the wheel within the wheel", "✦ the squeeze", "✦ quiet arrival"): + if want not in names: + print(f" ✗ missing canonical patch: {want}"); total_errs += 1 + if total_errs: + print(f"\nFAIL: {total_errs} shape error(s)"); sys.exit(1) + print(f"\nALL {len(files)} FACTORY PATCHES VALID (shape only — beauty is the human's)") + + +if __name__ == "__main__": + main()