# 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 `<lexml version="3">`, 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:
`<node ID="uuid" type="FADER">` containing `<properties>`, `<values>`, `<messages>`, `<children>`; a property is `<property type="s"><key>name</key><value>..</value></property>`; an OSC message is `<osc enabled="1" send="1" receive="0" feedback="0" connections="..."><triggers/><path><partial .../></path><arguments><partial .../></arguments></osc>`; 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 <value>`. 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 `<lexml version="3">` with a `<node type="GROUP">` root and child `<node type="FADER">` / `<node type="BUTTON">` entries, each with `<properties>`, `<messages>` (containing `<osc ...>`), 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 `<value>` child shape (especially `frame` and `color`), the full `<osc>` element with all attributes (`enabled send receive feedback connections`) and the `<path>`/`<arguments>` `<partial>` 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 `<property>`.
  - `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 `<osc>` 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 <name>` (with name + `receive` on `/armed/<name>`), vertical vol FADER `/launch/vol <name>`, 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/<name>`):
```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 <name> <0|1>`; `/armed/<name>` 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/<name> <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. ✓
