diff --git a/src/models/zone.py b/src/models/zone.py index 46f5bb2..173ffc4 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -134,7 +134,11 @@ class Zone(Model): id_str = str(id) if id_str not in self: return False - patch = data if isinstance(data, dict) else {} + patch = dict(data) if isinstance(data, dict) else {} + doc = self[id_str] + locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc) + if "content_kind" in patch: + patch["content_kind"] = locked_kind self[id_str].update(patch) if "content_kind" in patch: self._enforce_content_kind_invariants(self[id_str]) diff --git a/src/static/audio.js b/src/static/audio.js index e4d144c..f5be613 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -1,5 +1,6 @@ (() => { let pollTimer = null; + let audioDetectorRunning = false; let lastBeatSeq = 0; let lastLoggedSequenceBeatFractions = ""; /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ @@ -161,13 +162,37 @@ function updateSequenceSyncControls(zoneSeqActive) { const topSync = el("audio-top-beat-sync"); - if (topSync) topSync.disabled = !zoneSeqActive; + if (topSync) { + topSync.disabled = audioDetectorRunning && !zoneSeqActive; + topSync.title = !audioDetectorRunning + ? "Start beat detection" + : zoneSeqActive + ? "Sync step to music (S)" + : "Beat detection running"; + } const modalBeat = el("audio-modal-beat-readout"); if (modalBeat) modalBeat.disabled = !zoneSeqActive; const passBtn = el("audio-sync-pass-btn"); if (passBtn) passBtn.disabled = !zoneSeqActive; } + 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", @@ -250,6 +275,7 @@ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ async function stopAudioOnly() { + audioDetectorRunning = false; setTopBpmVisible(false); setNavResetVisible(false); clearBeatPhaseTimers(); @@ -285,6 +311,7 @@ node.textContent = String(status.error).trim().slice(0, 120); } updateBeatReadoutDisplays({}); + audioDetectorRunning = !!status.running; updateBpmDisplay(null); setTopBpmVisible(!!status.running); setNavResetVisible(!!status.running); @@ -294,6 +321,7 @@ } return; } + audioDetectorRunning = !!status.running; const zoneSeqActive = sequencePlaybackActiveFromStatus(status); setTopBpmVisible(!!status.running || zoneSeqActive); setNavResetVisible(!!status.running); @@ -482,7 +510,12 @@ } }); }; - bindSync(el("audio-top-beat-sync"), "step"); + const topBpm = el("audio-top-beat-sync"); + if (topBpm) { + topBpm.addEventListener("click", () => { + void handleTopBpmButtonClick(); + }); + } bindSync(el("audio-modal-beat-readout"), "step"); bindSync(el("audio-sync-pass-btn"), "pass"); @@ -501,11 +534,14 @@ const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); const status = data?.status || {}; + audioDetectorRunning = !!status.running; if (status.running && !pollTimer) { pollTimer = setInterval(pollStatus, 250); lastBeatSeq = Number(status.beat_seq || 0); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); await pollStatus(); + } else { + updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status)); } } catch (e) { console.warn("audio resume poll check failed", e); diff --git a/src/static/zones.js b/src/static/zones.js index ff366c1..2ab7f6d 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) { } /** - * Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only). - * Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``). + * Device names for one sequence step. Only devices in checked lane groups (within the zone tab). */ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { const zoneT = await computeZoneNamesTargets(zone); @@ -321,7 +320,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { ? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; if (!gids.length) { - return names.slice(); + return []; } const zoneMacSet = new Set( macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12), @@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) { return 'presets'; } -/** @returns {'presets' | 'sequences'} */ -function editModalContentKindSelected() { - const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked'); - return radio && radio.value === 'sequences' ? 'sequences' : 'presets'; -} - -function activeZoneContentKind(zoneDoc, zoneId) { - const modal = document.getElementById('edit-zone-modal'); - const editingId = document.getElementById('edit-zone-id')?.value; - if ( - modal && - modal.classList.contains('active') && - zoneId != null && - zoneId !== '' && - String(editingId) === String(zoneId) - ) { - return editModalContentKindSelected(); - } - return effectiveZoneContentKind(zoneDoc); -} - -/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */ -function zoneHasExplicitContentKind(zoneDoc) { - return normalizeZoneContentKind(zoneDoc) !== null; -} - /** @returns {boolean} */ function zoneAllowsPresets(zoneDoc, zoneId) { void zoneId; - if (!zoneHasExplicitContentKind(zoneDoc)) return true; return effectiveZoneContentKind(zoneDoc) === 'presets'; } /** @returns {boolean} */ function zoneAllowsSequences(zoneDoc, zoneId) { void zoneId; - if (!zoneHasExplicitContentKind(zoneDoc)) return true; return effectiveZoneContentKind(zoneDoc) === 'sequences'; } @@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) { for (const zoneId of tabOrder) { const zone = tabs[zoneId]; if (zone) { - const activeClass = zoneId === currentZoneId ? 'active' : ''; + const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : ''; const disp = zone.name || `Zone ${zoneId}`; html += `