feat(web): Live tab — 30 pads + reboot web

Context: live performance needs immediate, single-key triggers for the
common moves (intro / drop / breakdown / fills / mute a voice / random
tweak), AND mid-set the operator may need to reboot just the Node web
server without touching sclang or the audio engine. The previous
commit added scsynth and sclang reboots but not web.

Approach: a new Live tab dedicated to performance. 30 pads laid out
on a 10x3 grid that mirrors the QWERTY rows physically — top row
(qwertyuiop) = chains, home row (asdfghjkl;) = one-shots, bottom row
(zxcvbnm,./) = mutes + FX. Each pad shows a big label, a small kind
hint and the bound key in a kbd-style badge at the bottom right.
When the Live tab is active a capture-phase keydown listener wins
against the global keymap, so 'r' fires Buildup ×4 instead of
'refresh catalogues' etc. — non-Live tabs keep the global behavior.
Mute pads are stateful : they toggle /control/mute and /control/unmute
based on a local Set, and visually invert (red, 🔇 prefix) when active.
The ⟳ web button writes ~/.av-live-restart-web ; the launcher's
sentinel watcher restarts the Node server without touching sclang.

Changes:
- web/public/control/index.html :
  * new tab Live between Transport and Album
  * new tabpane #livePads (filled by JS), with header hint listing the
    three sections
  * three reboot buttons in the topbar (⟳ sc small / ⟳ web small / ⟳ sclang big)
- web/public/control/app.js :
  * LIVE_PADS array (30 entries, key/label/sub/kind/arg/voice)
  * firePad(pad, padEl) dispatcher : chain / playAll / stopAll / synth
    / fn / tap / ping / unsolo / muteToggle
  * initLivePads() builds the 30 buttons, attaches click + capture-phase
    keydown listener that only fires when Live tab is active, with
    .hit pulse animation feedback
  * makeArmedButton binding for #btnRebootWeb sending /control/rebootWeb
- web/public/control/style.css :
  * .live-grid 10-col CSS grid (5-col under 1200px, 2-col under 720px)
  * .live-pad with hover/active microinteractions and section-coloured
    .live-pad-{chain,shot,mute} variants
  * .live-pad.muted state (red tint, 🔇 prefix)
  * .live-pad-key kbd-style badge bottom-right
- web_bridge.scd :
  * /control/rebootWeb : writes ~/.av-live-restart-web sentinel
  * /control/triggerSynth <name> : safe Synth(<name>, [\decay 2, \amp 0.5])
  * /control/snareFill : ~snareFill.value
  * /control/tomFill : ~tomFill.value
  * /control/randomTweak : ~randomTweak.value
- launcher/ProcessManager.swift :
  * webWantsRestart flag + restartWeb() helper
  * web terminationHandler now respawns 0.4s later if wantsRestart
  * startSentinelWatcher() now also watches ~/.av-live-restart-web

