Merge remote-tracking branch 'origin/main'
CI build oscope-of / build-check (push) Has been cancelled
CI build oscope-of / build-check (push) Has been cancelled
This commit is contained in:
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
)
|
||||
@@ -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;
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
dist/_probe.tosc
|
||||
@@ -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.
|
||||
Vendored
BIN
Binary file not shown.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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"
|
||||
Generated
+79
@@ -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" }]
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user