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>
937 lines
31 KiB
JavaScript
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();
|
|
});
|
|
})();
|