Impact: switch to the Live tab and trigger any of the 30 commands by
clicking or by tapping the corresponding key on the keyboard. Mute
toggles persist visually. ⟳ web restarts the Node server in ~1 second
without disturbing sclang/scsynth or the WS clients (which auto-
reconnect).
This commit is contained in:
L'électron rare
2026-05-07 18:03:53 +02:00
parent b608a50c38
commit 3e82d81ffc
5 changed files with 320 additions and 7 deletions
@@ -160,6 +160,12 @@ final class ProcessManager: ObservableObject {
DispatchQueue.main.async {
self?.webProc = nil
self?.webRunning = false
if self?.webWantsRestart == true {
self?.webWantsRestart = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self?.startWeb()
}
}
}
}
append(source: "launcher", text: "started web server on :\(webPort)")
@@ -169,9 +175,20 @@ final class ProcessManager: ObservableObject {
}
func stopWeb() {
webWantsRestart = false
webProc?.terminate()
}
private var webWantsRestart = false
func restartWeb() {
guard webProc != nil else { startWeb(); return }
append(source: "launcher", text: "restarting web server…")
webWantsRestart = true
webProc?.terminate()
webWantsRestart = true
}
func openBrowser() {
if let url = URL(string: "http://localhost:\(webPort)/control/") {
NSWorkspace.shared.open(url)
@@ -297,19 +314,25 @@ final class ProcessManager: ObservableObject {
sclangWantsRestart = true
}
/// Watches the .av-live-restart-sclang sentinel file. Triggered by
/// the bridge's /control/rebootSclang OSC handler.
/// Watches both .av-live-restart-sclang and .av-live-restart-web
/// sentinel files. Triggered by the bridge's reboot handlers.
private var sentinelTimer: Timer?
func startSentinelWatcher() {
let path = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".av-live-restart-sclang").path
let home = FileManager.default.homeDirectoryForCurrentUser
let sclangSentinel = home.appendingPathComponent(".av-live-restart-sclang").path
let webSentinel = home.appendingPathComponent(".av-live-restart-web").path
sentinelTimer?.invalidate()
sentinelTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
if FileManager.default.fileExists(atPath: sclangSentinel) {
try? FileManager.default.removeItem(atPath: sclangSentinel)
self?.append(source: "launcher", text: "sentinel detected — restarting sclang")
self?.restartSclang()
}
if FileManager.default.fileExists(atPath: webSentinel) {
try? FileManager.default.removeItem(atPath: webSentinel)
self?.append(source: "launcher", text: "sentinel detected — restarting web")
self?.restartWeb()
}
}
}
+126
View File
@@ -655,6 +655,14 @@ makeArmedButton(document.getElementById("btnRebootScsynth"), {
toastFired: "scsynth reboot sent"
});
makeArmedButton(document.getElementById("btnRebootWeb"), {
armedLabel: "CONFIRM ?",
armedClass: "warn",
sendOnConfirm: () => send("/control/rebootWeb"),
toastArmed: "web reboot armed — click again",
toastFired: "web reboot sent — Node restarting"
});
makeArmedButton(document.getElementById("btnRebootSclang"), {
armedLabel: "CONFIRM sclang ?",
armedClass: "warn",
@@ -1784,3 +1792,121 @@ const HYDRA_PARAMS = [
// ---------------- Init final ----------------------------------------
// Underline calé après render initial (fonts, layout)
window.addEventListener("load", () => requestAnimationFrame(positionUnderline));
// =====================================================================
// LIVE PADS — 30 boutons mappés sur les 3 rangées qwerty / asdf / zxcv
// =====================================================================
const LIVE_PADS = [
// Row 1 — CHAINS (cyan)
{ key: "q", label: "INTRO", sub: "chain", kind: "chain", arg: "intro" },
{ key: "w", label: "DROP", sub: "chain", kind: "chain", arg: "drop" },
{ key: "e", label: "BRK", sub: "breakdown", kind: "chain", arg: "breakdown" },
{ key: "r", label: "BLD ×4", sub: "buildup", kind: "chain", arg: "buildup", arg2: 4 },
{ key: "t", label: "BLD ×8", sub: "buildup", kind: "chain", arg: "buildup", arg2: 8 },
{ key: "y", label: "TECHNO", sub: "chain", kind: "chain", arg: "techno", arg2: 4 },
{ key: "u", label: "DNB", sub: "chain", kind: "chain", arg: "dnb", arg2: 4 },
{ key: "i", label: "FADE", sub: "outro fade", kind: "chain", arg: "outroFade", arg2: 16 },
{ key: "o", label: "C-STOP", sub: "chain stop", kind: "chain", arg: "stop" },
{ key: "p", label: "PLAY", sub: "playAll", kind: "playAll" },
// Row 2 — ONESHOTS (orange)
{ key: "a", label: "RISER", sub: "FX trick", kind: "synth", arg: "riser" },
{ key: "s", label: "STOP", sub: "stopAll", kind: "stopAll" },
{ key: "d", label: "SN-FILL", sub: "fill", kind: "fn", arg: "snareFill" },
{ key: "f", label: "TM-FILL", sub: "fill", kind: "fn", arg: "tomFill" },
{ key: "g", label: "SWEEP", sub: "down", kind: "synth", arg: "sweep" },
{ key: "h", label: "CRASH", sub: "FX trick", kind: "synth", arg: "crash" },
{ key: "j", label: "GONG", sub: "FX trick", kind: "synth", arg: "gong" },
{ key: "k", label: "IMPACT", sub: "FX trick", kind: "synth", arg: "impact" },
{ key: "l", label: "TAP", sub: "BPM tap", kind: "tap" },
{ key: ";", label: "RANDOM", sub: "tweak", kind: "fn", arg: "randomTweak" },
// Row 3 — MUTES + FX (purple)
{ key: "z", label: "KICK", sub: "mute", kind: "muteToggle", voice: "kick" },
{ key: "x", label: "HAT", sub: "mute", kind: "muteToggle", voice: "hat" },
{ key: "c", label: "SNARE", sub: "mute", kind: "muteToggle", voice: "snare" },
{ key: "v", label: "CLAP", sub: "mute", kind: "muteToggle", voice: "clap" },
{ key: "b", label: "PERC", sub: "mute", kind: "muteToggle", voice: "perc" },
{ key: "n", label: "ACID", sub: "mute", kind: "muteToggle", voice: "acid" },
{ key: "m", label: "MELODY", sub: "mute", kind: "muteToggle", voice: "melody" },
{ key: ",", label: "HARMONY", sub: "mute", kind: "muteToggle", voice: "harmony" },
{ key: ".", label: "UNSOLO", sub: "clear", kind: "unsolo" },
{ key: "/", label: "PING", sub: "round-trip",kind: "ping" },
];
const liveMuteState = new Set(); // voices currently muted
function firePad(pad, padEl) {
switch (pad.kind) {
case "chain":
if (pad.arg2 != null) send("/control/chain", pad.arg, pad.arg2);
else send("/control/chain", pad.arg);
break;
case "playAll": send("/control/playAll"); break;
case "stopAll": send("/control/stopAll"); break;
case "synth": send("/control/triggerSynth", pad.arg); break;
case "fn": send("/control/" + pad.arg); break;
case "tap": document.getElementById("btnTap")?.click(); break;
case "ping": send("/control/ping"); break;
case "unsolo": send("/control/unsolo"); break;
case "muteToggle": {
if (liveMuteState.has(pad.voice)) {
send("/control/unmute", pad.voice);
liveMuteState.delete(pad.voice);
padEl?.classList.remove("muted");
} else {
send("/control/mute", pad.voice);
liveMuteState.add(pad.voice);
padEl?.classList.add("muted");
}
break;
}
}
if (padEl) {
padEl.classList.remove("hit");
void padEl.offsetWidth;
padEl.classList.add("hit");
}
}
(function initLivePads() {
const host = document.getElementById("livePads");
if (!host) return;
const byKey = new Map();
LIVE_PADS.forEach(pad => {
const el = document.createElement("button");
const sectionClass =
LIVE_PADS.indexOf(pad) < 10 ? "live-pad-chain" :
LIVE_PADS.indexOf(pad) < 20 ? "live-pad-shot" :
"live-pad-mute";
el.className = `live-pad ${sectionClass}`;
el.dataset.key = pad.key;
if (pad.voice) el.dataset.voice = pad.voice;
el.innerHTML =
`<div class="live-pad-label">${pad.label}</div>` +
`<div class="live-pad-sub">${pad.sub}</div>` +
`<div class="live-pad-key">${pad.key.toUpperCase()}</div>`;
el.addEventListener("click", () => firePad(pad, el));
host.appendChild(el);
byKey.set(pad.key, { pad, el });
});
// Keyboard handling — only when the Live tab is active. Overrides the
// global keymap (tab nav, refresh, stop, help) for the duration of
// the tab. Always honors Esc, ?/h, m for fallthrough.
document.addEventListener("keydown", (ev) => {
const t = ev.target;
if (t.matches?.("input, textarea, select, [contenteditable=true]")) return;
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
const liveTab = document.querySelector('.tabpane[data-tab="tab-live"]');
const isLive = liveTab && liveTab.classList.contains("active");
if (!isLive) return;
const pair = byKey.get(ev.key.toLowerCase());
if (!pair) return;
firePad(pair.pad, pair.el);
ev.preventDefault();
ev.stopPropagation();
}, true); // capture phase so we win against the global handler
})();
+14 -1
View File
@@ -35,7 +35,8 @@
<span class="ws-dot offline" id="scDot"></span>
<span class="ws-label" id="scLabel">sclang</span>
</div>
<button class="btn warn small" id="btnRebootScsynth" title="Reboot scsynth seul (click twice within 2s)">⟳ scsynth</button>
<button class="btn warn small" id="btnRebootScsynth" title="Reboot scsynth seul (click twice within 2s)">⟳ sc</button>
<button class="btn warn small" id="btnRebootWeb" title="Reboot serveur web Node (click twice within 2s)">⟳ web</button>
<button class="btn warn" id="btnRebootSclang" title="Reboot sclang complet — recharge le moteur (click twice within 2s)">⟳ sclang</button>
<button class="btn danger teardown" id="btnTeardown" title="Click twice within 2s to confirm">TEARDOWN</button>
</div>
@@ -79,6 +80,7 @@
<!-- ============================== TABS ============================== -->
<nav class="tabs" id="tabs">
<button class="tab active" data-target="tab-transport">Transport</button>
<button class="tab" data-target="tab-live">Live</button>
<button class="tab" data-target="tab-album">Album</button>
<button class="tab" data-target="tab-mixer">Mixer</button>
<button class="tab" data-target="tab-steps">Steps</button>
@@ -140,6 +142,17 @@
</div>
</section>
<!-- ============================== LIVE ============================== -->
<section class="tabpane" data-tab="tab-live">
<div class="card">
<div class="live-head">
<h2>Live pads</h2>
<span class="live-hint">Chaque pad est cablé sur la touche affichée en bas. Les sections : <b>Chains</b> (cyan, ligne 1), <b>One-shots</b> (orange, ligne 2), <b>Mutes / FX</b> (violet, ligne 3).</span>
</div>
<div class="live-grid" id="livePads"></div>
</div>
</section>
<!-- ============================== ALBUM ============================== -->
<section class="tabpane" data-tab="tab-album">
<div class="card album-toolbar">
+118
View File
@@ -1386,3 +1386,121 @@ kbd {
padding: 4px 8px;
font-size: 0.78rem;
}
/* ======================== LIVE PADS ================================= */
.live-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 10px;
}
.live-head h2 { margin: 0; }
.live-hint {
font-size: 0.78rem;
color: var(--muted);
flex: 1;
line-height: 1.4;
}
.live-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 8px;
}
.live-pad {
position: relative;
aspect-ratio: 1;
min-height: 88px;
padding: 10px 6px 22px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(20, 24, 36, 0.6);
color: var(--text);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
transition: transform 0.06s, box-shadow 0.12s, border-color 0.12s, background 0.12s;
user-select: none;
overflow: hidden;
}
.live-pad:hover {
transform: translateY(-1px);
border-color: rgba(255,255,255,0.18);
}
.live-pad:active { transform: translateY(0); }
.live-pad.hit {
animation: pad-hit 0.25s ease-out;
}
@keyframes pad-hit {
0% { transform: scale(0.94); }
40% { transform: scale(1.04); }
100% { transform: scale(1); }
}
.live-pad-label {
font: 700 0.85rem ui-monospace, JetBrains Mono, monospace;
letter-spacing: 0.04em;
line-height: 1.1;
text-align: center;
}
.live-pad-sub {
font-size: 0.66rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.live-pad-key {
position: absolute;
bottom: 4px;
right: 6px;
font: 600 0.66rem ui-monospace, JetBrains Mono, monospace;
color: var(--muted);
border: 1px solid rgba(255,255,255,0.15);
border-bottom-width: 2px;
border-radius: 4px;
padding: 1px 5px;
background: rgba(0,0,0,0.4);
}
/* Section colors */
.live-pad-chain {
border-color: rgba(92, 208, 255, 0.35);
background: linear-gradient(160deg, rgba(92, 208, 255, 0.08), rgba(20, 24, 36, 0.6));
}
.live-pad-chain:hover { border-color: rgba(92, 208, 255, 0.7); box-shadow: 0 0 16px rgba(92, 208, 255, 0.18); }
.live-pad-chain .live-pad-label { color: #8ee1ff; }
.live-pad-shot {
border-color: rgba(255, 168, 80, 0.35);
background: linear-gradient(160deg, rgba(255, 168, 80, 0.08), rgba(20, 24, 36, 0.6));
}
.live-pad-shot:hover { border-color: rgba(255, 168, 80, 0.7); box-shadow: 0 0 16px rgba(255, 168, 80, 0.18); }
.live-pad-shot .live-pad-label { color: #ffc890; }
.live-pad-mute {
border-color: rgba(190, 130, 255, 0.35);
background: linear-gradient(160deg, rgba(190, 130, 255, 0.08), rgba(20, 24, 36, 0.6));
}
.live-pad-mute:hover { border-color: rgba(190, 130, 255, 0.7); box-shadow: 0 0 16px rgba(190, 130, 255, 0.18); }
.live-pad-mute .live-pad-label { color: #d0b3ff; }
/* Mute toggle active state */
.live-pad.muted {
background: rgba(238, 153, 160, 0.18) !important;
border-color: rgba(238, 153, 160, 0.7) !important;
}
.live-pad.muted .live-pad-label { color: #ee99a0 !important; }
.live-pad.muted .live-pad-label::before { content: "🔇 "; }
/* Compact pad on narrow screens */
@media (max-width: 1199px) {
.live-grid { grid-template-columns: repeat(5, 1fr); }
}
@media (max-width: 720px) {
.live-grid { grid-template-columns: repeat(2, 1fr); }
}
+33
View File
@@ -203,6 +203,39 @@ thisProcess.openUDPPort(~webBridgeIn);
}).play(AppClock);
});
// Reboot serveur web (Node) : ecrit un sentinel que le launcher
// surveille. sclang reste en vie, juste le serveur Node redemarre.
~webBridgeAdd.("/control/rebootWeb", {
"[web_bridge] /control/rebootWeb reçu — relance Node...".postln;
var sentinel = Platform.userHomeDir ++ "/.av-live-restart-web";
File.use(sentinel, "w") { |f| f.write("1") };
});
// ---------- Live tab : triggers FX one-shot ------------------------
// /control/triggerSynth <name> -- joue un Synth one-shot du catalogue
// trick : impact / riser / sweep / crash / gong / ...
~webBridgeAdd.("/control/triggerSynth", { |msg|
var name = msg[1].asString.asSymbol;
{
Synth(name, [\decay, 2, \amp, 0.5]);
}.try { |err|
("[web_bridge] triggerSynth " ++ name ++ " : " ++ err.errorString).postln;
};
});
~webBridgeAdd.("/control/snareFill", {
currentEnvironment[\snareFill] !? { ~snareFill.value }
?? { "[web_bridge] ~snareFill n/a".postln };
});
~webBridgeAdd.("/control/tomFill", {
currentEnvironment[\tomFill] !? { ~tomFill.value }
?? { "[web_bridge] ~tomFill n/a".postln };
});
~webBridgeAdd.("/control/randomTweak", {
currentEnvironment[\randomTweak] !? { ~randomTweak.value }
?? { "[web_bridge] ~randomTweak n/a".postln };
});
// Reboot complet sclang : suicide via pkill, le launcher detecte
// l'exit et relance sclang via terminationHandler + wantsAutoRestart.
~webBridgeAdd.("/control/rebootSclang", {