Merge remote-tracking branch 'origin/main'
CI build oscope-of / build-check (push) Has been cancelled

This commit is contained in:
clement
2026-06-28 15:18:44 +02:00
31 changed files with 3290 additions and 41 deletions
File diff suppressed because it is too large Load Diff
@@ -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: `<lexml version="3">` containing exactly **one** root `<node>`.
- XML declaration present: `<?xml version='1.0' encoding='UTF-8'?>`.
## 3. Node
```
<node ID="<uuid4>" type="<CONTROL_TYPE>">
<properties> property* </properties>
<values/> <!-- present even when empty -->
<messages> osc* </messages>
<children> node* </children>
</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
```
<property type="<code>">
<key>name</key>
<value>...</value>
</property>
```
Type codes (the `<value>` child shape depends on the code):
| code | meaning | `<value>` shape |
|------|---------|-----------------|
| `s` | string | plain text (`<value>vol</value>`); also used for the `script` Lua property |
| `b` | bool | plain text `0`/`1` |
| `i` | int | plain text |
| `f` | float | plain text |
| `r` | frame/rect | `<value><x/><y/><w/><h/></value>` |
| `c` | color | `<value><r/><g/><b/><a/></value>` |
The `frame` property (`type="r"`) is confirmed verbatim in the probe:
`<value><x>10</x><y>20</y><w>60</w><h>200</h></value>`.
## 5. OSC message
```
<osc enabled="1" send="1" receive="0" feedback="0" connections="00001">
<triggers>
<trigger><var>x</var><condition>ANY</condition></trigger>
</triggers>
<path>
<partial> ... </partial> <!-- the address, segment by segment -->
</path>
<arguments>
<partial> ... </partial>* <!-- zero or more OSC arguments -->
</arguments>
</osc>
```
- **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):
```
<partial>
<type>CONSTANT|VALUE</type>
<conversion>STRING|FLOAT|INTEGER|BOOLEAN</conversion>
<value>...</value>
<scaleMin>0</scaleMin>
<scaleMax>1</scaleMax>
</partial>
```
- `CONSTANT`/`STRING` with `<value>/launch/vol</value>` → a literal address (or
a literal string argument like `kick`).
- `VALUE`/`FLOAT` with `<value>x</value>` → 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 <file>` 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.
@@ -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 : <SC OSCdef port, 57121 today> (control)
▼
SuperCollider engine on macm1 (launchpad.scd + concert)
│ OSC/UDP ──► <sender IP> : <TouchOSC listen port, 9000 default> (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` | `<name> <0\|1>` |
| Pad volume | `/launch/vol` | `<name> <0..1.5>` |
| Tempo | `/launch/tempo` | `<bpm>` |
| 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` | `<idx>` |
| Seq edit | `/seq/melody/set`, `/seq/rhythm/set` | degrees / `<k> <s> <h>` |
**Feedback (SC → TouchOSC):**
| Source | Address | Effect on TouchOSC |
|--------|---------|--------------------|
| Arm/disarm (incl. concert) | `/armed/<name>` `<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/<name> <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.
+3
View File
@@ -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;
+59 -17
View File
@@ -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) };
@@ -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;
)
+142
View File
@@ -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/<name> 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;
)
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc
dist/_probe.tosc
+99
View File
@@ -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 <file>` 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 <name> <0|1>`,
`/launch/vol <name> <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 <play|concert|all>`,
`/seq/{melody,rhythm} <idx>`, `/seq/{melody,rhythm}/set ...`,
arm sequencers via `/launch melseq|rhythmseq <0|1>`.
Feedback (SC → surface, port 9000, all created by `touchosc_feedback.scd`):
`/armed/<name> <0|1>`, `/sync/beat <1>`, `/sync/rms <amp>`,
`/seq/rhythm/state <idx> <k> <s> <h> <name>`,
`/seq/melody/state <idx> <degrees...>`.
## 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.
Binary file not shown.
View File
+17
View File
@@ -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()
+340
View File
@@ -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/<name> 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
+21
View File
@@ -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
+17
View File
@@ -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
+33
View File
@@ -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/<name>.
-- 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
+33
View File
@@ -0,0 +1,33 @@
-- Seq page GROUP: update rhythm name labels and melody fader values from SC state.
-- /seq/rhythm/state <idx> <k> <s> <h> <name> -> updates rhythm label R(idx+1)
-- /seq/melody/state <idx> <d0..dN> -> 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
+31
View File
@@ -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 <idx> <k> <s> <h> <name>: 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
+177
View File
@@ -0,0 +1,177 @@
"""TouchOSC v3 (.tosc) layout schema and serializer.
A ``.tosc`` file is a gzip-compressed XML document whose root is
``<lexml version="3">`` containing a single root ``<node>``. 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 ``<property type="..">`` with ``<key>``/``<value>`` 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 ``<partial>`` 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 ``<osc>`` 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 ``<node>`` 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 ``<lexml version="3">`` 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 ``<node>`` element."""
with gzip.open(path, "rb") as f:
lexml = ET.fromstring(f.read())
return lexml.find("node")
+12
View File
@@ -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 = ["."]
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<lexml version="3">
<node ID="7b287d4a-3ded-435e-96d3-3d7a1fb7d977" type="GROUP">
<properties>
<property type="s">
<key>name</key>
<value>root</value>
</property>
</properties>
<values/>
<messages/>
<children>
<node ID="2b908650-d7aa-49be-a3e5-dff646c214f2" type="FADER">
<properties>
<property type="s">
<key>name</key>
<value>vol</value>
</property>
<property type="r">
<key>frame</key>
<value>
<x>10</x>
<y>20</y>
<w>60</w>
<h>200</h>
</value>
</property>
</properties>
<values/>
<messages>
<osc enabled="1" send="1" receive="0" feedback="0" connections="00001">
<triggers>
<trigger>
<var>x</var>
<condition>ANY</condition>
</trigger>
</triggers>
<path>
<partial>
<type>CONSTANT</type>
<conversion>STRING</conversion>
<value>/launch/vol</value>
<scaleMin>0</scaleMin>
<scaleMax>1</scaleMax>
</partial>
</path>
<arguments>
<partial>
<type>CONSTANT</type>
<conversion>STRING</conversion>
<value>kick</value>
<scaleMin>0</scaleMin>
<scaleMax>1</scaleMax>
</partial>
<partial>
<type>VALUE</type>
<conversion>FLOAT</conversion>
<value>x</value>
<scaleMin>0</scaleMin>
<scaleMax>1</scaleMax>
</partial>
</arguments>
</osc>
</messages>
<children/>
</node>
</children>
</node>
</lexml>
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
<?xml version='1.0' encoding='UTF-8'?>
<lexml version="3"><node ID="7b287d4a-3ded-435e-96d3-3d7a1fb7d977" type="GROUP"><properties><property type="s"><key>name</key><value>root</value></property></properties><values /><messages /><children><node ID="2b908650-d7aa-49be-a3e5-dff646c214f2" type="FADER"><properties><property type="s"><key>name</key><value>vol</value></property><property type="r"><key>frame</key><value><x>10</x><y>20</y><w>60</w><h>200</h></value></property></properties><values /><messages><osc enabled="1" send="1" receive="0" feedback="0" connections="00001"><triggers><trigger><var>x</var><condition>ANY</condition></trigger></triggers><path><partial><type>CONSTANT</type><conversion>STRING</conversion><value>/launch/vol</value><scaleMin>0</scaleMin><scaleMax>1</scaleMax></partial></path><arguments><partial><type>CONSTANT</type><conversion>STRING</conversion><value>kick</value><scaleMin>0</scaleMin><scaleMax>1</scaleMax></partial><partial><type>VALUE</type><conversion>FLOAT</conversion><value>x</value><scaleMin>0</scaleMin><scaleMax>1</scaleMax></partial></arguments></osc></messages><children /></node></children></node></lexml>
+66
View File
@@ -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"
+324
View File
@@ -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/<name> 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 <messages> 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)}"
)
+27
View File
@@ -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 <partial><value>, not an attr.
addr = child.find("messages/osc/path/partial/value")
assert addr.text == "/launch/vol"
+79
View File
@@ -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" }]
+36 -2
View File
@@ -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; }
+123 -5
View File
@@ -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/<name> — 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");
});
+39 -13
View File
@@ -2,7 +2,19 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AV-Live — Control</title><link rel="stylesheet" href="control.css"></head>
<body>
<header><h1>AV-Live Control</h1><span id="status" class="off"></span></header>
<header>
<h1>AV-Live Control</h1>
<span id="status" class="off"></span>
<span id="beat-dot" class="beat-dot"></span>
<div class="vu-wrap"><div id="vu-bar" class="vu-bar"></div></div>
</header>
<nav class="tab-bar">
<button class="tab-btn active" data-tab="live">LIVE</button>
<button class="tab-btn" data-tab="fx">FX / HARMONIE / CONCERT</button>
<button class="tab-btn" data-tab="seq">SÉQUENCEURS</button>
</nav>
<div class="tab-panel active" id="tab-live">
<section><h2>Patterns</h2>
<div class="grid">
<div class="pat-cell"><button data-pad="kick">Kick</button><input type="range" min="0" max="1.5" step="0.05" value="0.8" data-vol="kick"></div>
@@ -24,12 +36,26 @@
</div>
<button id="clear" class="wide">Tout couper</button>
<label>Tempo <input id="tempo" type="range" min="60" max="180" value="126"></label>
<div class="quant-bar">
<span class="quant-label">Quant</span>
<button class="quant-btn" data-quant="0.5">½</button>
<button class="quant-btn" data-quant="1">1</button>
<button class="quant-btn active" data-quant="4">1 mes</button>
<button class="quant-btn" data-quant="8">2 mes</button>
</div>
</section>
<section><h2>Concert</h2>
</div>
<div class="tab-panel" id="tab-fx">
<section><h2>FX</h2>
<label>Filtre <input id="filter" type="range" min="0" max="1" step="0.01" value="1"></label>
<div class="grid">
<button data-osc="/control/concertNext" data-arg="1">Morceau suivant</button>
<button data-osc="/control/doScene" data-arg="concert">Scène concert</button>
<button data-osc="/control/doScene" data-arg="play">Body-play</button>
<button data-osc="/control/fx/stutter">Stutter</button>
<button data-osc="/control/fx/crash">Crash</button>
<button data-osc="/control/fx/kick">Kick</button>
<button data-osc="/control/fx/swell">Swell</button>
<button data-osc="/control/fx/breakdown" data-arg="1">Breakdown ↓</button>
<button data-osc="/control/fx/breakdown" data-arg="0">Breakdown ↑</button>
</div>
</section>
<section><h2>Harmonie</h2>
@@ -48,17 +74,16 @@
<button data-osc="/control/harmony/phrase" data-arg="3">Phr 3</button>
</div>
</section>
<section><h2>FX</h2>
<label>Filtre <input id="filter" type="range" min="0" max="1" step="0.01" value="1"></label>
<section><h2>Concert</h2>
<div class="grid">
<button data-osc="/control/fx/stutter">Stutter</button>
<button data-osc="/control/fx/crash">Crash</button>
<button data-osc="/control/fx/kick">Kick</button>
<button data-osc="/control/fx/swell">Swell</button>
<button data-osc="/control/fx/breakdown" data-arg="1">Breakdown ↓</button>
<button data-osc="/control/fx/breakdown" data-arg="0">Breakdown ↑</button>
<button data-osc="/control/concertNext" data-arg="1">Morceau suivant</button>
<button data-osc="/control/doScene" data-arg="concert">Scène concert</button>
<button data-osc="/control/doScene" data-arg="play">Body-play</button>
</div>
</section>
</div>
<div class="tab-panel" id="tab-seq">
<section><h2>Séquenceurs</h2>
<div class="seq-block">
<h3>Mélodie</h3>
@@ -99,4 +124,5 @@
<input type="range" min="0" max="1.5" step="0.05" value="0.8" data-vol="rhythmseq">
</div>
</section>
</div>
<script src="control.js"></script></body></html>
+29 -4
View File
@@ -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));
}