(() => { let beatEventSource = null; let beatEventsReconnectTimer = null; let audioDetectorRunning = false; let lastBeatSeq = 0; let lastSimulatedBeatTick = 0; /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ let prevZoneSequencePlaybackActive = false; /** * After sequence playback ends/stops while audio keeps running, keep header # idle until the * next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout). */ let headerBeatStickyIdleAfterSeq = false; /** @type {Set>} */ const pendingBeatPhaseTimers = new Set(); let cachedBeatPhaseMs = 0; /** @type {{ device: string|number|null, device_override: string, device_select: string }} */ let cachedAudioRun = { device: null, device_override: "", device_select: "" }; /** True after client starts sequence playback until server reports stop. */ let clientSequenceUiActive = false; /** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */ let stickySequenceBeatReadout = ""; function el(id) { return document.getElementById(id); } /** @param {Record} status */ function resolveBeatReadoutText(status) { let text = String((status && status.beat_readout) || "").trim(); if (text) return text; const seq = /** @type {Record|undefined} */ ( status && status.sequence ); if (seq && seq.active) { text = String(seq.beat_readout || "").trim(); if (text) return text; } if (stickySequenceBeatReadout) { return stickySequenceBeatReadout; } return ""; } /** @param {Record} status */ function updateBeatReadoutDisplays(status) { const text = resolveBeatReadoutText(status); for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) { const n = el(id); if (n) n.textContent = text; } } function updateBpmDisplay(bpm, simulated = false) { const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--"; for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) { const node = el(id); if (node) node.textContent = text; } for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) { const node = el(id); if (node) node.classList.toggle("audio-simulated", !!simulated); } } /** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */ function sequencePlaybackActiveFromStatus(status) { const seq = /** @type {Record|undefined} */ ( status && status.sequence ); return !!(seq && seq.active); } /** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */ function sequenceBeatUiActiveFromStatus(status) { if (sequencePlaybackActiveFromStatus(status)) return true; const pending = /** @type {Record|undefined} */ ( status && status.sequence_pending ); return !!(pending && pending.pending); } function resolveSeqUiActive(status) { return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive; } /** @param {Record} status */ function updateTopIndicatorFromStatus(status) { const running = !!(status && status.running); const bpmSimulated = !!(status && status.bpm_simulated); const seqUiActive = resolveSeqUiActive(status); const show = running || seqUiActive || bpmSimulated; setTopBpmVisible(show); if (!show || running) return; const simBpm = status && status.audio_simulated_bpm != null ? Number(status.audio_simulated_bpm) : getSimulatedBpmPercent(); updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true); } /** @param {Record} status */ function shouldKeepStatusPolling(status) { return ( !!(status && status.running) || resolveSeqUiActive(status) || !!(status && status.bpm_simulated) ); } function updateHitTypeDisplay(hitType, confidence) { const node = el("audio-hit-type-value"); if (!node) return; const label = String(hitType || "unknown").toLowerCase(); const conf = Number.isFinite(confidence) ? ` (${confidence.toFixed(2)})` : ""; node.textContent = `${label}${conf}`; } /** @param {Record} status */ function updateBarPhaseDisplay(status) { const readout = String((status && status.bar_phase_readout) || "").trim(); const phaseConf = Number((status && status.phase_confidence) || 0); const downbeat = !!(status && status.is_downbeat); const simulated = !!(status && status.bpm_simulated); const showPhase = !!(status && status.running) || simulated; let text = readout || "--"; if (readout && Number.isFinite(phaseConf) && phaseConf > 0) { text = `${text} (${Math.round(phaseConf * 100)}%)`; } for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) { const node = el(id); if (!node) continue; node.textContent = showPhase ? text : ""; node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase); } } function setTopBpmVisible(on) { const top = el("audio-top-indicator"); if (!top) return; top.classList.toggle("audio-running", !!on); } function closeBeatEvents() { if (beatEventsReconnectTimer != null) { clearTimeout(beatEventsReconnectTimer); beatEventsReconnectTimer = null; } if (beatEventSource) { beatEventSource.close(); beatEventSource = null; } } function scheduleBeatEventsReconnect() { if (beatEventsReconnectTimer != null) return; beatEventsReconnectTimer = setTimeout(() => { beatEventsReconnectTimer = null; void fetchAudioStatusOnce() .then((status) => { applyAudioStatus(status); if (shouldKeepStatusPolling(status)) ensureBeatEvents(); }) .catch((e) => { console.warn("audio status reconnect fetch failed", e); }); }, 2000); } function ensureBeatEvents() { if (beatEventSource) return; const es = new EventSource("/api/audio/events"); beatEventSource = es; es.onmessage = (ev) => { try { const data = JSON.parse(String(ev.data || "")); if (data && data.status) applyAudioStatus(data.status); } catch (e) { console.warn("audio beat event parse failed", e); } }; es.onerror = () => { closeBeatEvents(); scheduleBeatEventsReconnect(); }; } function setResetDetectorEnabled(on) { const btn = el("audio-reset-btn"); if (btn) btn.disabled = !on; } async function resetAudioTracking() { try { const res = await fetch("/api/audio/reset", { method: "POST", headers: { Accept: "application/json" }, }); if (!res.ok) { const data = await res.json().catch(() => ({})); console.warn("audio reset failed", data.error || res.status); return; } await pollStatus(); } catch (e) { console.warn("audio reset failed", e); } } function beatSyncButtonTitle(zoneSeqActive) { if (!audioDetectorRunning) return "Start beat detection"; if (zoneSeqActive) return "Sync step to music (S)"; return "Beat detection running"; } function updateSequenceSyncControls(zoneSeqActive) { const disabled = audioDetectorRunning && !zoneSeqActive; const title = beatSyncButtonTitle(zoneSeqActive); for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) { const btn = el(id); if (!btn) continue; btn.disabled = disabled; btn.title = title; } } async function handleTopBpmButtonClick() { if (!audioDetectorRunning) { try { await startAudio(); } catch (e) { console.error("audio start failed", e); alert("Failed to start audio input. Check mic permissions."); } return; } try { await syncSequenceBeatPhase("step"); } catch (e) { console.warn("sequence beat sync failed", e); } } async function syncSequenceBeatPhase(mode) { const res = await fetch("/sequences/sync-phase", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ mode: mode || "step" }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `Sync failed (${res.status})`); } await pollStatus(); } function isTypingTarget(target) { if (!target || typeof target !== "object") return false; const tag = String(target.tagName || "").toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; } function flashBeatSyncButton(btn, simulated = false) { if (!btn) return; btn.classList.add(simulated ? "flash-simulated" : "flash"); setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90); } function flashBeat(simulated = false) { const top = el("audio-top-indicator"); const topSync = el("audio-top-beat-sync"); if ( topSync && top && (top.classList.contains("audio-running") || simulated) ) { flashBeatSyncButton(topSync, simulated); } const modalSync = el("audio-modal-beat-sync"); if (modalSync && (audioDetectorRunning || simulated)) { flashBeatSyncButton(modalSync, simulated); } } function gainPercentToDb(pct) { const gain = Math.max(0.001, pct / 100); return 20 * Math.log10(gain); } function formatGainReadout(pct) { const db = gainPercentToDb(pct); const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2); return `${pct}% (${dbText} dB)`; } function updateInputLevelDisplay(level) { const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0; const bar = el("audio-input-level-bar"); const meter = el("audio-modal")?.querySelector(".audio-input-level-meter"); if (bar) bar.style.width = `${pct}%`; if (meter) meter.setAttribute("aria-valuenow", String(pct)); } function clearBeatPhaseTimers() { pendingBeatPhaseTimers.forEach((t) => clearTimeout(t)); pendingBeatPhaseTimers.clear(); } function getBeatPhaseDelayMs() { return Math.min(500, Math.max(0, cachedBeatPhaseMs)); } function getInputVolumePercent() { const inp = el("audio-input-volume"); if (!inp) return 100; const n = parseInt(String(inp.value).trim(), 10); if (!Number.isFinite(n)) return 100; return Math.min(200, Math.max(0, n)); } function updateInputVolumeReadout() { const readout = el("audio-input-volume-readout"); const slider = el("audio-input-volume"); const pct = getInputVolumePercent(); if (readout) readout.textContent = formatGainReadout(pct); if (slider) { slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`); } } const SIMULATED_BPM_MIN = 60; const SIMULATED_BPM_MAX = 200; function clampSimulatedBpm(n) { if (!Number.isFinite(n)) return 120; return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n))); } function clampLiveBpm(n) { if (!Number.isFinite(n)) return null; return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n)); } function getSimulatedBpmPercent() { const inp = el("audio-simulated-bpm"); if (!inp) return 120; return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10)); } async function persistSimulatedBpm() { const bpm = getSimulatedBpmPercent(); try { await fetch("/settings", { method: "PUT", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ audio_simulated_bpm: bpm }), }); } catch (e) { console.warn("simulated bpm save failed", e); } } async function persistInputVolume() { const vol = getInputVolumePercent(); updateInputVolumeReadout(); try { await fetch("/settings", { method: "PUT", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ audio_input_volume: vol }), }); } catch (e) { console.warn("input volume save failed", e); } } function scheduleBeatPhaseFire(seq, delayMs, simulated = false) { let tid = null; const run = () => { if (tid != null) pendingBeatPhaseTimers.delete(tid); flashBeat(simulated); try { window.dispatchEvent( new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }), ); } catch (e) { /* ignore */ } }; if (delayMs <= 0) { run(); return; } tid = setTimeout(run, delayMs); pendingBeatPhaseTimers.add(tid); } /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ async function stopAudioOnly() { audioDetectorRunning = false; setResetDetectorEnabled(false); clearBeatPhaseTimers(); lastBeatSeq = 0; lastSimulatedBeatTick = 0; prevZoneSequencePlaybackActive = false; headerBeatStickyIdleAfterSeq = false; updateBeatReadoutDisplays({}); updateInputLevelDisplay(0); setTopBpmVisible(true); updateBpmDisplay(getSimulatedBpmPercent(), true); try { await fetch("/api/audio/stop", { method: "POST" }); } catch (e) { console.warn("audio stop failed", e); } ensureBeatEvents(); await pollStatus(); } /** User-initiated stop (run intent cleared on server). */ async function stopAudio() { await stopAudioOnly(); } async function fetchAudioStatusOnce() { const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); return data?.status || {}; } async function pollStatus() { try { const status = await fetchAudioStatusOnce(); applyAudioStatus(status); } catch (e) { console.warn("audio status fetch failed", e); } } /** @param {Record} status */ function applyAudioStatus(status) { try { if (status.error && String(status.error).trim()) { const node = el("audio-hit-type-value"); if (node) { node.textContent = String(status.error).trim().slice(0, 120); } updateBeatReadoutDisplays({}); audioDetectorRunning = !!status.running; updateInputLevelDisplay(0); updateTopIndicatorFromStatus(status); setResetDetectorEnabled(!!status.running); if (!shouldKeepStatusPolling(status)) closeBeatEvents(); return; } audioDetectorRunning = !!status.running; const zoneSeqActive = sequencePlaybackActiveFromStatus(status); const seqUiActive = resolveSeqUiActive(status); const bpmSimulated = !!status.bpm_simulated; if (sequenceBeatUiActiveFromStatus(status)) { clientSequenceUiActive = false; } updateTopIndicatorFromStatus(status); setResetDetectorEnabled(!!status.running); updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive); const displayBpm = bpmSimulated && status.audio_simulated_bpm != null ? clampSimulatedBpm(Number(status.audio_simulated_bpm)) : status.bpm != null ? clampLiveBpm(Number(status.bpm)) : null; updateBpmDisplay( Number.isFinite(displayBpm) ? displayBpm : null, bpmSimulated, ); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); updateBarPhaseDisplay(status); updateInputLevelDisplay( status.running ? Number(status.input_level) : 0, ); applyServerAudioUiFields(status); if (typeof window.setSequenceSwitchSimulatedMode === "function") { window.setSequenceSwitchSimulatedMode(bpmSimulated); } if (typeof window.applySequenceSwitchWaitFromServer === "function") { window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait); } /* * `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle * after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` / * `sequence` on each poll. */ const beatSeq = Number(status.beat_seq || 0); const simTick = Number(status.simulated_beat_tick || 0); const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive; const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive; prevZoneSequencePlaybackActive = zoneSeqActive; if (startedSeq) { headerBeatStickyIdleAfterSeq = false; stickySequenceBeatReadout = ""; if (bpmSimulated) { lastSimulatedBeatTick = Math.max(0, simTick - 1); } } if (zoneSeqActive) { const liveReadout = String((status.beat_readout || "") || "").trim() || String((status.sequence && status.sequence.beat_readout) || "").trim(); if (liveReadout) { stickySequenceBeatReadout = liveReadout; } } if (endedSeq) { clientSequenceUiActive = false; headerBeatStickyIdleAfterSeq = true; clearBeatPhaseTimers(); lastBeatSeq = beatSeq; lastSimulatedBeatTick = simTick; if (!stickySequenceBeatReadout) { const tail = String((status.beat_readout || "") || "").trim(); if (tail) stickySequenceBeatReadout = tail; } } if (bpmSimulated && simTick > lastSimulatedBeatTick) { lastSimulatedBeatTick = simTick; scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true); } else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) { if (beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false); headerBeatStickyIdleAfterSeq = false; } } else if (!bpmSimulated && beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false); } updateBeatReadoutDisplays(status); if (shouldKeepStatusPolling(status)) { ensureBeatEvents(); } else { closeBeatEvents(); } } catch (e) { console.warn("audio status apply failed", e); } } /** Ignore server device sync briefly after the user picks from the dropdown. */ let deviceSelectLockUntil = 0; /** Suppress change handler while rebuilding or programmatically setting the select. */ let suppressDeviceSelectEvents = false; /** Last explicit UI choice (dropdown); not overwritten by server poll. */ let uiDeviceSelectId = ""; function lockDeviceSelect(ms = 10000) { deviceSelectLockUntil = Date.now() + ms; } function preferredSavedDeviceId() { return cachedAudioRun.device_select ? String(cachedAudioRun.device_select) : ""; } function optionIdForSavedDevice(select, savedId) { const saved = savedId == null ? "" : String(savedId); if (!saved || !select) return ""; if (selectHasDeviceOptionId(select, saved)) return saved; if (!/^-?\d+$/.test(saved)) return ""; for (const opt of select.options) { if (String(opt.dataset.sdIndex ?? "") === saved) return opt.value; } return ""; } function restoreDeviceSelectAfterRefresh(select, defaultId, restoreId = "") { const picked = restoreId || getSelectedDeviceId(); if (picked && selectHasDeviceOptionId(select, picked)) { setSelectedDeviceId(picked); return; } const saved = preferredSavedDeviceId(); const savedId = optionIdForSavedDevice(select, saved) || saved; if (savedId && selectHasDeviceOptionId(select, savedId)) { setSelectedDeviceId(savedId); return; } if (defaultId && selectHasDeviceOptionId(select, defaultId)) { setSelectedDeviceId(defaultId); return; } setSelectedDeviceId(""); } function getSelectedDeviceId() { return String(el("audio-device-select")?.value ?? ""); } function selectHasDeviceOptionId(select, deviceId) { const id = deviceId == null ? "" : String(deviceId); return [...select.options].some((opt) => opt.value === id); } function audioRunPreferredDeviceId(run) { return run.device_select ? String(run.device_select) : ""; } function setSelectedDeviceId(deviceId, { force = false } = {}) { const id = deviceId == null ? "" : String(deviceId); const select = el("audio-device-select"); if (!select) return false; if (id !== "" && !selectHasDeviceOptionId(select, id)) { if (!force) return false; } suppressDeviceSelectEvents = true; try { select.value = id; uiDeviceSelectId = id; } finally { suppressDeviceSelectEvents = false; } return true; } function readDeviceForm() { return { override: "", selected: getSelectedDeviceId() }; } async function persistDeviceSelection(deviceId) { const selected = deviceId != null ? String(deviceId) : getSelectedDeviceId(); uiDeviceSelectId = selected; cachedAudioRun.device_select = selected; try { const res = await fetch("/api/audio/device", { method: "PUT", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ device_select: selected, device_override: "" }), }); const data = await res.json().catch(() => ({})); if (data?.audio_run && typeof data.audio_run === "object") { const saved = data.audio_run.device_select ? String(data.audio_run.device_select) : ""; if (saved === selected) { cachedAudioRun.device_select = saved; } } } catch (e) { console.warn("device selection save failed", e); } } async function startAudio(deviceId) { const selected = deviceId != null && deviceId !== undefined ? String(deviceId) : uiDeviceSelectId || getSelectedDeviceId(); lockDeviceSelect(); uiDeviceSelectId = selected; cachedAudioRun.device_select = selected; await stopAudioOnly(); await persistDeviceSelection(selected); const rawDevice = selected; const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice; const body = { device: rawDevice === "" ? null : numeric, device_override: "", device_select: selected, }; const res = await fetch("/api/audio/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to start audio detector"); } cachedAudioRun.device_select = selected; setSelectedDeviceId(selected); updateBpmDisplay(null); updateHitTypeDisplay("unknown", NaN); ensureBeatEvents(); await pollStatus(); } async function refreshDevices() { const select = el("audio-device-select"); if (!select) return; const res = await fetch("/api/audio/devices"); const data = await res.json(); // Re-read after fetch so a pick during the request is not overwritten by a stale value. const restoreId = getSelectedDeviceId(); const inputs = Array.isArray(data?.devices) ? data.devices.slice() : []; select.innerHTML = ""; const defaultOpt = document.createElement("option"); defaultOpt.value = ""; defaultOpt.textContent = "System default input"; select.appendChild(defaultOpt); let defaultId = ""; inputs.forEach((d, idx) => { const opt = document.createElement("option"); opt.value = String(d.id); const text = d.display_name || d.name || `Input ${idx + 1}`; opt.textContent = text; const title = d.label || d.name || ""; if (title && title !== text) opt.title = title; if (d.sounddevice_index != null && d.sounddevice_index !== "") { opt.dataset.sdIndex = String(d.sounddevice_index); } select.appendChild(opt); if (d.is_default) defaultId = String(d.id); }); suppressDeviceSelectEvents = true; try { restoreDeviceSelectAfterRefresh(select, defaultId, restoreId); } finally { suppressDeviceSelectEvents = false; } } function bind() { const modal = el("audio-modal"); const openBtn = el("audio-btn"); const closeBtn = el("audio-close-btn"); const startBtn = el("audio-start-btn"); const stopBtn = el("audio-stop-btn"); const resetBtn = el("audio-reset-btn"); const refreshBtn = el("audio-refresh-btn"); if (!modal || !openBtn) return; openBtn.addEventListener("click", async () => { modal.classList.add("active"); try { await refreshDevices(); } catch (e) { console.warn("audio device refresh failed", e); } await loadServerAudioUiFields(); setResetDetectorEnabled(audioDetectorRunning); }); if (closeBtn) { closeBtn.addEventListener("click", () => { modal.classList.remove("active"); }); } if (startBtn) { startBtn.addEventListener("click", async () => { const picked = getSelectedDeviceId(); try { await startAudio(picked); } catch (e) { console.error("audio start failed", e); alert("Failed to start audio input. Check mic permissions."); } }); } if (stopBtn) { stopBtn.addEventListener("click", async () => { await stopAudio(); }); } if (resetBtn) { resetBtn.addEventListener("click", () => resetAudioTracking()); } if (refreshBtn) { refreshBtn.addEventListener("click", async () => { try { await refreshDevices(); } catch (e) { console.error("refresh devices failed", e); } }); } const deviceSelect = el("audio-device-select"); if (deviceSelect) { deviceSelect.addEventListener("change", async () => { if (suppressDeviceSelectEvents) return; const picked = getSelectedDeviceId(); uiDeviceSelectId = picked; lockDeviceSelect(); cachedAudioRun.device_select = picked; await persistDeviceSelection(picked); }); } const volInp = el("audio-input-volume"); if (volInp) { volInp.addEventListener("input", () => { updateInputVolumeReadout(); void persistInputVolume(); }); volInp.addEventListener("change", () => { updateInputVolumeReadout(); void persistInputVolume(); }); updateInputVolumeReadout(); } const simBpmInp = el("audio-simulated-bpm"); if (simBpmInp) { const onSimBpmChange = () => { void persistSimulatedBpm(); if (!audioDetectorRunning) { updateBpmDisplay(getSimulatedBpmPercent(), true); } }; simBpmInp.addEventListener("input", onSimBpmChange); simBpmInp.addEventListener("change", onSimBpmChange); } for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) { const btn = el(id); if (btn) { btn.addEventListener("click", () => { void handleTopBpmButtonClick(); }); } } document.addEventListener("keydown", (ev) => { if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return; const k = String(ev.key || "").toLowerCase(); if (k !== "s") return; ev.preventDefault(); const mode = ev.shiftKey ? "pass" : "step"; void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e)); }); } async function resumeBeatEventsIfNeeded() { try { const status = await fetchAudioStatusOnce(); audioDetectorRunning = !!status.running; updateTopIndicatorFromStatus(status); if (shouldKeepStatusPolling(status)) { lastBeatSeq = Number(status.beat_seq || 0); lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); applyAudioStatus(status); ensureBeatEvents(); } else { updateSequenceSyncControls( sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive, ); } } catch (e) { console.warn("audio resume status check failed", e); } } /** Apply server-owned audio UI fields from status (volume; device dropdown is user-owned). */ function applyServerAudioUiFields(status) { if (!status || typeof status !== "object") return; const run = status.audio_run; if (run && typeof run === "object") { cachedAudioRun = { device: run.device ?? null, device_override: run.device_override != null ? String(run.device_override) : "", device_select: run.device_select ? String(run.device_select) : "", }; } if (status.beat_phase_ms != null) { const ms = parseInt(String(status.beat_phase_ms), 10); if (Number.isFinite(ms)) { cachedBeatPhaseMs = Math.min(500, Math.max(0, ms)); } } const volInp = el("audio-input-volume"); if ( volInp && status.input_volume != null && document.activeElement !== volInp ) { const vol = parseInt(String(status.input_volume), 10); if (Number.isFinite(vol)) { volInp.value = String(Math.min(200, Math.max(0, vol))); updateInputVolumeReadout(); } } const simBpmInp = el("audio-simulated-bpm"); if ( simBpmInp && status.audio_simulated_bpm != null && document.activeElement !== simBpmInp ) { const bpm = parseInt(String(status.audio_simulated_bpm), 10); if (Number.isFinite(bpm)) { simBpmInp.value = String(clampSimulatedBpm(bpm)); } } } async function loadServerAudioUiFields() { try { const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); const status = data?.status || {}; applyServerAudioUiFields(status); const select = el("audio-device-select"); const saved = audioRunPreferredDeviceId(status.audio_run || {}); if (select && saved && selectHasDeviceOptionId(select, saved)) { uiDeviceSelectId = saved; setSelectedDeviceId(saved); } updateInputLevelDisplay(status.running ? Number(status.input_level) : 0); updateTopIndicatorFromStatus(status); if (typeof window.setSequenceSwitchSimulatedMode === "function") { window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated); } if (!status.running) { lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0); applyAudioStatus(status); } } catch (e) { console.warn("audio status load failed", e); } } /** Called from sequences.js when server playback starts/stops. */ window.ledControllerSequencePlaybackChanged = (active) => { clientSequenceUiActive = !!active; updateSequenceSyncControls(!!active); if (active) { setTopBpmVisible(true); if (!audioDetectorRunning) { updateBpmDisplay(getSimulatedBpmPercent(), true); } ensureBeatEvents(); void pollStatus(); return; } void pollStatus(); }; document.addEventListener("DOMContentLoaded", async () => { bind(); await loadServerAudioUiFields(); await resumeBeatEventsIfNeeded(); }); })();