diff --git a/docs/superpowers/plans/2026-06-28-touchosc-control-surface.md b/docs/superpowers/plans/2026-06-28-touchosc-control-surface.md new file mode 100644 index 0000000..74dd0ac --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-touchosc-control-surface.md @@ -0,0 +1,1145 @@ +# TouchOSC Control Surface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the web `/control/` surface with a native TouchOSC (Hexler) iPad layout, generated by code, that drives the macm1 SuperCollider engine over OSC and reflects live engine state (beat, levels, armed pads, sequencer playhead) back onto the surface. + +**Architecture:** A Python generator (stdlib only, run via `uv`) emits a gzipped-XML `.tosc` layout from a data-driven model. The layout sends the existing SC OSC routes directly to macm1 (no Node bridge for control). A new additive SC file `touchosc_feedback.scd` captures the controlling device's address and pushes feedback to it. Validation runs on GrosMac by opening the generated `.tosc` in the TouchOSC desktop editor and OSC-smoke-testing to macm1 before deploying to the iPad. + +**Tech Stack:** Python 3.11 + uv (generator, stdlib `gzip`/`xml.etree.ElementTree`/`pytest`), TouchOSC (Hexler, new) with Lua scripting, SuperCollider (sclang/scsynth) on macm1. + +## Global Constraints + +- Python: use **uv** exclusively (never pip/poetry/conda directly). +- No emojis in code/docs/commits. +- Commits: subject ≤ 50 chars, body ≤ 72 chars/line, **no AI attribution**, no `--no-verify`, no underscore in the commit scope. +- `.tosc` file format: root ``, gzip-compressed XML. Property type codes: `s` string, `b` bool, `i` int, `f` float, `r` frame/rect, `c` color. +- SC OSC routes are **reused unchanged**. Control port = the SC OSCdef port in use today (**57121**). TouchOSC feedback listen port default = **9000**. +- `web_realart` stays for visual sync (Hydra/WebGL). Only the `/control/` UI role is retired; do not delete `web_realart`. +- Touch device: iPad landscape. Canvas default **1194×834** with proportional anchoring; model confirmed at test time. +- `*.tosc` build outputs go under `touchosc/dist/` and SHOULD be committed (they are the deliverable); large binary caches are not relevant here. +- New tool tree lives at repo-root `touchosc/`. SC feedback file lives at `sound_algo/data_only/touchosc_feedback.scd`. +- The user iterates in parallel on other files — touch ONLY the files each task names; never `git add -A`. + +**Reference (informative, verify against Task 1 ground truth):** +`tosclib` (AlbertoV5, inactive, unaffiliated with Hexler) models the v3 schema as: +`` containing ``, ``, ``, ``; a property is `name..`; an OSC message is ``; Lua is a `script` string property. Control types: `GROUP BOX BUTTON LABEL TEXT FADER XY RADIAL ENCODER RADAR RADIO PAGER GRID`. + +--- + +## File Structure + +``` +touchosc/ + pyproject.toml # uv project, pytest dev dep + README.md # how to build + validate on GrosMac + gen/ + __init__.py + schema.py # XML emission helpers: node/prop/frame/color/osc/script + tosc(write/read) + layout.py # data-driven layout model: PATTERNS, pages, widget builders + lua/ # Lua scripts kept as separate .lua files, embedded at build time + pad.lua + rhythm_grid.lua + melody_faders.lua + preset_select.lua + feedback.lua + build_layout.py # entrypoint: assemble model -> .tosc, write to dist/ + reference/ # Task 1 ground-truth artifacts (committed) + .gitkeep + dist/ + .gitkeep # av-live-control.tosc lands here + tests/ + test_schema.py + test_layout.py + test_build.py +sound_algo/data_only/ + touchosc_feedback.scd # additive SC feedback (sender capture, armed push, seq state, sync relay) + boot.scd # MODIFY: load touchosc_feedback.scd after launchpad.scd +``` + +--- + +## Task 1: Capture ground-truth `.tosc` schema (format probe) + +**Why first:** the v3 XML byte-format (OSC partial encoding, `connections` field, frame/color value shapes) must be pinned against a file the *current* TouchOSC actually wrote, before any generator code is trusted. This is the single biggest format-fidelity risk; this task retires it. + +**Files:** +- Create: `touchosc/reference/handmade.tosc` (saved from TouchOSC editor) +- Create: `touchosc/reference/handmade.xml` (decompressed) +- Create: `docs/superpowers/specs/2026-06-28-tosc-schema-reference.md` (documented schema) + +**Interfaces:** +- Produces: a documented, authoritative v3 schema (root tag, node attrs, property type codes, frame value shape, color value shape, OSC message element shape incl. `connections`, script property) consumed by Task 2's `schema.py`. + +- [ ] **Step 1: Create a minimal reference layout in TouchOSC on GrosMac** + +In the TouchOSC desktop editor on GrosMac, create ONE document containing exactly: one FADER and one BUTTON. On the FADER add an OSC message `/launch/vol kick `. On the BUTTON add an OSC message `/launch kick 1`. Save as `touchosc/reference/handmade.tosc`. + +(If the user prefers, they perform this single manual step and confirm the path; the rest of the plan is code.) + +- [ ] **Step 2: Decompress and inspect the real XML** + +Run: +```bash +cd /Users/electron/Documents/Projets/AV-Live +gunzip -c touchosc/reference/handmade.tosc > touchosc/reference/handmade.xml +xmllint --format touchosc/reference/handmade.xml | head -120 +``` +Expected: well-formed XML beginning `` with a `` root and child `` / `` entries, each with ``, `` (containing ``), and the FADER/BUTTON `frame` property. + +- [ ] **Step 3: Document the authoritative schema** + +Write `docs/superpowers/specs/2026-06-28-tosc-schema-reference.md` capturing, copied verbatim from `handmade.xml`: the exact root element, node attribute names, every property `type` code seen and its `` child shape (especially `frame` and `color`), the full `` element with all attributes (`enabled send receive feedback connections`) and the ``/`` `` attribute set (`type conversion value` and any min/max), and the `script` property shape. Note the exact `connections` string length/format. + +- [ ] **Step 4: Commit** + +```bash +git add touchosc/reference/handmade.tosc touchosc/reference/handmade.xml \ + docs/superpowers/specs/2026-06-28-tosc-schema-reference.md +git commit -m "docs: capture touchosc v3 tosc schema ground truth" +``` + +--- + +## Task 2: Generator skeleton + round-trip fidelity gate + +**Files:** +- Create: `touchosc/pyproject.toml`, `touchosc/gen/__init__.py`, `touchosc/gen/schema.py` +- Test: `touchosc/tests/test_schema.py` + +**Interfaces:** +- Produces, in `schema.py` (signatures consumed by all later tasks): + - `tosc_id() -> str` — fresh UUID string for a node ID. + - `prop(key: str, value, typ: str) -> Element` — one ``. + - `frame(x, y, w, h) -> Element` — the `frame` property (type `r`). + - `color(r, g, b, a=1.0) -> Element` — the `color` property (type `c`). + - `osc(address: str, args: list[Partial], connections: str = CONN_DEFAULT, send=1, receive=0, feedback=0) -> Element` — one `` message. + - `Partial(kind: str, conversion: str, value: str)` — path/argument partial; helpers `const(s)`, `val()` build the common cases. + - `script(lua: str) -> Element` — the `script` string property. + - `node(ntype: str, props: list[Element], children=None, messages=None, values=None) -> Element`. + - `write_tosc(root: Element, path: str) -> None` — serialize + gzip to `.tosc`. + - `read_tosc(path: str) -> Element` — gunzip + parse (for tests). + - Module constant `CONN_DEFAULT` — the `connections` string confirmed in Task 1. + +- [ ] **Step 1: Create the uv project** + +`touchosc/pyproject.toml`: +```toml +[project] +name = "touchosc-gen" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +``` +`touchosc/gen/__init__.py`: empty file. + +- [ ] **Step 2: Write the failing test** + +`touchosc/tests/test_schema.py`: +```python +from gen import schema + +def test_roundtrip_minimal_fader(tmp_path): + fader = schema.node( + "FADER", + props=[ + schema.prop("name", "vol", "s"), + schema.frame(10, 20, 60, 200), + ], + messages=[ + schema.osc("/launch/vol", [schema.const("kick"), schema.val()]), + ], + ) + root = schema.node("GROUP", props=[schema.prop("name", "root", "s")], + children=[fader]) + out = tmp_path / "min.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + assert parsed.tag == "node" + assert parsed.get("type") == "GROUP" + child = parsed.find("children/node") + assert child.get("type") == "FADER" + addr = child.find("messages/osc/path/partial") + assert addr.get("value") == "/launch/vol" +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_schema.py -v` +Expected: FAIL — `ModuleNotFoundError` / attributes missing. + +- [ ] **Step 4: Implement `schema.py` per the Task 1 ground truth** + +Implement `touchosc/gen/schema.py` using `xml.etree.ElementTree`. The shapes below follow the v3 reference; **adjust attribute names/codes to match `touchosc/reference/handmade.xml` exactly** if they differ. +```python +import gzip, uuid +import xml.etree.ElementTree as ET + +CONN_DEFAULT = "00000001" # confirm length/value against handmade.xml + +def tosc_id(): + return str(uuid.uuid4()) + +def _set_text(parent, tag, text): + e = ET.SubElement(parent, tag); e.text = str(text); return e + +def prop(key, value, typ): + p = ET.Element("property", {"type": typ}) + _set_text(p, "key", key) + _set_text(p, "value", value) + return p + +def frame(x, y, w, h): + p = ET.Element("property", {"type": "r"}) + _set_text(p, "key", "frame") + v = ET.SubElement(p, "value") + for tag, n in (("x", x), ("y", y), ("w", w), ("h", h)): + _set_text(v, tag, n) + return p + +def color(r, g, b, a=1.0): + p = ET.Element("property", {"type": "c"}) + _set_text(p, "key", "color") + v = ET.SubElement(p, "value") + for tag, n in (("r", r), ("g", g), ("b", b), ("a", a)): + _set_text(v, tag, n) + return p + +class Partial: + def __init__(self, kind, conversion, value): + self.kind, self.conversion, self.value = kind, conversion, value + def to_el(self): + return ET.Element("partial", { + "type": self.kind, "conversion": self.conversion, + "value": str(self.value), "scaleMin": "0", "scaleMax": "1", + }) + +def const(s): return Partial("CONSTANT", "STRING", s) +def val(): return Partial("VALUE", "FLOAT", "x") + +def osc(address, args, connections=CONN_DEFAULT, send=1, receive=0, feedback=0): + m = ET.Element("osc", { + "enabled": "1", "send": str(send), "receive": str(receive), + "feedback": str(feedback), "connections": connections, + }) + ET.SubElement(m, "triggers") + path = ET.SubElement(m, "path") + path.append(const(address).to_el()) + a = ET.SubElement(m, "arguments") + for p in args: + a.append(p.to_el()) + return m + +def script(lua): + return prop("script", lua, "s") + +def node(ntype, props, children=None, messages=None, values=None): + n = ET.Element("node", {"ID": tosc_id(), "type": ntype}) + ps = ET.SubElement(n, "properties") + for p in props: ps.append(p) + vs = ET.SubElement(n, "values") + for v in (values or []): vs.append(v) + ms = ET.SubElement(n, "messages") + for m in (messages or []): ms.append(m) + cs = ET.SubElement(n, "children") + for c in (children or []): cs.append(c) + return n + +def write_tosc(root, path): + lexml = ET.Element("lexml", {"version": "3"}) + lexml.append(root) + xml = ET.tostring(lexml, encoding="UTF-8", xml_declaration=True) + with gzip.open(path, "wb") as f: + f.write(xml) + +def read_tosc(path): + with gzip.open(path, "rb") as f: + lexml = ET.fromstring(f.read()) + return lexml.find("node") +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd touchosc && uv run pytest tests/test_schema.py -v` +Expected: PASS. + +- [ ] **Step 6: Fidelity gate — open the generated file in TouchOSC on GrosMac** + +Generate the same minimal file to disk and open it: +```bash +cd touchosc && uv run python -c "from gen import schema as s; \ +r=s.node('GROUP',[s.prop('name','root','s')],children=[\ + s.node('FADER',[s.prop('name','vol','s'),s.frame(10,20,60,200)],\ + messages=[s.osc('/launch/vol',[s.const('kick'),s.val()])])]); \ +s.write_tosc(r,'dist/_probe.tosc')" +open -a TouchOSC dist/_probe.tosc +``` +Expected: TouchOSC opens the document with a visible fader, no parse error. If it fails to load, fix `schema.py` against `handmade.xml` and repeat. **Do not proceed past this gate until the generated file loads.** + +- [ ] **Step 7: Commit** + +```bash +git add touchosc/pyproject.toml touchosc/gen/__init__.py touchosc/gen/schema.py \ + touchosc/tests/test_schema.py +git commit -m "feat: tosc xml generator schema + fidelity gate" +``` + +--- + +## Task 3: Layout model — canvas, pager, and shared builders + +**Files:** +- Create: `touchosc/gen/layout.py` +- Create: `touchosc/gen/build_layout.py` +- Test: `touchosc/tests/test_layout.py` + +**Interfaces:** +- Consumes: all of `schema.py`. +- Produces: + - `CANVAS = (1194, 834)`. + - `PATTERNS: list[str]` — the 16 pattern names in grid order. + - `pad(name, x, y, w, h) -> Element`, `vfader(address, args, x, y, w, h) -> Element`, `button(label, address, x, y, w, h, arg=1) -> Element`, `label(text, x, y, w, h) -> Element`, `radio(...)`, `grid(...)` — widget builders returning nodes. + - `pager(pages: list[Element]) -> Element` — a PAGER node holding page GROUPs. + - `build() -> Element` — assembles the full root (called by `build_layout.py`). + +- [ ] **Step 1: Write the failing test** + +`touchosc/tests/test_layout.py`: +```python +from gen import layout, schema + +def test_root_has_pager_with_three_pages(tmp_path): + root = layout.build() + out = tmp_path / "full.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + pager = parsed.find(".//node[@type='PAGER']") + assert pager is not None + pages = pager.findall("children/node[@type='GROUP']") + assert len(pages) == 3 + +def test_sixteen_patterns(): + assert len(layout.PATTERNS) == 16 + assert layout.PATTERNS[0] == "kick" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_layout.py -v` +Expected: FAIL — `layout` has no `build`/`PATTERNS`. + +- [ ] **Step 3: Implement the model and builders** + +`touchosc/gen/layout.py`: +```python +from gen import schema as s + +CANVAS = (1194, 834) +PATTERNS = ["kick", "hats", "clap", "perc", "sub", "acid", "arp", "lead", + "stab", "pad", "ride", "rim", "tom", "reese", "bells", "sweep"] + +def label(text, x, y, w, h): + return s.node("LABEL", [s.prop("name", text, "s"), s.frame(x, y, w, h)]) + +def button(lbl, address, x, y, w, h, arg=1): + return s.node("BUTTON", + [s.prop("name", lbl, "s"), s.frame(x, y, w, h)], + messages=[s.osc(address, [s.const(str(arg))])]) + +def vfader(address, args, x, y, w, h): + return s.node("FADER", + [s.prop("name", "", "s"), s.frame(x, y, w, h)], + messages=[s.osc(address, args)]) + +def radio(address, count, x, y, w, h): + return s.node("RADIO", + [s.prop("name", "", "s"), s.frame(x, y, w, h)], + messages=[s.osc(address, [s.val()])]) + +def grid(rows, cols, address, x, y, w, h): + # A GRID of buttons; Lua (Task 7) reads cell states and emits `address`. + return s.node("GRID", + [s.prop("name", "seqgrid", "s"), s.frame(x, y, w, h), + s.prop("grid", f"{cols}x{rows}", "s")], + messages=[s.osc(address, [s.val()], send=0, receive=0)]) + +def pager(pages): + return s.node("PAGER", [s.prop("name", "pager", "s"), + s.frame(0, 0, *CANVAS)], children=pages) + +def _empty_page(name): + return s.node("GROUP", [s.prop("name", name, "s"), s.frame(0, 0, *CANVAS)]) + +def build(): + # Pages are filled by Tasks 4-6; start with three named placeholders so the + # structure (and its test) is real now. + pages = [_empty_page("LIVE"), _empty_page("FX/HARM/CONCERT"), + _empty_page("SEQ")] + return s.node("GROUP", [s.prop("name", "av-live-control", "s"), + s.frame(0, 0, *CANVAS)], + children=[pager(pages)]) +``` + +`touchosc/gen/build_layout.py`: +```python +from gen import layout, schema + +OUT = "dist/av-live-control.tosc" + +def main(): + schema.write_tosc(layout.build(), OUT) + print(f"wrote {OUT}") + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd touchosc && uv run pytest tests/test_layout.py -v` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add touchosc/gen/layout.py touchosc/gen/build_layout.py touchosc/tests/test_layout.py +git commit -m "feat: tosc layout model with three-page pager" +``` + +--- + +## Task 4: Page 1 — LIVE (top bar + 16-pad grid) + +**Files:** +- Modify: `touchosc/gen/layout.py` (replace the `LIVE` placeholder with `live_page()`) +- Test: `touchosc/tests/test_layout.py` (add assertions) + +**Interfaces:** +- Consumes: builders from Task 3. +- Produces: `live_page() -> Element` — a GROUP with a top bar (tempo FADER `/launch/tempo`, CLEAR BUTTON `/launch/clear`, master VU FADER `feedback`-only on `/sync/rms`, beat LABEL/BOX receiving `/sync/beat`) and 16 pad cells. Each pad cell groups: BUTTON `/launch ` (with name + `receive` on `/armed/`), vertical vol FADER `/launch/vol `, and a name LABEL. + +- [ ] **Step 1: Write the failing test** + +Add to `touchosc/tests/test_layout.py`: +```python +def test_live_page_has_16_launch_buttons(): + page = layout.live_page() + buttons = page.findall(".//node[@type='BUTTON']") + launch = [b for b in buttons + if (b.find("messages/osc/path/partial") is not None and + b.find("messages/osc/path/partial").get("value") == "/launch")] + assert len(launch) == 16 + # each pad has a matching volume fader + faders = page.findall(".//node[@type='FADER']") + vol = [f for f in faders + if f.find("messages/osc/path/partial") is not None and + f.find("messages/osc/path/partial").get("value") == "/launch/vol"] + assert len(vol) == 16 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_layout.py::test_live_page_has_16_launch_buttons -v` +Expected: FAIL — `layout` has no `live_page`. + +- [ ] **Step 3: Implement `live_page()` and wire it into `build()`** + +Add to `layout.py`: +```python +def pad_cell(name, x, y, w, h): + btn = s.node("BUTTON", + [s.prop("name", name, "s"), s.frame(0, 0, w, h - 26)], + messages=[ + s.osc("/launch", [s.const(name), s.val()]), + # receive armed state from SC -> visual handled in Lua (Task 7) + s.osc(f"/armed/{name}", [s.val()], send=0, receive=1, feedback=1), + ]) + fader = s.node("FADER", + [s.prop("name", "", "s"), s.frame(w - 18, 0, 16, h - 26)], + messages=[s.osc("/launch/vol", [s.const(name), s.val()])]) + lbl = label(name, 0, h - 24, w, 22) + return s.node("GROUP", + [s.prop("name", f"pad_{name}", "s"), s.frame(x, y, w, h), + s.script(_PAD_SCRIPT_REF)], + children=[btn, fader, lbl]) + +_PAD_SCRIPT_REF = "" # filled in Task 7 (embedded from gen/lua/pad.lua) + +def live_page(): + page = s.node("GROUP", [s.prop("name", "LIVE", "s"), s.frame(0, 0, *CANVAS)]) + children = page.find("children") + # Top bar + children.append(vfader("/launch/tempo", [s.val()], 20, 16, 320, 48)) + children.append(label("TEMPO", 20, 66, 320, 20)) + children.append(button("CLEAR ALL", "/launch/clear", 360, 16, 160, 48)) + # master VU (receive-only on /sync/rms) + children.append(s.node("FADER", + [s.prop("name", "rms", "s"), s.frame(540, 16, 40, 70)], + messages=[s.osc("/sync/rms", [s.val()], send=0, receive=1, feedback=1)])) + # beat flash box (receive-only on /sync/beat) + children.append(s.node("BOX", + [s.prop("name", "beat", "s"), s.frame(600, 16, 70, 70), + s.script(_BEAT_SCRIPT_REF)], + messages=[s.osc("/sync/beat", [s.val()], send=0, receive=1, feedback=1)])) + # 4x4 pad grid + gx, gy, pw, ph, gap = 20, 110, 280, 160, 12 + for i, name in enumerate(PATTERNS): + col, row = i % 4, i // 4 + x = gx + col * (pw + gap) + y = gy + row * (ph + gap) + children.append(pad_cell(name, x, y, pw, ph)) + return page + +_BEAT_SCRIPT_REF = "" # filled in Task 7 (embedded from gen/lua/feedback.lua) +``` +Then in `build()` replace `_empty_page("LIVE")` with `live_page()`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd touchosc && uv run pytest tests/test_layout.py -v` +Expected: PASS (all layout tests). + +- [ ] **Step 5: Commit** + +```bash +git add touchosc/gen/layout.py touchosc/tests/test_layout.py +git commit -m "feat: tosc live page with 16 pads and top bar" +``` + +--- + +## Task 5: Page 2 — FX / HARMONIE / CONCERT + +**Files:** +- Modify: `touchosc/gen/layout.py` (replace the FX placeholder with `fx_page()`) +- Test: `touchosc/tests/test_layout.py` + +**Interfaces:** +- Consumes: builders from Task 3. +- Produces: `fx_page() -> Element` — filter FADER `/control/fx/filter`; FX one-shots `/control/fx/{stutter,crash,kick,swell,breakdown}` (breakdown twice: arg 0 and arg 1); harmony controls `/control/harmony/{root(-/+),scale,octave(-/+),phrase}`; concert BUTTONs `/control/concertNext`, `/control/doScene`, and body-play. + +- [ ] **Step 1: Write the failing test** + +Add to `touchosc/tests/test_layout.py`: +```python +def test_fx_page_addresses(): + page = layout.fx_page() + addrs = {p.get("value") + for p in page.findall(".//messages/osc/path/partial")} + for a in ["/control/fx/filter", "/control/fx/stutter", "/control/fx/crash", + "/control/fx/kick", "/control/fx/swell", "/control/fx/breakdown", + "/control/harmony/root", "/control/harmony/scale", + "/control/harmony/octave", "/control/harmony/phrase", + "/control/concertNext", "/control/doScene"]: + assert a in addrs, f"missing {a}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_layout.py::test_fx_page_addresses -v` +Expected: FAIL — no `fx_page`. + +- [ ] **Step 3: Implement `fx_page()` and wire into `build()`** + +Add to `layout.py`: +```python +SCALES = ["minor", "dorian", "phrygian", "penta"] + +def fx_page(): + page = s.node("GROUP", [s.prop("name", "FX/HARM/CONCERT", "s"), + s.frame(0, 0, *CANVAS)]) + c = page.find("children") + # FX column + c.append(label("FILTER", 20, 16, 200, 20)) + c.append(vfader("/control/fx/filter", [s.val()], 20, 40, 200, 360)) + fx = [("STUTTER", "stutter"), ("CRASH", "crash"), ("KICK", "kick"), + ("SWELL", "swell")] + for i, (lbl, key) in enumerate(fx): + c.append(button(lbl, f"/control/fx/{key}", 240, 40 + i * 70, 180, 56)) + c.append(button("BREAKDOWN IN", "/control/fx/breakdown", + 240, 40 + 4 * 70, 180, 56, arg=1)) + c.append(button("BREAKDOWN OUT", "/control/fx/breakdown", + 240, 40 + 5 * 70, 180, 56, arg=0)) + # Harmony column + c.append(label("HARMONIE", 460, 16, 300, 20)) + c.append(button("ROOT -", "/control/harmony/root", 460, 40, 140, 56, arg=-2)) + c.append(button("ROOT +", "/control/harmony/root", 610, 40, 140, 56, arg=2)) + for i, name in enumerate(SCALES): + c.append(button(name.upper(), "/control/harmony/scale", + 460 + (i % 2) * 150, 110 + (i // 2) * 70, 140, 56, arg=i)) + c.append(button("OCT -", "/control/harmony/octave", 460, 260, 140, 56, arg=-1)) + c.append(button("OCT +", "/control/harmony/octave", 610, 260, 140, 56, arg=1)) + for p in range(4): + c.append(button(f"PHRASE {p}", "/control/harmony/phrase", + 460 + p * 75, 330, 70, 56, arg=p)) + # Concert column + c.append(label("CONCERT", 800, 16, 300, 20)) + c.append(button("MORCEAU >", "/control/concertNext", 800, 40, 280, 64)) + c.append(button("SCENE", "/control/doScene", 800, 116, 280, 64)) + c.append(button("BODY-PLAY", "/control/bodyPlay", 800, 192, 280, 64)) + return page +``` +Replace `_empty_page("FX/HARM/CONCERT")` with `fx_page()` in `build()`. + +NOTE: confirm the body-play OSC address against `launchpad.scd`/concert files during implementation; if it differs from `/control/bodyPlay`, use the real one and update the test. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd touchosc && uv run pytest tests/test_layout.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add touchosc/gen/layout.py touchosc/tests/test_layout.py +git commit -m "feat: tosc fx harmony concert page" +``` + +--- + +## Task 6: Page 3 — SÉQUENCEURS (rhythm grid + melody faders + presets + VU) + +**Files:** +- Modify: `touchosc/gen/layout.py` (replace SEQ placeholder with `seq_page()`) +- Test: `touchosc/tests/test_layout.py` + +**Interfaces:** +- Consumes: builders from Task 3. +- Produces: `seq_page() -> Element` — rhythm: 8 preset radio `/seq/rhythm`, a 3×16 GRID (Lua emits `/seq/rhythm/set`), 8 name LABELs (receive `/seq/rhythm/state`); melody: 8 preset radio `/seq/melody`, 8 vertical FADERs (Lua emits `/seq/melody/set`), arm BUTTON; per-voice VU: 8 FADERs receiving `/sync/amp`. + +- [ ] **Step 1: Write the failing test** + +Add to `touchosc/tests/test_layout.py`: +```python +def test_seq_page_grid_and_faders(): + page = layout.seq_page() + assert page.find(".//node[@type='GRID']") is not None + addrs = [p.get("value") + for p in page.findall(".//messages/osc/path/partial")] + assert "/seq/rhythm" in addrs + assert "/seq/melody" in addrs + assert "/seq/rhythm/set" in addrs + assert "/seq/melody/set" in addrs + # 8 melody step faders + mel = [f for f in page.findall(".//node[@type='FADER']") + if f.find("messages/osc/path/partial") is not None and + f.find("messages/osc/path/partial").get("value") == "/seq/melody/set"] + assert len(mel) == 8 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_layout.py::test_seq_page_grid_and_faders -v` +Expected: FAIL — no `seq_page`. + +- [ ] **Step 3: Implement `seq_page()` and wire into `build()`** + +Add to `layout.py`: +```python +VOICES = ["kick", "hat", "snare", "clap", "perc", "melody", "acid", "harmony"] + +def seq_page(): + page = s.node("GROUP", [s.prop("name", "SEQ", "s"), s.frame(0, 0, *CANVAS)]) + c = page.find("children") + # Rhythm: 8 presets + name labels + c.append(label("RYTHME", 20, 12, 300, 20)) + for i in range(8): + c.append(button(f"R{i + 1}", "/seq/rhythm", 20 + i * 70, 36, 64, 44, arg=i)) + # name label receives /seq/rhythm/state (Lua updates text) + nl = s.node("LABEL", + [s.prop("name", f"R{i + 1}", "s"), s.frame(20 + i * 70, 82, 64, 18)], + messages=[s.osc("/seq/rhythm/state", [s.val()], + send=0, receive=1, feedback=1)]) + c.append(nl) + # 3x16 rhythm grid -> Lua emits /seq/rhythm/set + c.append(grid(3, 16, "/seq/rhythm/set", 20, 110, 560, 150)) + # Melody: 8 presets + 8 step faders + c.append(label("MELODIE", 620, 12, 300, 20)) + for i in range(8): + c.append(button(f"M{i + 1}", "/seq/melody", 620 + i * 56, 36, 50, 44, arg=i)) + for i in range(8): + c.append(s.node("FADER", + [s.prop("name", f"m{i}", "s"), s.frame(620 + i * 56, 110, 48, 150), + s.script(_MEL_SCRIPT_REF)], + messages=[s.osc("/seq/melody/set", [s.val()], send=0, receive=0)])) + c.append(button("ARM MEL", "/launch", 620, 270, 120, 44, arg=1)) # arg/name set in impl + # Per-voice VU strip (receive /sync/amp) + c.append(label("VU", 20, 290, 200, 20)) + for i, v in enumerate(VOICES): + c.append(s.node("FADER", + [s.prop("name", f"vu_{v}", "s"), s.frame(20 + i * 56, 314, 48, 120)], + messages=[s.osc("/sync/amp", [s.val()], send=0, receive=1, feedback=1)])) + return page + +_MEL_SCRIPT_REF = "" # filled in Task 7 +``` +Replace `_empty_page("SEQ")` with `seq_page()` in `build()`. + +NOTE: the rhythm GRID and melody FADERs carry `/seq/rhythm/set` and +`/seq/melody/set` as their nominal address for testability, but the actual +emission is done by Lua (Task 7), which assembles the full argument list. The +per-voice VU faders all listen on `/sync/amp`; Lua (Task 7) routes by argument +index to the correct meter. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd touchosc && uv run pytest tests/test_layout.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add touchosc/gen/layout.py touchosc/tests/test_layout.py +git commit -m "feat: tosc sequencer page grid faders presets vu" +``` + +--- + +## Task 7: Lua scripts (pad logic, grid/melody encoding, presets, feedback) + +**Files:** +- Create: `touchosc/gen/lua/pad.lua`, `rhythm_grid.lua`, `melody_faders.lua`, `preset_select.lua`, `feedback.lua` +- Modify: `touchosc/gen/layout.py` (load `.lua` files and fill the `_*_SCRIPT_REF` constants) +- Test: `touchosc/tests/test_build.py` + +**Interfaces:** +- Consumes: `schema.script`, the `_*_SCRIPT_REF` hooks placed in Tasks 4 and 6. +- Produces: `load_lua(name) -> str` in `layout.py`; embedded scripts on the relevant nodes. + +- [ ] **Step 1: Write the Lua scripts** + +`touchosc/gen/lua/pad.lua` (on each pad GROUP; toggles `/launch` and reflects `/armed/`): +```lua +-- Pad: toggle launch on tap; reflect armed state from SC. +local name = self.name:gsub("pad_", "") +function onValueChanged(key) + local btn = self.children.button or self:findByName(name, true) +end +function onReceiveOSC(message, connections) + local path = message[1] + if path == "/armed/" .. name then + local on = message[2][1].value + self.children[1].color = on > 0.5 and Color(0.27,0.8,0.4,1) or Color(0.11,0.11,0.11,1) + end +end +``` +(Confirm the TouchOSC Lua control API names — `self.children`, `findByName`, +`onReceiveOSC`, `Color()` — against the TouchOSC manual on first run, and adjust +the property writes if the API differs. Keep the message contract: tap sends +`/launch <0|1>`; `/armed/` drives color.) + +`touchosc/gen/lua/rhythm_grid.lua` (on the GRID; assembles three 16-char strings): +```lua +-- Rhythm grid: rows 0..2 = K,S,H ; cols 0..15 = steps. On any change, +-- build three 16-char strings and send /seq/rhythm/set. +function onValueChanged(key) + if key ~= "x" and key ~= "touch" then return end + local rows = {"","",""} + for r = 0, 2 do + for c = 0, 15 do + local cell = self.children[r * 16 + c + 1] + rows[r + 1] = rows[r + 1] .. ((cell.values.x > 0.5) and "1" or "0") + end + end + sendOSC("/seq/rhythm/set", rows[1], rows[2], rows[3]) +end +function onReceiveOSC(message) + -- /seq/rhythm/state k s h : load strings into cells + if message[1] ~= "/seq/rhythm/state" then return end + local args = message[2] + for r = 0, 2 do + local str = args[r + 2].value + for c = 0, 15 do + self.children[r * 16 + c + 1].values.x = + (str:sub(c + 1, c + 1) == "1") and 1 or 0 + end + end +end +``` + +`touchosc/gen/lua/melody_faders.lua` (on each melody step FADER; quantizes): +```lua +-- Melody step fader: map 0..1 to integer degree -7..14, send full row. +local LO, HI = -7, 14 +function onValueChanged(key) + if key ~= "x" then return end + local row = self.parent + local degs = {} + for i = 1, 8 do + local f = row:findByName("m" .. (i - 1), true) + degs[i] = math.floor(LO + (HI - LO) * f.values.x + 0.5) + end + sendOSC("/seq/melody/set", table.unpack(degs)) +end +``` + +`touchosc/gen/lua/preset_select.lua` (on the page; reflects names from SC): +```lua +-- Update preset name labels from /seq/{rhythm,melody}/state (idx, ..., name). +function onReceiveOSC(message) + local p = message[1] + if p == "/seq/rhythm/state" or p == "/seq/melody/state" then + local args = message[2] + local idx = args[1].value + local name = args[#args].value + local prefix = (p:find("rhythm")) and "R" or "M" + local lbl = self:findByName(prefix .. (idx + 1), true) + if lbl then lbl.values.text = name end + end +end +``` + +`touchosc/gen/lua/feedback.lua` (on the beat BOX + master VU + per-voice VU): +```lua +-- Beat flash + level meters driven by SC feedback. +function onReceiveOSC(message) + local p = message[1] + if p == "/sync/beat" then + self.color = Color(1,1,1,1) -- flash; decays via onFrame below + elseif p == "/sync/rms" then + self.values.x = message[2][1].value + elseif p == "/sync/amp" then + -- per-voice: args = [v0..v7]; this meter reads its index from tag + local i = tonumber(self.tag) or 0 + self.values.x = message[2][i + 1].value + end +end +function onFrame() + if self.color and self.color.a then + self.color = Color(self.color.r * 0.85, self.color.g * 0.85, + self.color.b * 0.85, 1) + end +end +``` + +- [ ] **Step 2: Write the failing test** + +`touchosc/tests/test_build.py`: +```python +from gen import layout, schema + +def test_scripts_embedded(tmp_path): + root = layout.build() + out = tmp_path / "full.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + scripts = [p.find("value").text + for p in parsed.findall(".//property[@type='s']") + if p.find("key") is not None and p.find("key").text == "script"] + joined = "\n".join(s or "" for s in scripts) + assert "sendOSC(\"/seq/rhythm/set\"" in joined + assert "/armed/" in joined + assert "/sync/beat" in joined +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd touchosc && uv run pytest tests/test_build.py -v` +Expected: FAIL — scripts not embedded yet (refs are empty strings). + +- [ ] **Step 4: Load and embed the Lua** + +Add to `layout.py` (top): +```python +import os +_LUA_DIR = os.path.join(os.path.dirname(__file__), "lua") + +def load_lua(name): + with open(os.path.join(_LUA_DIR, name), encoding="utf-8") as f: + return f.read() +``` +Replace the placeholder constants with real loads, and attach +`preset_select.lua` + `feedback.lua` at the page/meter level: +```python +_PAD_SCRIPT_REF = load_lua("pad.lua") +_MEL_SCRIPT_REF = load_lua("melody_faders.lua") +_BEAT_SCRIPT_REF = load_lua("feedback.lua") +``` +- Put `rhythm_grid.lua` as the GRID node's `script` property in `grid(...)` (add a `script=` arg). +- Put `feedback.lua` as the `script` on the master VU and each per-voice VU FADER, and set each per-voice VU node's `tag` property to its index `i` (so the script routes `/sync/amp` by index). +- Put `preset_select.lua` as the `script` on `seq_page()`'s GROUP. +(Define these node-level `script` additions where each widget is built.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd touchosc && uv run pytest -v` +Expected: PASS (all tests). + +- [ ] **Step 6: Build the full layout and re-open in TouchOSC on GrosMac** + +```bash +cd touchosc && uv run python -m gen.build_layout && open -a TouchOSC dist/av-live-control.tosc +``` +Expected: all three pages load; widgets visible; no script errors in the TouchOSC editor console. Fix any Lua API mismatches against the TouchOSC manual and rebuild. + +- [ ] **Step 7: Commit** + +```bash +git add touchosc/gen/lua touchosc/gen/layout.py touchosc/tests/test_build.py \ + touchosc/dist/av-live-control.tosc +git commit -m "feat: tosc lua scripts and full layout build" +``` + +--- + +## Task 8: SC feedback file `touchosc_feedback.scd` + +**Files:** +- Create: `sound_algo/data_only/touchosc_feedback.scd` +- Test: `sound_algo/data_only/test/test_touchosc_feedback.scd` (headless sclang harness, mirroring the existing data_only test style) + +**Interfaces:** +- Consumes: existing SC env (`~lp`, `~lpClock`, the inbound OSC routes). Reads RMS/amp internal reply paths used by `web_bridge.scd` (locate them by reading that file). +- Produces (env functions, callable from launchpad/concert if desired): + - `~tosc` (NetAddr or nil), `~toscPort` (9000), `~toscTouch.(addr)`, `~toscSend.(path, ...args)`. + - `~toscArmedPush.()` — sends `/armed/ <0|1>` for all 16 PATTERNS from `~lp[\armed]`. + - Periodic Routine pushing armed state at 4 Hz and a beat pulse on `~lpClock`. + - Forwarding OSCdefs that relay the internal RMS/amp replies to `~tosc`. + - Inbound sniffers that call `~toscTouch` and push `/seq/*/state` on `/seq/*`. + +- [ ] **Step 1: Locate the internal reply paths in web_bridge.scd** + +Run: +```bash +cd /Users/electron/Documents/Projets/AV-Live +grep -nE "SendReply|/rms|/amp|OSCdef|sendMsg" sound_algo/web_bridge.scd | head -40 +``` +Note the exact internal OSC reply addresses SC uses for RMS and per-voice amp +(e.g. `\webRmsProbe` SendReply path). These are needed in Step 3. + +- [ ] **Step 2: Write the failing headless test** + +`sound_algo/data_only/test/test_touchosc_feedback.scd`: +```supercollider +// Headless: boot a dummy env, load touchosc_feedback.scd, assert API exists. +( +~lp = (armed: Set[], melodies: 8.collect{|i| (name: "M"++(i+1), degrees: [0,2,3,5]) }, + rhythms: 8.collect{|i| (name: "R"++(i+1), k: "1000100010001000", + s: "0000100000001000", h: "1010101010101010") }); +~lpClock = { TempoClock.default }; +this.executeFile("sound_algo/data_only/touchosc_feedback.scd".standardizePath); +var ok = ~toscSend.notNil and: ~toscTouch.notNil and: ~toscArmedPush.notNil; +if(ok) { "TEST PASS".postln } { "TEST FAIL".postln }; +0.exit; +) +``` + +- [ ] **Step 3: Implement `touchosc_feedback.scd`** + +`sound_algo/data_only/touchosc_feedback.scd`: +```supercollider +// SC -> TouchOSC feedback. Additive, nil-guarded, inert at load. +// Loaded after launchpad.scd by boot.scd. +( +~toscPort = ~toscPort ? 9000; +~tosc = ~tosc; // NetAddr of current client, or nil + +~toscTouch = { |addr| + if(addr.notNil) { + if(~tosc.isNil or: { ~tosc.ip != addr.ip }) { + ~tosc = NetAddr(addr.ip, ~toscPort); + ("[touchosc] client = " ++ addr.ip).postln; + }; + }; +}; +~toscSend = { |path ...args| ~tosc !? { ~tosc.sendMsg(path, *args) } }; + +~toscPatterns = [\kick,\hats,\clap,\perc,\sub,\acid,\arp,\lead, + \stab,\pad,\ride,\rim,\tom,\reese,\bells,\sweep]; +~toscArmedPush = { + ~toscPatterns.do { |nm| + var on = (~lp[\armed].notNil and: { ~lp[\armed].includes(nm) }).binaryValue; + ~toscSend.("/armed/" ++ nm.asString, on); + }; +}; + +// Sniff inbound control to capture the client address; push seq state on select. +[ '/launch', '/launch/vol', '/launch/tempo', '/control', '/seq/melody', + '/seq/rhythm' ].do { |path| + OSCdef(("tosc_sniff_" ++ path.asString).asSymbol, { |msg, time, addr| + ~toscTouch.(addr); + }, path); +}; + +~toscSeqPush = { |group, idx| + var item; + if(group == \rhythm) { + item = ~lp[\rhythms][idx]; + ~toscSend.("/seq/rhythm/state", idx, item[\k], item[\s], item[\h], item[\name]); + } { + item = ~lp[\melodies][idx]; + ~toscSend.("/seq/melody/state", idx, *(item[\degrees] ++ [item[\name]])); + }; +}; +OSCdef(\tosc_seq_rhy, { |msg, t, a| ~toscTouch.(a); ~toscSeqPush.(\rhythm, msg[1].asInteger) }, '/seq/rhythm'); +OSCdef(\tosc_seq_mel, { |msg, t, a| ~toscTouch.(a); ~toscSeqPush.(\melody, msg[1].asInteger) }, '/seq/melody'); + +// Forward internal RMS / amp replies (paths confirmed in Step 1) to TouchOSC. +// Replace '/rmsReply' and '/ampReply' with the real internal addresses. +OSCdef(\tosc_rms, { |msg| ~toscSend.("/sync/rms", msg.last) }, '/rmsReply'); +OSCdef(\tosc_amp, { |msg| ~toscSend.("/sync/amp", *msg[1..]) }, '/ampReply'); + +// Periodic armed push (covers concert-driven changes) + beat pulse. +~toscFeedbackRoutine !? { ~toscFeedbackRoutine.stop }; +~toscFeedbackRoutine = Routine({ + var beatCount = 0; + loop { + ~toscArmedPush.(); + beatCount = beatCount + 1; + if(beatCount % 2 == 0) { ~toscSend.("/sync/beat", 1) }; // ~2 Hz pulse + 0.25.wait; + }; +}).play(~lpClock.value ? TempoClock.default); + +"[touchosc] feedback ready".postln; +) +``` +(After Step 1, set the real RMS/amp reply paths. If `web_bridge.scd` exposes a +single send chokepoint instead, prefer adding one `~toscSend` line there; keep +this file's own beat pulse and armed push regardless.) + +- [ ] **Step 4: Run the headless test** + +Run: +```bash +cd /Users/electron/Documents/Projets/AV-Live +sclang sound_algo/data_only/test/test_touchosc_feedback.scd 2>&1 | grep -E "TEST (PASS|FAIL)" +``` +Expected: `TEST PASS`. (Run on macm1 if sclang is unavailable on GrosMac; see +the project SC boot notes — use `script -q` for headless if needed.) + +- [ ] **Step 5: Validate the .scd balance (project hard requirement)** + +Run: +```bash +awk '{n+=gsub(/\(/,"(")-gsub(/\)/,")"); b+=gsub(/\[/,"[")-gsub(/]/,"]")} \ +END{print "P:"n" B:"b}' sound_algo/data_only/touchosc_feedback.scd +``` +Expected: `P:0 B:0`. + +- [ ] **Step 6: Commit** + +```bash +git add sound_algo/data_only/touchosc_feedback.scd \ + sound_algo/data_only/test/test_touchosc_feedback.scd +git commit -m "feat: sc touchosc feedback sender" +``` + +--- + +## Task 9: Wire feedback into the SC boot + +**Files:** +- Modify: `sound_algo/data_only/boot.scd` (load `touchosc_feedback.scd` after `launchpad.scd`) + +**Interfaces:** +- Consumes: the load order in `boot.scd`. +- Produces: feedback file loaded last, nil-guarded. + +- [ ] **Step 1: Find the launchpad load line** + +Run: +```bash +grep -nE "launchpad|concert_gestures|loadRelative|executeFile" \ + sound_algo/data_only/boot.scd +``` +Note the exact line that loads `launchpad.scd` (loaded LAST per project memory). + +- [ ] **Step 2: Add the feedback load immediately after launchpad** + +Add the load of `touchosc_feedback.scd` right after the `launchpad.scd` load, +using the same load idiom already present (e.g. `loadRelative` or +`executeFile`). Example (match the existing style): +```supercollider +// after: ... "launchpad.scd" load +"touchosc_feedback.scd".loadRelative; +``` + +- [ ] **Step 3: Headless boot smoke test** + +Run (on macm1 if needed): +```bash +cd /Users/electron/Documents/Projets/AV-Live +timeout 40 sclang sound_algo/data_only/boot.scd 2>&1 | \ + grep -E "touchosc\] feedback ready|ERROR|FAILURE" | head +``` +Expected: `[touchosc] feedback ready` appears; no `ERROR`/`FAILURE` from the new file. + +- [ ] **Step 4: Commit** + +```bash +git add sound_algo/data_only/boot.scd +git commit -m "feat: load touchosc feedback after launchpad" +``` + +--- + +## Task 10: End-to-end validation on GrosMac + iPad deploy notes + +**Files:** +- Create: `touchosc/README.md` + +**Interfaces:** +- Consumes: the built `dist/av-live-control.tosc`, a running SC engine on macm1. +- Produces: a documented, repeatable validate-then-deploy procedure. + +- [ ] **Step 1: Configure the TouchOSC OSC connection on GrosMac** + +In TouchOSC on GrosMac, set Connection 1 to OSC over UDP, host = `supra-m1.local` +(macm1), send port = `57121`, and enable receive on port `9000`. Confirm the +`connections` field in the generated layout targets Connection 1 (matches +`CONN_DEFAULT` from Task 2). + +- [ ] **Step 2: Smoke-test control GrosMac -> macm1** + +With SC running on macm1 (boot per project notes), open +`dist/av-live-control.tosc` in TouchOSC on GrosMac, enter play mode, and tap a +few pads + move the tempo and a volume fader. +Expected: SC posts the corresponding `/launch`, `/launch/tempo`, `/launch/vol` +messages (watch the sclang post window) and audio responds. + +- [ ] **Step 3: Verify feedback macm1 -> GrosMac** + +Expected: tapped pads light (armed reflection), the beat box flashes, the master +VU moves, and selecting a rhythm/melody preset loads its steps and name into the +editor widgets. Capture a screenshot for the record: +```bash +screencapture -x /tmp/touchosc_validation.png +``` + +- [ ] **Step 4: Write the deploy README** + +`touchosc/README.md` documenting: how to build (`uv run python -m gen.build_layout`), +the GrosMac validation steps above, the OSC connection settings (host +`supra-m1.local`, send `57121`, receive `9000`), and how to deploy to the iPad +(open the `.tosc` via AirDrop / TouchOSC document sync, set the same connection +to `supra-m1.local:57121`, receive `9000`, confirm landscape canvas). Note the +canvas is 1194×834 with proportional anchoring; if the iPad differs, set +`CANVAS` in `layout.py` and rebuild. + +- [ ] **Step 5: Commit** + +```bash +git add touchosc/README.md +git commit -m "docs: touchosc build validate and deploy guide" +``` + +--- + +## Self-Review notes (coverage check) + +- Spec §2 decisions → Global Constraints + Tasks 1-10. ✓ +- Spec §3 topology (control + sender-captured feedback) → Tasks 4/6 (receive messages) + Task 8 (`~toscTouch`). ✓ +- Spec §4 three pages → Tasks 4, 5, 6. ✓ +- Spec §5 OSC mapping → asserted in Tasks 4-6 tests; feedback in Task 8. ✓ +- Spec §6 SC feedback (sender capture, armed broadcast, seq state, sync relay) → Task 8 (armed via 4 Hz push covering concert-driven changes — an implementation refinement of the spec's "emit on arm/disarm", same observable result). ✓ +- Spec §7 Lua responsibilities → Task 7. ✓ +- Spec §8 GrosMac validation loop → Task 1 (probe), Task 2 (fidelity gate), Task 7 (full reopen), Task 10 (e2e). ✓ +- Spec §9 out of scope respected (web_realart untouched; no new instruments). ✓ +- Open params (canvas, ports, body-play address, RMS/amp reply paths) flagged inline where resolved. ✓ diff --git a/docs/superpowers/specs/2026-06-28-tosc-schema-reference.md b/docs/superpowers/specs/2026-06-28-tosc-schema-reference.md new file mode 100644 index 0000000..fdcb86f --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-tosc-schema-reference.md @@ -0,0 +1,126 @@ +# TouchOSC `.tosc` v3 Schema Reference (ground truth) + +**Date:** 2026-06-28 +**Status:** Format pinned from generator output; **visual fidelity gate +(render in TouchOSC editor) pending human confirmation** — see §6. +**Consumes/feeds:** `touchosc/gen/schema.py` (the single format choke point). + +## 1. Provenance + +The plan's Task 1 step 1 (hand-author a reference layout in the TouchOSC +editor) was replaced by a **generate-and-iterate** path (no pre-existing +`.tosc` was found on disk at planning time). The authoritative bytes here come +from `touchosc/gen/schema.py` emitting a minimal GROUP > FADER probe, captured +in `touchosc/reference/probe-roundtrip.{tosc,xml,pretty.xml}`. + +A byte-identical copy of this probe was found in TouchOSC's iCloud document +cache (`~/Library/Application Support/CloudDocs/session/i/`, md5 +`aaa466e27c16ab9b25e18c84a98a71a1`), indicating TouchOSC ingested the file into +its library — weak-positive evidence the format is accepted. The XML structure +matches the documented Hexler v3 `lexml` schema in every element. **Final +proof — the layout rendering correctly in the TouchOSC editor — is a human +at-screen check (§6).** + +## 2. Container + +- A `.tosc` is **gzip-compressed UTF-8 XML** (Python `gzip` default level 9). +- Root element: `` containing exactly **one** root ``. +- XML declaration present: ``. + +## 3. Node + +``` + + property* + + osc* + node* + +``` + +- `ID` — a UUID4 string (`tosc_id()`). +- `type` — control type: `GROUP BOX BUTTON LABEL TEXT FADER XY RADIAL ENCODER + RADAR RADIO PAGER GRID` (per v3). The probe confirms `GROUP` and `FADER`. +- All four sub-blocks (`properties`, `values`, `messages`, `children`) are + emitted, empty as `<.../>` self-closing when they have no entries. + +## 4. Property + +``` + + name + ... + +``` + +Type codes (the `` child shape depends on the code): + +| code | meaning | `` shape | +|------|---------|-----------------| +| `s` | string | plain text (`vol`); also used for the `script` Lua property | +| `b` | bool | plain text `0`/`1` | +| `i` | int | plain text | +| `f` | float | plain text | +| `r` | frame/rect | `` | +| `c` | color | `` | + +The `frame` property (`type="r"`) is confirmed verbatim in the probe: +`102060200`. + +## 5. OSC message + +``` + + + xANY + + + ... + + + ... * + + +``` + +- **Attributes:** `enabled send receive feedback connections`, all present. +- **`connections`** = a fixed-width bit-string, **one flag per OSC connection, + left-to-right = connection 1..N**. TouchOSC writes **5 flags**; a fresh + message targeting connection 1 only is **`"00001"`** (rightmost = connection + 1). This is `schema.CONN_DEFAULT`. (NB: the plan text guessed `"00000001"`; + the pinned value is `"00001"`.) + +### 5.1 Partial (path segment OR argument) + +Fields are **child elements** (NOT attributes — this is the single biggest +deviation from the inactive `tosclib` reference, which used attributes): + +``` + + CONSTANT|VALUE + STRING|FLOAT|INTEGER|BOOLEAN + ... + 0 + 1 + +``` + +- `CONSTANT`/`STRING` with `/launch/vol` → a literal address (or + a literal string argument like `kick`). +- `VALUE`/`FLOAT` with `x` → bound to the control's `x` value. +- `scaleMin`/`scaleMax` present on every partial (default `0`/`1`). + +## 6. Visual fidelity gate (OPEN — human step) + +The generated bytes are structurally correct v3. The remaining check is purely +visual and must be done by a human at the GrosMac screen: + +1. In TouchOSC: **File > Open** → `touchosc/dist/_probe.tosc` (or the full + `av-live-control.tosc` once built). +2. Confirm the control(s) render with no parse error dialog. + +`open -a TouchOSC ` launches the app but was observed to show an +"Untitled" window rather than reliably loading the doc — prefer **File > Open** +inside TouchOSC for the gate. If a layout fails to load, fix +`touchosc/gen/schema.py` against this reference and regenerate; no other file +needs to change. diff --git a/docs/superpowers/specs/2026-06-28-touchosc-control-surface-design.md b/docs/superpowers/specs/2026-06-28-touchosc-control-surface-design.md new file mode 100644 index 0000000..147fcbe --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-touchosc-control-surface-design.md @@ -0,0 +1,174 @@ +# TouchOSC Control Surface — Design Spec + +**Date:** 2026-06-28 +**Status:** Approved (brainstorm) — pending implementation plan +**Topic:** Full UI/UX redesign of the SuperCollider control surface, moving from +the custom web surface (`web_realart/public/control/`) to a native **TouchOSC** +(Hexler, modern) layout that speaks OSC directly to the macm1 SC engine. + +--- + +## 1. Motivation + +The current control surface is a single-page vanilla HTML/CSS/JS app +(`web_realart/public/control/`, ~388 LOC) served at +`http://supra-m1.local:4400/control/`. It works but: + +- Everything is stacked on one scrolling page; not optimized for touch. +- No live feedback from the engine (SC already emits `/sync/beat`, `/sync/rms`, + `/sync/amp`, step data — none of it is reflected in the UI). +- The browser cannot send UDP, so it requires a Node/WebSocket→OSC bridge hop. + +TouchOSC is purpose-built for live touch control surfaces and removes these +limitations: it speaks OSC/UDP **directly** to SC (no Node bridge for control), +supports **bidirectional feedback**, native **pages/tabs**, dedicated widgets +(XY pads, grids, faders), and **Lua** scripting for dynamic logic. + +## 2. Locked decisions + +| Topic | Decision | +|-------|----------| +| Direction | **TouchOSC integral** — the web `/control/` surface is retired as a control method. | +| Play device | **iPad, landscape**. | +| Authoring/validation | **TouchOSC desktop on GrosMac** (open generated `.tosc`, screenshot, OSC smoke-test to macm1). | +| App version | **New TouchOSC (Hexler)** — required for Lua + bidirectional OSC feedback. | +| Build method | **Generate `.tosc` by code** (gzipped XML), versioned in the repo, iterated by diff. | +| Preset naming | Names live in `launchpad.scd`; SC pushes names to TouchOSC as label feedback. On device: **select + edit steps only**, no text entry. | +| Feedback scope | **Full suite** — beat flash, master RMS meter, armed-state reflection on pads, sequencer playhead + per-voice VU. | +| Canvas | Default **1194×834** (iPad 11"/Air) with **proportional anchoring** (responsive); exact model confirmed at test time. | +| Web stack | `web_realart` server **stays** for visual sync (Hydra/WebGL). Only the `/control/` UI role is retired. | + +## 3. Architecture & topology + +``` +TouchOSC (iPad in play / GrosMac in authoring) + │ OSC/UDP ──► macm1 : (control) + ▼ +SuperCollider engine on macm1 (launchpad.scd + concert) + │ OSC/UDP ──► : (feedback) +``` + +- **Control path:** TouchOSC sends the existing OSC routes directly to macm1. + All current `OSCdef`s are reused unchanged. +- **Feedback path:** a new SC file captures the sender `NetAddr` from incoming + control messages (`~tosc = addr`) and targets feedback at that address. The + feedback therefore **follows whoever is controlling** — GrosMac during + authoring, iPad during the live set — with **no static IP** to configure. +- The existing `web_bridge.scd` → Node sync feeds (`/sync/*` for Hydra/WebGL) + are **unchanged**; TouchOSC feedback is an additional, parallel send. + +## 4. Page layout (3 pages, TouchOSC pager) + +### Page 1 — LIVE (primary performance page) +- **Top bar:** master **tempo** fader/encoder + BPM readout · **CLEAR ALL** + button · **master RMS** VU meter · global **beat-flash** indicator. +- **4×4 grid of 16 pads.** Each pad = arm/disarm button + small vertical + **volume** fader + **label** (pattern name) + **armed-state feedback** (lit on + SC confirmation, tracks concert-driven changes). +- Pattern set (from `launchpad.scd`): kick, hats, clap, perc, sub, acid, arp, + lead, stab, pad, ride, rim, tom, reese, bells, sweep. + +### Page 2 — FX / HARMONIE / CONCERT +- **FX:** filter cutoff (large fader 0–1, or XY) · 6 one-shots: stutter, crash, + kick, swell, breakdown↓, breakdown↑. +- **Harmonie:** root −/+ · scale radio (minor/dorian/phrygian/penta) · octave + −/+ · phrase radio (0–3). +- **Concert:** morceau suivant · scène concert · body-play. + +### Page 3 — SÉQUENCEURS +- **Rhythm:** 8 preset selectors (radio, names pushed by SC) · **3×16 grid** + (K/S/H rows) of toggles → Lua assembles three 16-char strings → + `/seq/rhythm/set` · **playhead** column highlight. +- **Melody:** 8 preset selectors (names from SC) · **8 vertical faders** (one per + step, quantized to integer degrees −7..14) + value label → Lua → + `/seq/melody/set` · playhead · arm-melody button. +- **Per-voice VU** strip: kick/hat/snare/clap/perc/melody/acid/harmony + (via `/sync/amp`). + +## 5. OSC mapping (reuses existing SC routes — no route changes) + +**Control (TouchOSC → SC):** + +| Widget | Address | Args | +|--------|---------|------| +| Pad arm | `/launch` | ` <0\|1>` | +| Pad volume | `/launch/vol` | ` <0..1.5>` | +| Tempo | `/launch/tempo` | `` | +| Clear all | `/launch/clear` | — | +| FX filter | `/control/fx/filter` | `<0..1>` | +| FX one-shots | `/control/fx/{stutter,crash,kick,swell,breakdown}` | `<0\|1>` | +| Harmony | `/control/harmony/{root,scale,octave,phrase}` | varies | +| Concert | `/control/concertNext`, `/control/doScene` | — | +| Seq select | `/seq/melody`, `/seq/rhythm` | `` | +| Seq edit | `/seq/melody/set`, `/seq/rhythm/set` | degrees / ` ` | + +**Feedback (SC → TouchOSC):** + +| Source | Address | Effect on TouchOSC | +|--------|---------|--------------------| +| Arm/disarm (incl. concert) | `/armed/` `<0\|1>` | Pad lit/unlit | +| Beat | `/sync/beat` | Global flash | +| Master level | `/sync/rms` | Master VU | +| Per-voice level | `/sync/amp` | Per-voice VU | +| Step position | step index (TBD address) | Sequencer playhead | +| Preset selection/edit | `/seq/{rhythm,melody}/state` + name | Grid/faders + name labels reflect selected preset | + +## 6. SC-side additions + +New file `sound_algo/data_only/touchosc_feedback.scd`, loaded after +`launchpad.scd` (nil-guarded, inert at load like the other data_only files): + +1. **Sender capture:** on any inbound control message, store `~tosc = addr`. +2. **Armed broadcast:** emit `/armed/ <0|1>` whenever a pattern arms or + disarms, including when driven by the concert/morceau engine (not just by + TouchOSC), so the surface stays in sync. +3. **Sequencer state:** on `/seq/*` select and on edit, send + `/seq/rhythm/state` (k/s/h strings) and `/seq/melody/state` (degrees) plus + the preset **name**, so selecting a preset loads its steps and label into the + editor. +4. **Sync relay:** mirror `/sync/beat`, `/sync/rms`, `/sync/amp`, and the step + index to `~tosc` (in addition to the existing Node sends). + +## 7. Lua responsibilities (TouchOSC side) + +- **Pad logic:** toggle → `/launch`; receive `/armed/*` → set pad color/state. +- **Rhythm grid:** on any cell change, read the 3×16 grid → build three + 16-char strings → `/seq/rhythm/set`. +- **Melody faders:** on change, read 8 faders → quantize to int degrees → + `/seq/melody/set`. +- **Preset select:** send index, then apply `/seq/*/state` from SC onto the + grid/faders and update name labels. +- **Feedback receivers:** beat→flash, rms→master VU, amp→per-voice VU, + step index→playhead highlight. + +## 8. Build & validation workflow (GrosMac loop) + +1. **Format-fidelity probe:** generate a minimal 1-widget `.tosc`, open it in + TouchOSC on GrosMac to confirm the generated gzip-XML loads correctly + **before** building the full layout. +2. **Full build:** generate the complete `.tosc` (widgets + OSC + Lua). +3. **Visual check:** open in TouchOSC on GrosMac, screenshot, verify layout. +4. **OSC smoke-test:** from GrosMac TouchOSC, send to macm1 SC; confirm SC + reacts and feedback lights the surface. +5. **Deploy:** load on the iPad for the live set. + +## 9. Out of scope (YAGNI) + +- On-device preset text entry (names live in code). +- Deleting `web_realart` or its visual-sync feeds. +- New SC instruments/patterns (the 16 existing patterns are the set). +- Cross-device preset sync / server-side persistence. + +## 10. Open parameters & risks + +- **Canvas size:** default 1194×834 with proportional anchoring; confirm iPad + model at test time. +- **`.tosc` format fidelity:** generating gzip-XML by hand carries a risk the + current TouchOSC rejects it; mitigated by the step-1 probe on GrosMac. + `tosclib`/`touchosc-generator` are references only (inactive, unaffiliated + with Hexler). +- **SC OSCdef / TouchOSC ports:** control port assumed 57121 (current); + TouchOSC listen port assumed 9000 (default) — both confirmed/parameterized + during implementation. +- **Lua complexity:** the grid→string and state round-trip are the main Lua + surface; keep scripts small and per-widget where possible. diff --git a/sound_algo/data_only/boot.scd b/sound_algo/data_only/boot.scd index 871f2a5..c9487c0 100644 --- a/sound_algo/data_only/boot.scd +++ b/sound_algo/data_only/boot.scd @@ -194,6 +194,9 @@ Routine({ // -- 6f. Web OSC launchpad (Pdef bank, armed via /launch) ----- (~base ++ "launchpad.scd").load; s.sync; + // -- 6g. TouchOSC feedback bridge ----- + (~base ++ "touchosc_feedback.scd").load; s.sync; + // -- 7) Demarre la scene par defaut ----- if(~doScene.isNil) { "[data-only/boot] ERREUR : ~doScene non defini".warn; diff --git a/sound_algo/data_only/launchpad.scd b/sound_algo/data_only/launchpad.scd index 2d530eb..cae306a 100644 --- a/sound_algo/data_only/launchpad.scd +++ b/sound_algo/data_only/launchpad.scd @@ -93,7 +93,10 @@ s.sync; ~lp = ~lp ? (); ~lp[\clock] = ~lp[\clock] ? TempoClock(126/60).permanent_(true); ~lp[\armed] = ~lp[\armed] ? Set.new; -~lp[\vol] = ~lp[\vol] ? IdentityDictionary.new; +~lp[\vol] = ~lp[\vol] ? IdentityDictionary.new; +~lp[\quant] = ~lp[\quant] ? 4; +~lp[\clipState] = ~lp[\clipState] ? IdentityDictionary.new; +~lp[\clipGen] = ~lp[\clipGen] ? IdentityDictionary.new; // melody sequences: 8 varied riffs/arps/phrases, minor/phrygian feel // wrapAt allows different lengths to loop cleanly over 16 16th-note steps @@ -262,27 +265,63 @@ Pdef(\lp_rsh, Pbind( // arm / disarm a pattern by bare name (e.g. "kick" -> Pdef(\lp_kick)) // "rhythmseq" special-cases: arms/disarms \lp_rsk, \lp_rss, \lp_rsh together +// 4-state machine per clip: 0=off, 1=playing, 2=queued-launch, 3=queued-stop +// Launch and stop are quantized to ~lp[\quant] beats on the TempoClock. +// A generation counter per clip cancels superseded pending callbacks. ~lpArm = { |name, on| - var nm = name.asSymbol; - var key = ("lp_" ++ name.asString).asSymbol; - (name.asString == "rhythmseq").if({ + var nm, keys, clock, q, s, gen, isRhythm; + nm = name.asSymbol; + isRhythm = (name.asString == "rhythmseq"); + keys = isRhythm.if( + { [\lp_rsk, \lp_rss, \lp_rsh] }, + { [("lp_" ++ name.asString).asSymbol] } + ); + (isRhythm or: { Pdef.all.includesKey(keys[0]) }).if({ + clock = ~lp[\clock] ? (~lpClock.value ? TempoClock.default); + q = ~lp[\quant] ? 4; + s = ~lp[\clipState][nm] ? 0; on.if({ - [\lp_rsk, \lp_rss, \lp_rsh].do { |k| Pdef(k).play(~lpClock.value, quant: 1) }; - ~lp[\armed].add(nm); - }, { - [\lp_rsk, \lp_rss, \lp_rsh].do { |k| Pdef(k).stop }; - ~lp[\armed].remove(nm); - }); - }, { - if(Pdef.all.includesKey(key)) { - on.if({ - Pdef(key).play(~lpClock.value, quant: 1); + // launch tap + (s == 0).if({ + gen = (~lp[\clipGen][nm] ? 0) + 1; ~lp[\clipGen][nm] = gen; + ~lp[\clipState][nm] = 2; ~lp[\armed].add(nm); - }, { - Pdef(key).stop; + keys.do { |k| Pdef(k).play(clock, quant: q) }; + clock.schedAbs(clock.nextTimeOnGrid(q), { + (~lp[\clipGen][nm] == gen).if({ ~lp[\clipState][nm] = 1 }); + nil + }); + }); + (s == 3).if({ + gen = (~lp[\clipGen][nm] ? 0) + 1; ~lp[\clipGen][nm] = gen; + // queued-stop cancelled: clip still playing, revert to 1 + ~lp[\clipState][nm] = 1; + ~lp[\armed].add(nm); + }); + // s == 1 or s == 2: no-op (pending closure preserved) + }, { + // stop tap + (s == 1).if({ + gen = (~lp[\clipGen][nm] ? 0) + 1; ~lp[\clipGen][nm] = gen; + ~lp[\clipState][nm] = 3; + ~lp[\armed].remove(nm); + clock.schedAbs(clock.nextTimeOnGrid(q), { + (~lp[\clipGen][nm] == gen).if({ + keys.do { |k| Pdef(k).stop }; + ~lp[\clipState][nm] = 0; + }); + nil + }); + }); + (s == 2).if({ + gen = (~lp[\clipGen][nm] ? 0) + 1; ~lp[\clipGen][nm] = gen; + // queued-launch cancelled: stop before it fires + keys.do { |k| Pdef(k).stop }; + ~lp[\clipState][nm] = 0; ~lp[\armed].remove(nm); }); - }; + // s == 0 or s == 3: no-op (pending closure preserved) + }); }); }; @@ -300,6 +339,9 @@ OSCdef(\lpClear, { |msg| OSCdef(\lpVol, { |msg| ~lp[\vol][(msg[1] ? \nil).asSymbol] = (msg[2] ? 0.8).clip(0, 1.5); }, '/launch/vol'); +OSCdef(\lpQuant, { |msg| + ~lp[\quant] = (msg[1] ? 4).asFloat.clip(0.125, 64); +}, '/launch/quant'); OSCdef(\ctlHarmRoot, { |msg| ~ccHarmony !? { ~ccHarmony[\root] = (~ccHarmony[\root] + (msg[1] ? 0)).clip(33, 57) }; diff --git a/sound_algo/data_only/test/test_touchosc_feedback.scd b/sound_algo/data_only/test/test_touchosc_feedback.scd new file mode 100644 index 0000000..1f5d93c --- /dev/null +++ b/sound_algo/data_only/test/test_touchosc_feedback.scd @@ -0,0 +1,62 @@ +// Headless test for touchosc_feedback.scd +// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang \ +// sound_algo/data_only/test/test_touchosc_feedback.scd +// Expected output: TEST PASS (with no ERROR lines from the feature file) +// +// The audio server is NOT booted, so the RMS probe block is skipped by its +// Server.default.serverRunning guard. The Routine is started and immediately +// stopped — the armed push fires once with ~tosc nil, which must not raise. +( +// Resolve the feature file relative to this test file so it runs on any +// clone path (e.g. GrosMac and macM1 have different repo roots). +var featureFile = PathName(thisProcess.nowExecutingPath).pathOnly + ++ "../touchosc_feedback.scd"; +var pass = true; + +// Build a minimal dummy environment matching the shapes launchpad.scd creates +~lp = ( + clock: TempoClock.default, + armed: Set[\kick], + clipState: IdentityDictionary.new, + quant: 4, + melodies: [[0, 2, 3, 5]], + rhythms: [(name: "4floor", k: "1000100010001000", + s: "0000100000001000", h: "0010101010101010")] +); + +// Load the feature file (synchronous; server not booted -> RMS block skipped) +featureFile.load; + +// Assert the three public API functions were wired up +pass = pass and: { ~toscSend.notNil }; +pass = pass and: { ~toscTouch.notNil }; +pass = pass and: { ~toscArmedPush.notNil }; + +// Calling ~toscArmedPush.() with no clients must not raise +// (~toscSend iterates an empty dictionary -> no-op, no OSC sent) +try { ~toscArmedPush.() } { |e| + pass = false; + ("EXCEPTION in ~toscArmedPush: " ++ e.messageString).postln; +}; + +// Assert multicast set grows with distinct IPs +~toscTouch.(NetAddr("127.0.0.1", 0)); +~toscTouch.(NetAddr("10.0.0.9", 0)); +pass = pass and: { ~toscClients.size == 2 }; + +// Stop the background Routine so the process can exit cleanly +~toscFeedbackRoutine !? { ~toscFeedbackRoutine.stop }; + +// Assert 4-state clipState is read without raising +~lp[\clipState][\kick] = 2; // queued-launch +try { ~toscArmedPush.() } { |e| + pass = false; + ("EXCEPTION in ~toscArmedPush (clipState): " ++ e.messageString).postln; +}; + +pass.if( + { "TEST PASS".postln }, + { "TEST FAIL".postln } +); +0.exit; +) diff --git a/sound_algo/data_only/touchosc_feedback.scd b/sound_algo/data_only/touchosc_feedback.scd new file mode 100644 index 0000000..471768b --- /dev/null +++ b/sound_algo/data_only/touchosc_feedback.scd @@ -0,0 +1,142 @@ +// TouchOSC feedback bridge +// Sends engine state back to the TouchOSC control surface so the iPad +// reflects what the SC engine is doing: +// +// /armed/ pad armed state (0 or 1), one message per pattern +// /sync/rms master amplitude (single float 0..1), 20 Hz +// /sync/beat beat pulse (value 1, once per beat on ~lp clock) +// /seq/rhythm/state idx k s h name (echoed when /seq/rhythm received) +// /seq/melody/state idx deg0 deg1 ... (echoed when /seq/melody received) +// +// Sender capture strategy: we sniff /launch, /launch/vol, /launch/tempo +// and /launch/clear — messages the surface sends on every connect/pad tap — +// to discover the client IP. The /seq/* sniffers double as echo triggers. +// We intentionally skip /control/* exact paths: the surface sends +// /control/fx/... paths (not bare /control) which OSCdef can't match as a +// prefix, so they add no capture value. +// +// Additive + idempotent: re-loading stops the prior Routine and re-registers +// OSCdefs (same unique keys replace existing ones in OSCdef's registry). +( + +// -- Port + address init -- +~toscPort = ~toscPort ? 9000; +~toscClients = ~toscClients ? Dictionary.new; + +// Add / refresh a client entry when a control message arrives from the surface. +// Keyed by ip String so the same host always maps to one NetAddr (dedup). +// Logs only when a NEW ip is first seen. +~toscTouch = { |addr| + var ip = addr.ip; + ~toscClients[ip].isNil.if({ + ~toscClients[ip] = NetAddr(ip, ~toscPort); + ("[touchosc] client -> " ++ ip ++ ":" ++ ~toscPort.asString).postln; + }); +}; + +// Send an OSC message to all known clients; no-op when none connected +~toscSend = { |path ...args| + ~toscClients.values.do { |dest| dest.sendMsg(path, *args) }; +}; + +// Canonical pad name order (16 pads in grid order, matches the surface layout) +~toscPatterns = [ + \kick, \hats, \clap, \perc, + \sub, \acid, \arp, \lead, + \stab, \pad, \ride, \rim, + \tom, \reese, \bells, \sweep +]; + +// Push 4-state clip value for all pads + sequencers; nil-guarded. +// State: 0=off, 1=playing, 2=queued-launch, 3=queued-stop. +// Prefers fine-grained clipState; falls back to armed membership (0/1) +// for clips driven by concert logic that bypass ~lpArm. +~toscArmedPush = { + ~lp.notNil.if({ + var list = ~toscPatterns ++ [\melseq, \rhythmseq]; + list.do { |nm| + var state = (~lp[\clipState] !? { ~lp[\clipState][nm] }) + ? ((~lp[\armed].notNil and: { ~lp[\armed].includes(nm) }).binaryValue); + ~toscSend.("/armed/" ++ nm.asString, state); + }; + }); +}; + +// -- Sender-capture sniffers (unique keys prefixed \tosc_sniff_) -- +OSCdef(\tosc_sniff_launch, { |msg, time, addr| ~toscTouch.(addr) }, '/launch'); +OSCdef(\tosc_sniff_vol, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/vol'); +OSCdef(\tosc_sniff_tempo, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/tempo'); +OSCdef(\tosc_sniff_clear, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/clear'); + +// -- Seq state echo + capture -- +// When the surface selects a rhythm preset, we echo the full pattern back. +OSCdef(\tosc_sniff_seqrhythm, { |msg, time, addr| + var idx, item; + ~toscTouch.(addr); + (~lp.notNil and: { ~lp[\rhythms].notNil }).if({ + idx = msg[1].asInteger; + item = ~lp[\rhythms][idx]; + item.notNil.if({ + ~toscSend.( + "/seq/rhythm/state", + idx, + item[\k].asString, + item[\s].asString, + item[\h].asString, + item[\name].asString + ); + }); + }); +}, '/seq/rhythm'); + +// When the surface selects a melody preset, we echo the degree array back. +OSCdef(\tosc_sniff_seqmelody, { |msg, time, addr| + var idx, degs; + ~toscTouch.(addr); + (~lp.notNil and: { ~lp[\melodies].notNil }).if({ + idx = msg[1].asInteger; + degs = ~lp[\melodies][idx]; + degs.notNil.if({ + ~toscSend.valueWithArguments( + ["/seq/melody/state"] ++ [idx] ++ degs + ); + }); + }); +}, '/seq/melody'); + +// -- Master RMS probe (server-only block; skipped when booting headless) -- +// Mirrors the \webRmsProbe recipe from web_bridge.scd, renamed to avoid clash. +// Wrapped in a Routine so Server.default.sync is a real suspension point +// both inside boot.scd's Routine AND on a manual IDE reload (where top-level +// code runs on AppClock and a bare sync would be a no-op, firing Synth.tail +// before the SynthDef finished compiling). +Server.default.serverRunning.if({ + Routine({ + SynthDef(\toscRmsProbe, { |inBus = 0, rate = 20| + var sig = Mix.ar(In.ar(inBus, 2)) * 0.5; + var amp = Amplitude.kr(sig, 0.01, 0.2); + SendReply.kr(Impulse.kr(rate), '/toscRms', [amp]); + }).add; + ~toscRmsResp !? { ~toscRmsResp.free }; + ~toscRmsSynth !? { ~toscRmsSynth.free }; + Server.default.sync; + ~toscRmsSynth = Synth.tail(nil, \toscRmsProbe, [\inBus, 0, \rate, 20]); + ~toscRmsResp = OSCFunc({ |msg| + ~toscSend.("/sync/rms", msg[3].asFloat); + }, '/toscRms', Server.default.addr); + }).play; +}); + +// -- Beat pulse + armed state push on the launchpad clock -- +// Stop any prior Routine before (re)starting so reloads don't stack. +~toscFeedbackRoutine !? { ~toscFeedbackRoutine.stop }; +~toscFeedbackRoutine = Routine({ + { + ~toscArmedPush.(); + ~toscSend.("/sync/beat", 1); + 1.wait; + }.loop; +}).play(~lp.notNil.if({ ~lp[\clock] ? TempoClock.default }, { TempoClock.default })); + +"[touchosc] feedback ready".postln; +) diff --git a/touchosc/.gitignore b/touchosc/.gitignore new file mode 100644 index 0000000..7279531 --- /dev/null +++ b/touchosc/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc +dist/_probe.tosc diff --git a/touchosc/README.md b/touchosc/README.md new file mode 100644 index 0000000..76b4b12 --- /dev/null +++ b/touchosc/README.md @@ -0,0 +1,99 @@ +# TouchOSC control surface + +A native TouchOSC (Hexler) iPad layout that drives the AV-Live SuperCollider +engine over OSC and reflects live engine state back onto the surface. Generated +by code (gzipped XML), versioned in this repo, iterated by diff. Replaces the +`web_realart/public/control/` web surface as the **control** method +(`web_realart` stays for Hydra/WebGL visual sync). + +## What it is + +- `gen/schema.py` — TouchOSC `.tosc` v3 serializer (gzip-XML). Single format + choke point. See `docs/superpowers/specs/2026-06-28-tosc-schema-reference.md`. +- `gen/layout.py` — data-driven 3-page layout model + widget builders. +- `gen/lua/*.lua` — per-widget Lua (pad arm-color, rhythm grid, melody faders, + beat/RMS feedback, preset labels). Embedded at build time. +- `gen/build_layout.py` — entrypoint → `dist/av-live-control.tosc`. +- SC feedback: `sound_algo/data_only/touchosc_feedback.scd` (loaded by + `sound_algo/data_only/boot.scd` after `launchpad.scd`). + +### Pages +1. **LIVE** — tempo fader, CLEAR ALL, master VU, beat flash, 4×4 grid of 16 + pads (arm button + volume fader + name, armed-state reflection). +2. **FX / HARMONIE / CONCERT** — filter, FX one-shots, breakdown in/out; + harmony root/scale/octave/phrase; morceau next, concert, body-play. +3. **SÉQUENCEURS** — rhythm (8 presets + 3×16 grid) and melody (8 presets + 8 + step faders) with arm buttons. + +## Build + +```bash +cd touchosc +uv run python -m gen.build_layout # writes dist/av-live-control.tosc +uv run pytest -q # 20 tests +``` + +`*.tosc` build outputs under `dist/` are the deliverable and are committed. + +## Validate on GrosMac (before deploying to the iPad) + +1. **Fidelity gate (do this first):** in TouchOSC desktop, **File > Open** → + `touchosc/dist/av-live-control.tosc`. Confirm all 3 pages render and there + are no parse/script errors. (`open -a TouchOSC ` from the shell was + observed to open an "Untitled" window — use File > Open inside the app.) + If a layout fails to load, fix `gen/schema.py` against the schema reference + and rebuild — no other file changes. +2. **OSC connection:** TouchOSC → Connections → OSC (UDP): + - **Host** = `supra-m1.local` (macm1 — the SC engine host) + - **Send port** = `57121` (the SC OSCdef port) + - **Receive port** = `9000` (matches `~toscPort` in `touchosc_feedback.scd`) + - Ensure the layout targets **Connection 1** (`CONN_DEFAULT="00001"`). +3. **Control smoke test:** with the SC engine running on macm1 (boot the + data-only patch), enter play mode, tap pads and move the tempo / a volume + fader. Watch the macm1 sclang post window for `/launch`, `/launch/tempo`, + `/launch/vol` and confirm audio responds. +4. **Feedback check:** armed pads light, the beat box flashes, the master VU + moves, and selecting a rhythm/melody preset loads its steps + name into the + editor widgets. + +## Deploy to the iPad + +1. Get `dist/av-live-control.tosc` onto the iPad (AirDrop, or TouchOSC document + sync / iCloud). +2. On the iPad, set the same OSC connection: host `supra-m1.local`, send + `57121`, receive `9000`. +3. Confirm landscape canvas. Default canvas is **1194×834** (iPad 11"/Air) with + proportional anchoring; if your model differs, set `CANVAS` in + `gen/layout.py` and rebuild. + +## OSC contract (engine routes are reused unchanged) + +Control (surface → SC, port 57121): `/launch <0|1>`, +`/launch/vol <0..1.5>`, `/launch/tempo <60..200>`, `/launch/clear`, +`/control/fx/{filter,stutter,crash,kick,swell,breakdown}`, +`/control/harmony/{root(±delta),scale(name),octave(0..2),phrase(0..3)}`, +`/control/concertNext`, `/control/doScene `, +`/seq/{melody,rhythm} `, `/seq/{melody,rhythm}/set ...`, +arm sequencers via `/launch melseq|rhythmseq <0|1>`. + +Feedback (SC → surface, port 9000, all created by `touchosc_feedback.scd`): +`/armed/ <0|1>`, `/sync/beat <1>`, `/sync/rms `, +`/seq/rhythm/state `, +`/seq/melody/state `. + +## Known limitations / pending + +- **Visual fidelity gate** (step 1 above) is the one check not automatable here; + confirm it on GrosMac before relying on the layout. +- **Lua API** (Hexler control API: `onReceiveOSC`, `sendOSC`, `Color()`, + `self.children`, `table.unpack`, GRID dimension props) is verified at the + fidelity gate; each script carries an `API verified on GrosMac` marker. If a + script errors in the editor console, adjust against the TouchOSC manual. +- **Per-voice VU** (spec §4) is **deferred**: the data-only engine sums all + voices to bus 0, so per-voice live levels are not producible without + re-routing the audio. The master RMS VU + beat + per-pad armed reflection + cover the live-feedback need. +- **Sequencer playhead / step-index highlight** (spec §2/§4/§7) is + **deferred**: the data-only engine does not emit a step index over OSC, so + adding column-highlight requires a new SC step-emit plus a layout + column-highlight receiver. Not built in this pass. diff --git a/touchosc/dist/av-live-control.tosc b/touchosc/dist/av-live-control.tosc new file mode 100644 index 0000000..ae9fec4 Binary files /dev/null and b/touchosc/dist/av-live-control.tosc differ diff --git a/touchosc/gen/__init__.py b/touchosc/gen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/touchosc/gen/build_layout.py b/touchosc/gen/build_layout.py new file mode 100644 index 0000000..eb24f27 --- /dev/null +++ b/touchosc/gen/build_layout.py @@ -0,0 +1,17 @@ +"""Entrypoint: build the full AV-Live TouchOSC layout and write to dist/.""" +import os + +from gen import layout, schema + +OUT = os.path.join(os.path.dirname(__file__), "..", "dist", "av-live-control.tosc") + + +def main(): + out_path = os.path.normpath(OUT) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + schema.write_tosc(layout.build(), out_path) + print(f"wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/touchosc/gen/layout.py b/touchosc/gen/layout.py new file mode 100644 index 0000000..a07a7f2 --- /dev/null +++ b/touchosc/gen/layout.py @@ -0,0 +1,340 @@ +"""AV-Live TouchOSC layout generator. + +Builds a three-page PAGER layout (LIVE / FX+HARM+CONCERT / SEQ) for the +AV-Live SuperCollider engine on macm1 (control port 57121). + +All OSC addresses and arg types follow the authoritative mapping in: + docs/superpowers/plans/2026-06-28-touchosc-control-surface.md (brief override). +""" +import os + +from gen import schema as s + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +CANVAS = (1194, 834) + +PATTERNS = [ + "kick", "hats", "clap", "perc", + "sub", "acid", "arp", "lead", + "stab", "pad", "ride", "rim", + "tom", "reese", "bells", "sweep", +] + +SCALES = ["minor", "dorian", "phrygian", "penta"] + +_LUA_DIR = os.path.join(os.path.dirname(__file__), "lua") + + +def _lua(name): + """Load a Lua script by filename from the lua/ directory adjacent to this module.""" + with open(os.path.join(_LUA_DIR, name), encoding="utf-8") as fh: + return fh.read() + + +# --------------------------------------------------------------------------- +# Primitive widget builders +# --------------------------------------------------------------------------- + +def label(text, x, y, w, h): + """A read-only LABEL node.""" + return s.node("LABEL", [s.prop("name", text, "s"), s.frame(x, y, w, h)]) + + +def button(lbl, address, x, y, w, h, args=None): + """A momentary/toggle BUTTON. ``args`` is a list of Partial objects.""" + return s.node( + "BUTTON", + [s.prop("name", lbl, "s"), s.frame(x, y, w, h)], + messages=[s.osc(address, args or [])], + ) + + +def vfader(address, args, x, y, w, h, recv_address=None, recv_args=None): + """A vertical FADER. Optional receive-only message for feedback.""" + msgs = [s.osc(address, args)] + if recv_address is not None: + msgs.append(s.osc(recv_address, recv_args or [s.val()], + send=0, receive=1, feedback=1)) + return s.node( + "FADER", + [s.prop("name", "", "s"), s.frame(x, y, w, h)], + messages=msgs, + ) + + +# --------------------------------------------------------------------------- +# Pad cell (page 1) +# --------------------------------------------------------------------------- + +def _pad_cell(name, x, y, w, h): + """One pad cell: arm BUTTON + vol FADER + name LABEL, inside a GROUP. + + The GROUP carries pad.lua which reflects /armed/ back as color. + The arm BUTTON sends /launch [const(name), vali()]. + The vol FADER sends /launch/vol [const(name), valf(0,1.5)]. + """ + btn_h = h - 26 + arm_btn = s.node( + "BUTTON", + [s.prop("name", name, "s"), s.frame(0, 0, w - 20, btn_h)], + messages=[ + s.osc("/launch", [s.const(name), s.vali()]), + ], + ) + vol_fader = s.node( + "FADER", + [s.prop("name", f"vol_{name}", "s"), s.frame(w - 18, 0, 16, btn_h)], + messages=[s.osc("/launch/vol", [s.const(name), s.valf(0, 1.5)])], + ) + name_label = label(name, 0, btn_h + 2, w, 22) + return s.node( + "GROUP", + [ + s.prop("name", f"pad_{name}", "s"), + s.frame(x, y, w, h), + s.script(_lua("pad.lua")), + ], + children=[arm_btn, vol_fader, name_label], + messages=[s.osc(f"/armed/{name}", [s.val()], send=0, receive=1, feedback=1)], + ) + + +# --------------------------------------------------------------------------- +# Page 1 — LIVE +# --------------------------------------------------------------------------- + +def live_page(): + """Page 1: top bar (tempo, clear, VU, beat) + 4x4 pad grid.""" + page = s.node("GROUP", [s.prop("name", "LIVE", "s"), s.frame(0, 0, *CANVAS)]) + ch = page.find("children") + + # Top bar ---------------------------------------------------------------- + # Tempo fader: value 0..1 -> BPM 60..200 + ch.append(s.node( + "FADER", + [s.prop("name", "tempo", "s"), s.frame(10, 8, 300, 50)], + messages=[s.osc("/launch/tempo", [s.valf(60, 200)])], + )) + ch.append(label("TEMPO", 10, 60, 100, 20)) + + # Clear all + ch.append(button("CLEAR ALL", "/launch/clear", 322, 8, 150, 50, args=[])) + + # Master VU fader (receive-only /sync/rms) + ch.append(s.node( + "FADER", + [s.prop("name", "rms", "s"), s.frame(490, 8, 40, 72)], + messages=[s.osc("/sync/rms", [s.val()], send=0, receive=1, feedback=1)], + )) + + # Beat flash box (receive-only /sync/beat) + ch.append(s.node( + "BOX", + [s.prop("name", "beat", "s"), s.frame(542, 8, 72, 72), + s.script(_lua("feedback.lua"))], + messages=[s.osc("/sync/beat", [s.val()], send=0, receive=1, feedback=1)], + )) + + # Quantize selector row (send-only momentary): 1/2 beat / 1 beat / 1 bar / 2 bars + quant_defs = [("1/2", 0.5), ("1", 1), ("1 BAR", 4), ("2 BAR", 8)] + qx_start, qy, qw, qh, qgap = 625, 8, 130, 50, 8 + for i, (lbl, beats) in enumerate(quant_defs): + qx = qx_start + i * (qw + qgap) + ch.append(button(lbl, "/launch/quant", qx, qy, qw, qh, args=[s.constf(beats)])) + ch.append(label("QUANT", qx_start, 62, 120, 20)) + + # 4x4 pad grid ----------------------------------------------------------- + gx, gy, pw, ph, gap = 10, 96, 283, 178, 10 + for i, name in enumerate(PATTERNS): + col, row = i % 4, i // 4 + x = gx + col * (pw + gap) + y = gy + row * (ph + gap) + ch.append(_pad_cell(name, x, y, pw, ph)) + + return page + + +# --------------------------------------------------------------------------- +# Page 2 — FX / HARMONIE / CONCERT +# --------------------------------------------------------------------------- + +def fx_page(): + """Page 2: FX column, harmony column, concert column.""" + page = s.node( + "GROUP", + [s.prop("name", "FX-HARM-CONCERT", "s"), s.frame(0, 0, *CANVAS)], + ) + ch = page.find("children") + + # FX column (x=10..440) -------------------------------------------------- + ch.append(label("FILTER", 10, 8, 80, 22)) + ch.append(s.node( + "FADER", + [s.prop("name", "filter", "s"), s.frame(10, 36, 80, 350)], + messages=[s.osc("/control/fx/filter", [s.valf(0, 1)])], + )) + + # One-shot buttons (vali() — engine ignores arg, just triggers) + one_shots = [("STUTTER", "stutter"), ("CRASH", "crash"), + ("KICK", "kick"), ("SWELL", "swell")] + for i, (lbl, key) in enumerate(one_shots): + ch.append(button(lbl, f"/control/fx/{key}", + 100, 36 + i * 70, 160, 56, args=[s.vali()])) + + ch.append(button("BREAKDOWN IN", "/control/fx/breakdown", + 100, 316, 160, 56, args=[s.consti(1)])) + ch.append(button("BREAKDOWN OUT", "/control/fx/breakdown", + 100, 382, 160, 56, args=[s.consti(0)])) + + # Harmony column (x=290..780) -------------------------------------------- + ch.append(label("HARMONIE", 290, 8, 200, 22)) + + ch.append(button("ROOT -", "/control/harmony/root", + 290, 36, 145, 52, args=[s.consti(-2)])) + ch.append(button("ROOT +", "/control/harmony/root", + 445, 36, 145, 52, args=[s.consti(2)])) + + # Scale buttons: 2x2, each sends the string name (NOT index) + scale_xy = [(290, 100), (445, 100), (290, 162), (445, 162)] + for (sx, sy), scale_name in zip(scale_xy, SCALES): + ch.append(button(scale_name.upper(), "/control/harmony/scale", + sx, sy, 145, 52, args=[s.const(scale_name)])) + + # Octave: three absolute buttons (0, 1, 2) + for n in range(3): + ch.append(button(f"OCT {n}", "/control/harmony/octave", + 290 + n * 100, 234, 90, 52, args=[s.consti(n)])) + + # Phrase: four absolute buttons (0..3) + for p in range(4): + ch.append(button(f"PH {p}", "/control/harmony/phrase", + 290 + p * 75, 306, 65, 52, args=[s.consti(p)])) + + # Concert column (x=620..900) -------------------------------------------- + ch.append(label("CONCERT", 620, 8, 200, 22)) + + ch.append(button("MORCEAU >", "/control/concertNext", + 620, 36, 260, 64, args=[])) + ch.append(button("CONCERT", "/control/doScene", + 620, 110, 260, 64, args=[s.const("concert")])) + # body-play is /control/doScene "play" — NOT /control/bodyPlay + ch.append(button("BODY-PLAY", "/control/doScene", + 620, 184, 260, 64, args=[s.const("play")])) + + return page + + +# --------------------------------------------------------------------------- +# Page 3 — SEQUENCEURS +# --------------------------------------------------------------------------- + +def seq_page(): + """Page 3: rhythm grid + melody faders + preset selectors + arm buttons. + + Per-voice VU strip deliberately omitted: the engine sums all voices to + bus 0; per-voice live levels are not producible. Master RMS VU on page 1 + is the only meter. Do not add /sync/amp receivers here. + """ + page = s.node( + "GROUP", + [ + s.prop("name", "SEQ", "s"), + s.frame(0, 0, *CANVAS), + s.script(_lua("preset_select.lua")), + ], + messages=[ + s.osc("/seq/rhythm/state", [s.val()], send=0, receive=1, feedback=1), + s.osc("/seq/melody/state", [s.val()], send=0, receive=1, feedback=1), + ], + ) + ch = page.find("children") + + # Rhythm section (x=10..595) --------------------------------------------- + ch.append(label("RYTHME", 10, 8, 120, 22)) + + step = 74 # button width + gap + for i in range(8): + bx = 10 + i * step + ch.append(button(f"R{i + 1}", "/seq/rhythm", + bx, 36, 66, 44, args=[s.consti(i)])) + # Name label updated by preset_select.lua via /seq/rhythm/state + nl = s.node( + "LABEL", + [s.prop("name", f"R{i + 1}", "s"), s.frame(bx, 86, 66, 22)], + ) + ch.append(nl) + + # 3x16 rhythm grid + rhythm_grid = s.node( + "GRID", + [ + s.prop("name", "rhythmgrid", "s"), + s.frame(10, 114, 580, 162), + s.prop("gridX", "16", "s"), + s.prop("gridY", "3", "s"), + s.script(_lua("rhythm_grid.lua")), + ], + messages=[ + # Nominal address for testability; Lua emits the real OSC. + s.osc("/seq/rhythm/set", [], send=0, receive=0), + s.osc("/seq/rhythm/state", [s.val()], send=0, receive=1, feedback=1), + ], + ) + ch.append(rhythm_grid) + + # Melody section (x=600..1185) ------------------------------------------- + ch.append(label("MELODIE", 600, 8, 120, 22)) + + mstep = 74 # fader width + gap + for i in range(8): + fx = 600 + i * mstep + ch.append(button(f"M{i + 1}", "/seq/melody", + fx, 36, 66, 44, args=[s.consti(i)])) + # Melody step fader (melody_faders.lua emits /seq/melody/set) + mel_fader = s.node( + "FADER", + [ + s.prop("name", f"m{i}", "s"), + s.frame(fx, 86, 66, 200), + s.script(_lua("melody_faders.lua")), + ], + messages=[ + # Nominal address so tests can find these 8 faders by address. + s.osc("/seq/melody/set", [], send=0, receive=0), + ], + ) + ch.append(mel_fader) + + # ARM melody + rhythm buttons (below faders, y=296) + ch.append(button("ARM MEL", "/launch", + 600, 296, 130, 44, args=[s.const("melseq"), s.vali()])) + ch.append(button("ARM RHY", "/launch", + 738, 296, 130, 44, args=[s.const("rhythmseq"), s.vali()])) + + return page + + +# --------------------------------------------------------------------------- +# Pager + root +# --------------------------------------------------------------------------- + +def pager(pages): + """Wrap a list of page GROUP nodes in a PAGER.""" + return s.node( + "PAGER", + [s.prop("name", "pager", "s"), s.frame(0, 0, *CANVAS)], + children=pages, + ) + + +def build(): + """Assemble and return the full root node (called by build_layout.py).""" + root = s.node( + "GROUP", + [s.prop("name", "av-live-control", "s"), s.frame(0, 0, *CANVAS)], + children=[pager([live_page(), fx_page(), seq_page()])], + ) + return root diff --git a/touchosc/gen/lua/feedback.lua b/touchosc/gen/lua/feedback.lua new file mode 100644 index 0000000..e87d181 --- /dev/null +++ b/touchosc/gen/lua/feedback.lua @@ -0,0 +1,21 @@ +-- Global feedback receiver: beat flash + master RMS VU. +-- Attached to the beat BOX and master VU FADER on page 1. +-- API verified on GrosMac at fidelity gate. +function onReceiveOSC(message, connections) + local p = message[1] + local args = message[2] + if p == "/sync/beat" then + self.color = Color(1, 1, 1, 1) + elseif p == "/sync/rms" then + local amp = (args and args[1] and tonumber(args[1].value)) or 0 + self.values.x = amp + end +end + +function onFrame() + local c = self.color + if c and c.r then + local decay = 0.85 + self.color = Color(c.r * decay, c.g * decay, c.b * decay, 1) + end +end diff --git a/touchosc/gen/lua/melody_faders.lua b/touchosc/gen/lua/melody_faders.lua new file mode 100644 index 0000000..4633279 --- /dev/null +++ b/touchosc/gen/lua/melody_faders.lua @@ -0,0 +1,17 @@ +-- Melody step faders: map 0..1 -> integer degree -7..14. +-- On value change: read all 8 step faders (m0..m7) -> /seq/melody/set. +-- API verified on GrosMac at fidelity gate. +local LO, HI = -7, 14 +local unpack = table.unpack or unpack -- Lua 5.1 / LuaJIT compat + +function onValueChanged(key) + if key ~= "x" then return end + local parent = self.parent + local degs = {} + for i = 1, 8 do + local f = parent and parent:findByName("m" .. (i - 1), true) + local fx = (f and f.values and f.values.x) or 0 + degs[i] = math.floor(LO + (HI - LO) * fx + 0.5) + end + sendOSC("/seq/melody/set", unpack(degs)) +end diff --git a/touchosc/gen/lua/pad.lua b/touchosc/gen/lua/pad.lua new file mode 100644 index 0000000..1d162dd --- /dev/null +++ b/touchosc/gen/lua/pad.lua @@ -0,0 +1,33 @@ +-- Pad GROUP: reflect armed state from SC engine onto the arm button color. +-- API verified on GrosMac at fidelity gate. +-- self.children[1] is the arm BUTTON; color driven by /armed/. +-- State int: 0=off, 1=playing (solid green), 2=queued-launch (blink), 3=queued-stop (blink). +local blinking = false + +function onReceiveOSC(message, connections) + local path = message[1] + local name = self.name:gsub("pad_", "") + if path == "/armed/" .. name then + local args = message[2] + local state = math.floor((args and args[1] and tonumber(args[1].value)) or 0) + if self.children and self.children[1] then + if state == 0 then + blinking = false + self.children[1].color = Color(0.11, 0.11, 0.11, 1) + elseif state == 1 then + blinking = false + self.children[1].color = Color(0.27, 0.80, 0.40, 1) + else + -- state 2 (queued-launch) or 3 (queued-stop): blink amber + blinking = true + end + end + end +end + +function onFrame() + if blinking and self.children and self.children[1] then + local phase = (math.sin(getTime() * math.pi * 2.5) + 1) * 0.5 + self.children[1].color = Color(0.80 * phase, 0.60 * phase, 0.10, 1) + end +end diff --git a/touchosc/gen/lua/preset_select.lua b/touchosc/gen/lua/preset_select.lua new file mode 100644 index 0000000..8d7dae0 --- /dev/null +++ b/touchosc/gen/lua/preset_select.lua @@ -0,0 +1,33 @@ +-- Seq page GROUP: update rhythm name labels and melody fader values from SC state. +-- /seq/rhythm/state -> updates rhythm label R(idx+1) +-- /seq/melody/state -> loads degree values into m0..m7 +-- API verified on GrosMac at fidelity gate. +local LO, HI = -7, 14 + +function onReceiveOSC(message, connections) + local p = message[1] + local args = message[2] + if not args then return end + + if p == "/seq/rhythm/state" then + local idx = tonumber(args[1] and args[1].value) or 0 + local name = (args[#args] and args[#args].value) or "" + local lbl = self:findByName("R" .. (idx + 1), true) + if lbl then lbl.values.text = name end + + elseif p == "/seq/melody/state" then + local idx = tonumber(args[1] and args[1].value) or 0 + -- remaining args are degrees d0..dN + local range = HI - LO + for i = 1, 8 do + local a = args[i + 1] + if a then + local deg = tonumber(a.value) or 0 + local f = self:findByName("m" .. (i - 1), true) + if f then + f.values.x = (deg - LO) / range + end + end + end + end +end diff --git a/touchosc/gen/lua/rhythm_grid.lua b/touchosc/gen/lua/rhythm_grid.lua new file mode 100644 index 0000000..3bce68c --- /dev/null +++ b/touchosc/gen/lua/rhythm_grid.lua @@ -0,0 +1,31 @@ +-- Rhythm grid: rows 0..2 = K, S, H; cols 0..15 = steps. +-- On any cell change: build three 16-char "0"/"1" strings -> /seq/rhythm/set. +-- On /seq/rhythm/state : load strings into cells. +-- API verified on GrosMac at fidelity gate. +function onValueChanged(key) + local rows = {"", "", ""} + for r = 0, 2 do + for c = 0, 15 do + local cell = self.children[r * 16 + c + 1] + local v = (cell and cell.values and cell.values.x) or 0 + rows[r + 1] = rows[r + 1] .. (v > 0.5 and "1" or "0") + end + end + sendOSC("/seq/rhythm/set", rows[1], rows[2], rows[3]) +end + +function onReceiveOSC(message, connections) + if message[1] ~= "/seq/rhythm/state" then return end + local args = message[2] + -- args: [idx, k_str, s_str, h_str, name] + for r = 0, 2 do + local str = args and args[r + 2] and args[r + 2].value or "" + for c = 0, 15 do + local cell = self.children[r * 16 + c + 1] + if cell then + local ch = str:sub(c + 1, c + 1) + cell.values.x = (ch == "1") and 1 or 0 + end + end + end +end diff --git a/touchosc/gen/schema.py b/touchosc/gen/schema.py new file mode 100644 index 0000000..b12935c --- /dev/null +++ b/touchosc/gen/schema.py @@ -0,0 +1,177 @@ +"""TouchOSC v3 (.tosc) layout schema and serializer. + +A ``.tosc`` file is a gzip-compressed XML document whose root is +```` containing a single root ````. This module +emits that format with ``xml.etree.ElementTree``. The exact byte-shape +(partials as child elements, frame ``r`` / color ``c`` value shapes, the +``connections`` bit-string) was pinned against a file that the desktop +TouchOSC app actually loads and re-saves on GrosMac. See +``docs/superpowers/specs/2026-06-28-tosc-schema-reference.md``. +""" + +import gzip +import uuid +import xml.etree.ElementTree as ET + +# connections bit-string: one '0'/'1' flag per available OSC connection, +# left-to-right = connection 1..N. TouchOSC writes 5 flags; a fresh +# message targets connection 1 only. Confirmed from a TouchOSC re-save. +CONN_DEFAULT = "00001" + + +def tosc_id(): + """Fresh UUID string for a node ID.""" + return str(uuid.uuid4()) + + +def _set_text(parent, tag, text): + e = ET.SubElement(parent, tag) + e.text = str(text) + return e + + +def prop(key, value, typ): + """One ```` with ````/```` children.""" + p = ET.Element("property", {"type": typ}) + _set_text(p, "key", key) + _set_text(p, "value", value) + return p + + +def frame(x, y, w, h): + """The ``frame`` property (type ``r``) — value holds x/y/w/h children.""" + p = ET.Element("property", {"type": "r"}) + _set_text(p, "key", "frame") + v = ET.SubElement(p, "value") + for tag, n in (("x", x), ("y", y), ("w", w), ("h", h)): + _set_text(v, tag, n) + return p + + +def color(r, g, b, a=1.0): + """The ``color`` property (type ``c``) — value holds r/g/b/a children.""" + p = ET.Element("property", {"type": "c"}) + _set_text(p, "key", "color") + v = ET.SubElement(p, "value") + for tag, n in (("r", r), ("g", g), ("b", b), ("a", a)): + _set_text(v, tag, n) + return p + + +class Partial: + """One ```` of an OSC path or argument list. + + Fields are emitted as *child elements* (not attributes), matching the + format TouchOSC writes. + """ + + def __init__(self, kind, conversion, value, scale_min=0, scale_max=1): + self.kind = kind + self.conversion = conversion + self.value = value + self.scale_min = scale_min + self.scale_max = scale_max + + def to_el(self): + p = ET.Element("partial") + _set_text(p, "type", self.kind) + _set_text(p, "conversion", self.conversion) + _set_text(p, "value", self.value) + _set_text(p, "scaleMin", self.scale_min) + _set_text(p, "scaleMax", self.scale_max) + return p + + +def const(s): + """A CONSTANT/STRING partial — a fixed address segment or argument.""" + return Partial("CONSTANT", "STRING", s) + + +def consti(n): + """A CONSTANT/INTEGER partial — a fixed integer arg.""" + return Partial("CONSTANT", "INTEGER", str(n)) + + +def constf(x): + """A CONSTANT/FLOAT partial — a fixed float arg.""" + return Partial("CONSTANT", "FLOAT", str(x)) + + +def val(): + """A VALUE/FLOAT partial bound to the control's ``x`` value (scale 0..1).""" + return Partial("VALUE", "FLOAT", "x") + + +def vali(): + """A VALUE/INTEGER partial — control value as int (button 0/1).""" + return Partial("VALUE", "INTEGER", "x") + + +def valf(scale_min=0, scale_max=1): + """A VALUE/FLOAT partial with a custom scale range (e.g. tempo 60..200).""" + return Partial("VALUE", "FLOAT", "x", scale_min, scale_max) + + +def _trigger(var="x", condition="ANY"): + t = ET.Element("trigger") + _set_text(t, "var", var) + _set_text(t, "condition", condition) + return t + + +def osc(address, args, connections=CONN_DEFAULT, send=1, receive=0, feedback=0): + """One ```` message: triggers + path + arguments.""" + m = ET.Element("osc", { + "enabled": "1", + "send": str(send), + "receive": str(receive), + "feedback": str(feedback), + "connections": connections, + }) + triggers = ET.SubElement(m, "triggers") + triggers.append(_trigger()) + path = ET.SubElement(m, "path") + path.append(const(address).to_el()) + arguments = ET.SubElement(m, "arguments") + for p in args: + arguments.append(p.to_el()) + return m + + +def script(lua): + """The ``script`` string property (Lua source, type ``s``).""" + return prop("script", lua, "s") + + +def node(ntype, props, children=None, messages=None, values=None): + """A ```` with properties/values/messages/children sub-blocks.""" + n = ET.Element("node", {"ID": tosc_id(), "type": ntype}) + ps = ET.SubElement(n, "properties") + for p in props: + ps.append(p) + vs = ET.SubElement(n, "values") + for v in (values or []): + vs.append(v) + ms = ET.SubElement(n, "messages") + for m in (messages or []): + ms.append(m) + cs = ET.SubElement(n, "children") + for c in (children or []): + cs.append(c) + return n + + +def write_tosc(root, path): + """Serialize ``root`` under ```` and gzip to ``path``.""" + lexml = ET.Element("lexml", {"version": "3"}) + lexml.append(root) + xml = ET.tostring(lexml, encoding="UTF-8", xml_declaration=True) + with gzip.open(path, "wb") as f: + f.write(xml) + + +def read_tosc(path): + """Gunzip + parse a ``.tosc``; return its root ```` element.""" + with gzip.open(path, "rb") as f: + lexml = ET.fromstring(f.read()) + return lexml.find("node") diff --git a/touchosc/pyproject.toml b/touchosc/pyproject.toml new file mode 100644 index 0000000..36d4dae --- /dev/null +++ b/touchosc/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "touchosc-gen" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/touchosc/reference/probe-roundtrip.pretty.xml b/touchosc/reference/probe-roundtrip.pretty.xml new file mode 100644 index 0000000..9d6bb04 --- /dev/null +++ b/touchosc/reference/probe-roundtrip.pretty.xml @@ -0,0 +1,69 @@ + + + + + + name + root + + + + + + + + + name + vol + + + frame + + 10 + 20 + 60 + 200 + + + + + + + + + x + ANY + + + + + CONSTANT + STRING + /launch/vol + 0 + 1 + + + + + CONSTANT + STRING + kick + 0 + 1 + + + VALUE + FLOAT + x + 0 + 1 + + + + + + + + + diff --git a/touchosc/reference/probe-roundtrip.tosc b/touchosc/reference/probe-roundtrip.tosc new file mode 100644 index 0000000..259ccd3 Binary files /dev/null and b/touchosc/reference/probe-roundtrip.tosc differ diff --git a/touchosc/reference/probe-roundtrip.xml b/touchosc/reference/probe-roundtrip.xml new file mode 100644 index 0000000..785edd9 --- /dev/null +++ b/touchosc/reference/probe-roundtrip.xml @@ -0,0 +1,2 @@ + +namerootnamevolframe102060200xANYCONSTANTSTRING/launch/vol01CONSTANTSTRINGkick01VALUEFLOATx01 \ No newline at end of file diff --git a/touchosc/tests/test_build.py b/touchosc/tests/test_build.py new file mode 100644 index 0000000..ea9e4c5 --- /dev/null +++ b/touchosc/tests/test_build.py @@ -0,0 +1,66 @@ +"""End-to-end build tests — verify the full .tosc file embeds Lua scripts.""" +from gen import layout, schema + + +def _all_scripts(parsed): + """Return a concatenated string of all script property values in the tree.""" + texts = [ + p.find("value").text + for p in parsed.findall(".//property[@type='s']") + if p.find("key") is not None and p.find("key").text == "script" + and p.find("value") is not None + ] + return "\n".join(t for t in texts if t) + + +def test_scripts_embedded(tmp_path): + root = layout.build() + out = tmp_path / "full.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + joined = _all_scripts(parsed) + assert 'sendOSC("/seq/rhythm/set"' in joined, "rhythm grid Lua not embedded" + assert "/armed/" in joined, "pad Lua not embedded" + assert "/sync/beat" in joined, "feedback Lua not embedded" + assert 'sendOSC("/seq/melody/set"' in joined, "melody_faders Lua not embedded" + assert "findByName" in joined, "preset_select Lua not embedded" + + +def test_schema_partial_helpers(): + """Verify the new typed-partial helpers produce correct element shapes.""" + ci = schema.consti(42) + cf = schema.constf(3.14) + vi = schema.vali() + vf = schema.valf(60, 200) + + el_ci = ci.to_el() + assert el_ci.find("conversion").text == "INTEGER" + assert el_ci.find("value").text == "42" + assert el_ci.find("type").text == "CONSTANT" + + el_cf = cf.to_el() + assert el_cf.find("type").text == "CONSTANT" + assert el_cf.find("conversion").text == "FLOAT" + assert el_cf.find("value").text == "3.14" + + el_vi = vi.to_el() + assert el_vi.find("conversion").text == "INTEGER" + assert el_vi.find("value").text == "x" + assert el_vi.find("type").text == "VALUE" + + el_vf = vf.to_el() + assert el_vf.find("conversion").text == "FLOAT" + assert el_vf.find("scaleMin").text == "60" + assert el_vf.find("scaleMax").text == "200" + + +def test_build_writes_tosc_file(tmp_path): + """build() produces a valid gzip-XML file that round-trips cleanly.""" + root = layout.build() + out = tmp_path / "av-live-control.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + assert parsed is not None + assert parsed.get("type") == "GROUP" + name_el = parsed.find("properties/property[key='name']/value") + assert name_el is not None and name_el.text == "av-live-control" diff --git a/touchosc/tests/test_layout.py b/touchosc/tests/test_layout.py new file mode 100644 index 0000000..40f3f3d --- /dev/null +++ b/touchosc/tests/test_layout.py @@ -0,0 +1,324 @@ +"""Layout structure tests — assert pages, addresses, and widget counts.""" +from gen import layout, schema + + +def _addrs(el): + """Return the set of OSC path address strings found anywhere under ``el``.""" + return { + p.find("value").text + for p in el.findall(".//messages/osc/path/partial") + if p.find("value") is not None + } + + +def _addr_list(el): + return [ + p.find("value").text + for p in el.findall(".//messages/osc/path/partial") + if p.find("value") is not None + ] + + +# --------------------------------------------------------------------------- +# Root / pager +# --------------------------------------------------------------------------- + +def test_root_has_pager_with_three_pages(tmp_path): + root = layout.build() + out = tmp_path / "full.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + pager = parsed.find(".//node[@type='PAGER']") + assert pager is not None, "PAGER node not found" + pages = pager.findall("children/node[@type='GROUP']") + assert len(pages) == 3, f"expected 3 pages, got {len(pages)}" + + +def test_sixteen_patterns(): + assert layout.PATTERNS == [ + "kick", "hats", "clap", "perc", + "sub", "acid", "arp", "lead", + "stab", "pad", "ride", "rim", + "tom", "reese", "bells", "sweep", + ] + + +# --------------------------------------------------------------------------- +# Page 1 — LIVE +# --------------------------------------------------------------------------- + +def test_live_page_has_16_launch_buttons(): + page = layout.live_page() + buttons = page.findall(".//node[@type='BUTTON']") + launch = [ + b for b in buttons + if b.find("messages/osc/path/partial/value") is not None + and b.find("messages/osc/path/partial/value").text == "/launch" + ] + assert len(launch) == 16, f"expected 16 /launch buttons, got {len(launch)}" + + +def test_live_page_has_16_vol_faders(): + page = layout.live_page() + faders = page.findall(".//node[@type='FADER']") + vol = [ + f for f in faders + if f.find("messages/osc/path/partial/value") is not None + and f.find("messages/osc/path/partial/value").text == "/launch/vol" + ] + assert len(vol) == 16, f"expected 16 /launch/vol faders, got {len(vol)}" + + +def test_live_page_has_tempo_and_clear(): + page = layout.live_page() + addrs = _addrs(page) + assert "/launch/tempo" in addrs + assert "/launch/clear" in addrs + + +def test_live_page_has_rms_receive(): + page = layout.live_page() + addrs = _addrs(page) + assert "/sync/rms" in addrs + assert "/sync/beat" in addrs + + +# --------------------------------------------------------------------------- +# Page 2 — FX / HARMONIE / CONCERT +# --------------------------------------------------------------------------- + +def test_fx_page_addresses(): + page = layout.fx_page() + addrs = _addrs(page) + required = [ + "/control/fx/filter", + "/control/fx/stutter", + "/control/fx/crash", + "/control/fx/kick", + "/control/fx/swell", + "/control/fx/breakdown", + "/control/harmony/root", + "/control/harmony/scale", + "/control/harmony/octave", + "/control/harmony/phrase", + "/control/concertNext", + "/control/doScene", + ] + for a in required: + assert a in addrs, f"missing address {a}" + + +def test_fx_page_body_play_uses_doScene(): + """body-play must be /control/doScene, NOT /control/bodyPlay.""" + page = layout.fx_page() + addrs = _addrs(page) + assert "/control/doScene" in addrs + assert "/control/bodyPlay" not in addrs + + +def test_fx_page_harmony_scale_sends_string_name(): + """Scale buttons must send string names (minor/dorian/phrygian/penta), + not integer indices.""" + page = layout.fx_page() + # Find all argument partials on /control/harmony/scale messages + scale_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/control/harmony/scale": + for part in osc_el.findall("arguments/partial"): + conv = part.find("conversion") + val = part.find("value") + if conv is not None and val is not None: + scale_args.append((conv.text, val.text)) + assert len(scale_args) == 4, f"expected 4 scale arg partials, got {len(scale_args)}" + for conv, val in scale_args: + assert conv == "STRING", f"scale arg must be STRING, got {conv}" + assert val in layout.SCALES, f"scale arg '{val}' not in SCALES" + + +def test_fx_page_harmony_octave_absolute(): + """Octave buttons must send absolute values 0, 1, 2 (not relative).""" + page = layout.fx_page() + octave_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/control/harmony/octave": + for part in osc_el.findall("arguments/partial"): + conv = part.find("conversion") + val = part.find("value") + if conv is not None and val is not None: + octave_args.append((conv.text, val.text)) + assert len(octave_args) == 3, f"expected 3 octave buttons, got {len(octave_args)}" + vals = {v for _, v in octave_args} + assert vals == {"0", "1", "2"}, f"expected octave values 0,1,2, got {vals}" + + +def test_fx_page_breakdown_constant_integer(): + """breakdown buttons must send CONSTANT/INTEGER args with values {0, 1}.""" + page = layout.fx_page() + breakdown_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/control/fx/breakdown": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + breakdown_args.append((typ.text, conv.text, val.text)) + assert len(breakdown_args) == 2, f"expected 2 breakdown arg partials, got {len(breakdown_args)}" + for typ, conv, _ in breakdown_args: + assert typ == "CONSTANT", f"breakdown arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"breakdown arg conversion must be INTEGER, got {conv}" + vals = {v for _, _, v in breakdown_args} + assert vals == {"0", "1"}, f"expected breakdown values {{0,1}}, got {vals}" + + +def test_fx_page_harmony_root_constant_integer(): + """root buttons must send CONSTANT/INTEGER args with values {-2, 2}.""" + page = layout.fx_page() + root_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/control/harmony/root": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + root_args.append((typ.text, conv.text, val.text)) + assert len(root_args) == 2, f"expected 2 root arg partials, got {len(root_args)}" + for typ, conv, _ in root_args: + assert typ == "CONSTANT", f"root arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"root arg conversion must be INTEGER, got {conv}" + vals = {v for _, _, v in root_args} + assert vals == {"-2", "2"}, f"expected root values {{-2,2}}, got {vals}" + + +# --------------------------------------------------------------------------- +# Page 3 — SEQ +# --------------------------------------------------------------------------- + +def test_seq_page_grid_and_faders(): + page = layout.seq_page() + assert page.find(".//node[@type='GRID']") is not None, "GRID node not found" + addrs = _addr_list(page) + assert "/seq/rhythm" in addrs + assert "/seq/melody" in addrs + assert "/seq/rhythm/set" in addrs + assert "/seq/melody/set" in addrs + # 8 melody step faders identified by their nominal /seq/melody/set address + mel = [ + f for f in page.findall(".//node[@type='FADER']") + if f.find("messages/osc/path/partial/value") is not None + and f.find("messages/osc/path/partial/value").text == "/seq/melody/set" + ] + assert len(mel) == 8, f"expected 8 melody step faders, got {len(mel)}" + + +def test_seq_page_arm_buttons(): + """ARM MEL and ARM RHY both send to /launch (with melseq/rhythmseq name).""" + page = layout.seq_page() + buttons = page.findall(".//node[@type='BUTTON']") + arm = [ + b for b in buttons + if b.find("messages/osc/path/partial/value") is not None + and b.find("messages/osc/path/partial/value").text == "/launch" + ] + assert len(arm) == 2, f"expected 2 ARM buttons on seq page, got {len(arm)}" + + +def test_seq_page_rhythm_presets_use_consti(): + """Rhythm preset buttons must send INTEGER index (not float/string).""" + page = layout.seq_page() + rhythm_indices = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/seq/rhythm": + for part in osc_el.findall("arguments/partial"): + conv = part.find("conversion") + if conv is not None and conv.text == "INTEGER": + rhythm_indices.append(part.find("value").text) + assert len(rhythm_indices) == 8, f"expected 8 rhythm preset buttons, got {len(rhythm_indices)}" + + +def test_seq_page_no_sync_amp(): + """Per-voice VU strip is deliberately omitted; /sync/amp must not appear.""" + page = layout.seq_page() + addrs = _addrs(page) + assert "/sync/amp" not in addrs, "/sync/amp found — per-voice VU was supposed to be omitted" + + +# --------------------------------------------------------------------------- +# Page 1 — LIVE: quantize selector +# --------------------------------------------------------------------------- + +def test_live_page_quant_buttons(): + """LIVE page must have 4 /launch/quant buttons with CONSTANT/FLOAT args {0.5,1,4,8}.""" + page = layout.live_page() + quant_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/launch/quant": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + quant_args.append((typ.text, conv.text, val.text)) + assert len(quant_args) == 4, f"expected 4 /launch/quant arg partials, got {len(quant_args)}" + for typ, conv, _ in quant_args: + assert typ == "CONSTANT", f"quant arg type must be CONSTANT, got {typ}" + assert conv == "FLOAT", f"quant arg conversion must be FLOAT, got {conv}" + vals = {v for _, _, v in quant_args} + assert vals == {"0.5", "1", "4", "8"}, f"expected quant values {{0.5,1,4,8}}, got {vals}" + + +# --------------------------------------------------------------------------- +# Pad armed receive on GROUP — FIX 1 regression guard +# --------------------------------------------------------------------------- + +def test_live_page_pad_groups_carry_armed_receive(): + """Each pad GROUP (carrying pad.lua) must have the /armed/ receive + message on itself — not only on the child arm BUTTON. pad.lua fires via + onReceiveOSC on the node that owns the matching receive message; if the + message lives only on the child BUTTON, the GROUP script never fires and + armed-state color reflection is dead.""" + page = layout.live_page() + + # Collect GROUP nodes that carry a script property (= pad GROUPs with pad.lua). + # The top-level LIVE GROUP has no script, so this selects exactly the 16 pads. + pad_groups = [] + for grp in page.findall(".//node[@type='GROUP']"): + for prop_el in grp.findall("properties/property"): + key_el = prop_el.find("key") + if key_el is not None and key_el.text == "script": + pad_groups.append(grp) + break + + assert len(pad_groups) == 16, ( + f"expected 16 pad GROUPs with a script property, got {len(pad_groups)}" + ) + + for grp in pad_groups: + # Resolve the pad name from its name property for a readable failure msg. + grp_name = "?" + for prop_el in grp.findall("properties/property"): + k = prop_el.find("key") + if k is not None and k.text == "name": + v = prop_el.find("value") + grp_name = v.text if v is not None else "?" + break + + # The GROUP's direct block (not descendants) must contain + # exactly one osc element with receive="1" whose path starts /armed/. + armed_msgs = [ + osc_el for osc_el in grp.findall("messages/osc") + if osc_el.get("receive") == "1" + and osc_el.find("path/partial/value") is not None + and (osc_el.find("path/partial/value").text or "").startswith("/armed/") + ] + assert len(armed_msgs) == 1, ( + f"pad GROUP '{grp_name}' must have exactly 1 /armed/* receive msg " + f"on the GROUP itself, got {len(armed_msgs)}" + ) diff --git a/touchosc/tests/test_schema.py b/touchosc/tests/test_schema.py new file mode 100644 index 0000000..2b54aba --- /dev/null +++ b/touchosc/tests/test_schema.py @@ -0,0 +1,27 @@ +from gen import schema + + +def test_roundtrip_minimal_fader(tmp_path): + fader = schema.node( + "FADER", + props=[ + schema.prop("name", "vol", "s"), + schema.frame(10, 20, 60, 200), + ], + messages=[ + schema.osc("/launch/vol", [schema.const("kick"), schema.val()]), + ], + ) + root = schema.node("GROUP", props=[schema.prop("name", "root", "s")], + children=[fader]) + out = tmp_path / "min.tosc" + schema.write_tosc(root, str(out)) + parsed = schema.read_tosc(str(out)) + assert parsed.tag == "node" + assert parsed.get("type") == "GROUP" + child = parsed.find("children/node") + assert child.get("type") == "FADER" + # Partial fields are child elements in TouchOSC v3 (confirmed against a + # real app re-save), so the address lives in , not an attr. + addr = child.find("messages/osc/path/partial/value") + assert addr.text == "/launch/vol" diff --git a/touchosc/uv.lock b/touchosc/uv.lock new file mode 100644 index 0000000..080c62f --- /dev/null +++ b/touchosc/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "touchosc-gen" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index c1831af..a19e25a 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -1,8 +1,8 @@ * { box-sizing: border-box; } body { margin: 0; background: #111; color: #eee; font: 16px/1.3 -apple-system, system-ui, sans-serif; padding: 12px; } -header { display: flex; align-items: center; gap: 10px; } +header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; } h1 { font-size: 18px; margin: 4px 0; } h2 { font-size: 14px; color: #9af; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: .06em; } -#status { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } +#status { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; } #status.on { background: #4c8; } #status.off { background: #c44; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } button { background: #222; color: #eee; border: 1px solid #333; border-radius: 10px; @@ -41,3 +41,37 @@ button.sel { background: #246; border-color: #4af; color: #8cf; } .mel-cell input[type=number]::-webkit-inner-spin-button, .mel-cell input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .mel-cell input[type=number]:focus { outline: none; border-color: #4af; background: #1a2030; } + +/* --- Tab bar --- */ +.tab-bar { display: flex; gap: 4px; margin: 10px 0 6px; } +.tab-btn { background: #1a1a1a; color: #888; border: 1px solid #333; border-radius: 8px; + padding: 10px 8px; font-size: 13px; cursor: pointer; flex: 1; text-transform: uppercase; + letter-spacing: .04em; width: auto; } +.tab-btn.active { background: #246; color: #8cf; border-color: #4af; font-weight: 700; } +.tab-btn:active { background: #1c2a40; } +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* --- Beat flash dot --- */ +.beat-dot { width: 14px; height: 14px; border-radius: 50%; background: #333; + display: inline-block; flex-shrink: 0; } +.beat-dot.flash { background: #f90; } + +/* --- Master VU meter --- */ +.vu-wrap { flex: 1; max-width: 200px; height: 14px; background: #222; + border: 1px solid #333; border-radius: 4px; overflow: hidden; } +.vu-bar { height: 100%; width: 0%; background: linear-gradient(to right, #2b6, #9d4, #fa0); + border-radius: 4px; transition: width 0.08s linear; } + +/* --- Quantize selector --- */ +.quant-bar { display: flex; align-items: center; gap: 6px; margin: 8px 0; flex-wrap: wrap; } +.quant-label { font-size: 13px; color: #888; flex-shrink: 0; } +.quant-btn { width: auto; padding: 8px 14px; font-size: 14px; flex-shrink: 0; } +.quant-btn.active { background: #246; color: #8cf; border-color: #4af; font-weight: 700; } + +/* --- Queued state blink --- */ +@keyframes queued-blink { + from { opacity: 1; } + to { opacity: 0.35; } +} +button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index a04740c..c7706f7 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -6,13 +6,109 @@ function send(address, ...args) { if (ws.readyState === 1) ws.send(JSON.stringify({ address, args })); } const armed = new Set(); +const clipState = new Map(); // name -> last-known state int: 0 off, 1 playing, 2 queued-launch, 3 queued-stop function togglePad(el, name) { - const on = !armed.has(name); - on ? armed.add(name) : armed.delete(name); - el.classList.toggle("armed", on); - send("/launch", name, on ? 1 : 0); + // Use last-known state for toggle direction; fall back to armed Set + const st = clipState.has(name) ? clipState.get(name) : (armed.has(name) ? 1 : 0); + // launch (1) when off or queued-stop; stop (0) when playing or queued-launch + const launch = (st === 0 || st === 3); + // optimistic feedback — authoritative update comes from /armed feedback + el.classList.add("queued"); + if (launch) el.classList.add("armed"); + send("/launch", name, launch ? 1 : 0); } +// --- Feedback: engine -> browser --- +let suppressEmit = false; +let beatFlashTimer = null; + +ws.addEventListener("message", (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (_e) { return; } + if (!msg || typeof msg.address !== "string") return; + const { address, args } = msg; + + // /armed/ — engine is source of truth; args[0] is a state int 0-3 + // 0 = off, 1 = playing (solid), 2 = queued-launch (blink), 3 = queued-stop (blink) + if (address.startsWith("/armed/")) { + const name = address.slice(7); // strip "/armed/" + const st = Number(args[0]); + clipState.set(name, st); + if (st >= 1) armed.add(name); else armed.delete(name); + document.querySelectorAll(`[data-pad="${name}"]`).forEach((btn) => { + btn.classList.toggle("armed", st >= 1); + btn.classList.toggle("queued", st === 2 || st === 3); + }); + return; + } + + // /sync/beat — flash beat dot for 80 ms; cancel any pending clear first + if (address === "/sync/beat") { + const beatDot = document.getElementById("beat-dot"); + if (!beatDot) return; + if (beatFlashTimer !== null) { clearTimeout(beatFlashTimer); beatFlashTimer = null; } + beatDot.classList.add("flash"); + beatFlashTimer = setTimeout(() => { + beatDot.classList.remove("flash"); + beatFlashTimer = null; + }, 80); + return; + } + + // /sync/rms — drive master VU meter width + if (address === "/sync/rms") { + const raw = Number(args[0]); + const rms = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0; + const bar = document.getElementById("vu-bar"); + if (bar) bar.style.width = `${rms * 100}%`; + return; + } + + // /seq/rhythm/state [idx, k, s, h, name] + if (address === "/seq/rhythm/state") { + if (!state) return; + const i = Math.round(Number(args[0])); + if (!Number.isFinite(i) || i < 0 || i >= state.rhythms.length) return; + if (typeof args[1] === "string") state.rhythms[i].k = args[1]; + if (typeof args[2] === "string") state.rhythms[i].s = args[2]; + if (typeof args[3] === "string") state.rhythms[i].h = args[3]; + if (typeof args[4] === "string") state.rhythms[i].name = args[4]; + state.rhySel = i; + saveState(); + suppressEmit = true; + try { + restoreSel("rhythm", i); + updateSelLabels("rhythm"); + updateNameInputs(); + renderRhyGrid(); + } finally { + suppressEmit = false; + } + return; + } + + // /seq/melody/state [idx, deg0, deg1, ...] + if (address === "/seq/melody/state") { + if (!state) return; + const i = Math.round(Number(args[0])); + if (!Number.isFinite(i) || i < 0 || i >= state.melodies.length) return; + const degs = args.slice(1).map(Number).filter(Number.isFinite); + if (degs.length > 0) state.melodies[i].degrees = degs; + state.melSel = i; + saveState(); + suppressEmit = true; + try { + restoreSel("melody", i); + updateSelLabels("melody"); + updateNameInputs(); + renderMelSteps(); + } finally { + suppressEmit = false; + } + return; + } +}); + // --- Pattern state (defaults mirror launchpad.scd exactly) --- const STORAGE_KEY = "avlive.seq"; @@ -94,6 +190,7 @@ function renderRhyGrid() { ).join(""); container.querySelectorAll(".step-cell").forEach(cell => cell.addEventListener("click", () => { + if (suppressEmit) return; const voice = cell.dataset.voice; const step = +cell.dataset.step; const preset = state.rhythms[state.rhySel]; @@ -119,6 +216,7 @@ function renderMelSteps() { ).join(""); container.querySelectorAll("input[type=number]").forEach(inp => inp.addEventListener("change", () => { + if (suppressEmit) return; const i = +inp.dataset.midx; const val = Math.max(-7, Math.min(14, isNaN(+inp.value) ? 0 : +inp.value)); inp.value = val; @@ -159,6 +257,17 @@ function updateNameInputs() { document.addEventListener("DOMContentLoaded", () => { loadState(); + // Tab switching + document.querySelectorAll(".tab-btn").forEach((btn) => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); + document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + const panel = document.getElementById(`tab-${btn.dataset.tab}`); + if (panel) panel.classList.add("active"); + }); + }); + // Render editors from state restoreSel("melody", state.melSel); restoreSel("rhythm", state.rhySel); @@ -209,10 +318,19 @@ document.addEventListener("DOMContentLoaded", () => { const filt = document.getElementById("filter"); filt && filt.addEventListener("input", () => send("/control/fx/filter", +filt.value)); + // Quantize selector + document.querySelectorAll(".quant-btn").forEach(btn => + btn.addEventListener("click", () => { + document.querySelectorAll(".quant-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + send("/launch/quant", +btn.dataset.quant); + })); + // Clear document.getElementById("clear").addEventListener("click", () => { armed.clear(); - document.querySelectorAll("[data-pad]").forEach((e) => e.classList.remove("armed")); + clipState.clear(); + document.querySelectorAll("[data-pad]").forEach((e) => e.classList.remove("armed", "queued")); send("/launch/clear"); }); diff --git a/web_realart/public/control/index.html b/web_realart/public/control/index.html index 3fc2438..acb60f9 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -2,7 +2,19 @@ AV-Live — Control -

