Files
led-controller/src/static/audio.js
Jimmy ace5770b3a refactor(api): complete fastapi migration and related features
Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:55:28 +12:00

937 lines
31 KiB
JavaScript

(() => {
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<ReturnType<typeof setTimeout>>} */
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<string, unknown>} status */
function resolveBeatReadoutText(status) {
let text = String((status && status.beat_readout) || "").trim();
if (text) return text;
const seq = /** @type {Record<string, unknown>|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<string, unknown>} 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<string, unknown>|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<string, unknown>|undefined} */ (
status && status.sequence_pending
);
return !!(pending && pending.pending);
}
function resolveSeqUiActive(status) {
return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive;
}
/** @param {Record<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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();
});
})();