From 3e82d81ffcd85d73610394c187f7aa064ee1bd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 7 May 2026 18:03:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Live=20tab=20=E2=80=94=2030=20pads?= =?UTF-8?q?=20+=20reboot=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : safe Synth(, [\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). --- .../AVLiveLauncher/ProcessManager.swift | 35 ++++- sound_algo/web/public/control/app.js | 126 ++++++++++++++++++ sound_algo/web/public/control/index.html | 15 ++- sound_algo/web/public/control/style.css | 118 ++++++++++++++++ sound_algo/web_bridge.scd | 33 +++++ 5 files changed, 320 insertions(+), 7 deletions(-) diff --git a/launcher/Sources/AVLiveLauncher/ProcessManager.swift b/launcher/Sources/AVLiveLauncher/ProcessManager.swift index 0dff11d..5648512 100644 --- a/launcher/Sources/AVLiveLauncher/ProcessManager.swift +++ b/launcher/Sources/AVLiveLauncher/ProcessManager.swift @@ -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() + } } } diff --git a/sound_algo/web/public/control/app.js b/sound_algo/web/public/control/app.js index 7ad2fa4..e05c0ec 100644 --- a/sound_algo/web/public/control/app.js +++ b/sound_algo/web/public/control/app.js @@ -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 = + `
${pad.label}
` + + `
${pad.sub}
` + + `
${pad.key.toUpperCase()}
`; + 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 +})(); diff --git a/sound_algo/web/public/control/index.html b/sound_algo/web/public/control/index.html index 089803c..4150f47 100644 --- a/sound_algo/web/public/control/index.html +++ b/sound_algo/web/public/control/index.html @@ -35,7 +35,8 @@ sclang - + + @@ -79,6 +80,7 @@