Files
L'électron rare 495c5680bf
CI build oscope-of / build-check (push) Has been cancelled
feat: tosc sections page for song build
Add SECTIONS (page 4): 8 slot launch+save buttons, PREV/NEXT nav,
/scene/state receive on the GROUP so sections.lua colors slots
(green=current, blue=filled, dark=empty). Update pager to 4 pages,
add 4 new tests (26 pass), rebuild dist/av-live-control.tosc.
2026-06-28 16:16:14 +02:00

397 lines
14 KiB
Python

"""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
# ---------------------------------------------------------------------------
# Page 4 — SECTIONS
# ---------------------------------------------------------------------------
def sections_page():
"""Page 4: 8 section slots (launch + save), prev/next nav, SC scene feedback.
The GROUP carries sections.lua and a /scene/state receive message so that
onReceiveOSC fires on the GROUP and can color the slot buttons.
"""
page = s.node(
"GROUP",
[
s.prop("name", "SECTIONS", "s"),
s.frame(0, 0, *CANVAS),
s.script(_lua("sections.lua")),
],
messages=[
s.osc("/scene/state", [s.val()], send=0, receive=1, feedback=1),
],
)
ch = page.find("children")
ch.append(label("SECTIONS", 10, 8, 200, 30))
gx = 10
bw = 138
gap = 10
step = bw + gap # 148 px per column
launch_y, launch_h = 46, 140
save_y, save_h = 196, 60
for i in range(8):
x = gx + i * step
# Launch button: name=slot{i} so sections.lua can findByName("slot"..i)
ch.append(s.node(
"BUTTON",
[s.prop("name", f"slot{i}", "s"), s.frame(x, launch_y, bw, launch_h)],
messages=[s.osc("/scene/launch", [s.consti(i)])],
))
# Save button
ch.append(s.node(
"BUTTON",
[s.prop("name", f"S{i + 1}", "s"), s.frame(x, save_y, bw, save_h)],
messages=[s.osc("/scene/save", [s.consti(i)])],
))
# Nav buttons
nav_y, nav_h = 272, 80
ch.append(button("< PREV", "/scene/prev", 10, nav_y, 200, nav_h, args=[s.vali()]))
ch.append(button("NEXT >", "/scene/next", 220, nav_y, 200, nav_h, args=[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(), sections_page()])],
)
return root