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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user