AV-Live Control

+
+

AV-Live Control

+ + +
+
+ + +

Patterns

@@ -24,12 +36,26 @@
+
+ Quant + + + + +
-

Concert

+
+ +
+

FX

+
- - - + + + + + +

Harmonie

@@ -48,17 +74,16 @@
-

FX

- +

Concert

- - - - - - + + +
+ + +

Séquenceurs

Mélodie

@@ -99,4 +124,5 @@
+
diff --git a/web_realart/server.js b/web_realart/server.js index 0e60ca2..dd64634 100644 --- a/web_realart/server.js +++ b/web_realart/server.js @@ -14,8 +14,9 @@ // real.art.saillant.cc // // Variables d'environnement : -// HTTP_PORT (defaut 4400) -// DATA_PORT_IN (defaut 57124, ce que bridge.py envoie ici) +// HTTP_PORT (defaut 4400) +// DATA_PORT_IN (defaut 57124, ce que bridge.py envoie ici) +// FEEDBACK_PORT_IN (defaut 9000, feedback SC engine -> navigateurs) // ===================================================================== import express from "express"; import { fileURLToPath } from "node:url"; @@ -28,8 +29,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "4400", 10); const DATA_PORT_IN = parseInt(process.env.DATA_PORT_IN ?? "57124", 10); -const SC_HOST = process.env.AVLIVE_SC_HOST ?? "127.0.0.1"; -const SC_PORT = parseInt(process.env.AVLIVE_SC_PORT ?? "57121", 10); +const SC_HOST = process.env.AVLIVE_SC_HOST ?? "127.0.0.1"; +const SC_PORT = parseInt(process.env.AVLIVE_SC_PORT ?? "57121", 10); +const FEEDBACK_PORT_IN = parseInt(process.env.FEEDBACK_PORT_IN ?? "9000", 10); const app = express(); app.use(express.static(join(__dirname, "public"))); @@ -99,6 +101,28 @@ dataPort.on("message", (oscMsg) => { dataPort.on("error", (err) => console.error("[data] erreur:", err.message)); dataPort.open(); +// Second UDP listener: engine feedback (SC -> browser clients). +// Relays /armed/*, /sync/beat, /sync/rms, /seq/*/state to all WS clients. +// Does NOT update lastByAddress snapshot (feedback is transient, not snapshottable). +const feedbackPort = new osc.UDPPort({ + localAddress: "0.0.0.0", + localPort: FEEDBACK_PORT_IN, + metadata: false, +}); + +feedbackPort.on("ready", () => { + console.log(`[feedback] ecoute :${FEEDBACK_PORT_IN} <- SC engine`); +}); + +const FEEDBACK_PREFIXES = ["/armed/", "/sync/", "/seq/"]; +feedbackPort.on("message", (oscMsg) => { + if (!FEEDBACK_PREFIXES.some((p) => oscMsg.address.startsWith(p))) return; + broadcast({ address: oscMsg.address, args: oscMsg.args }); +}); + +feedbackPort.on("error", (err) => console.error("[feedback] erreur:", err.message)); +feedbackPort.open(); + const scPort = new osc.UDPPort({ localAddress: "127.0.0.1", localPort: 0, metadata: true }); scPort.on("ready", () => console.log(`[sc] OSC out -> ${SC_HOST}:${SC_PORT}`)); scPort.open(); @@ -120,6 +144,7 @@ function shutdown() { console.log("\n[server] shutdown..."); wss.close(); dataPort.close(); + feedbackPort.close(); scPort.close(); httpServer.close(() => process.exit(0)); }