feat(ui): lock zone type and start audio from BPM

Zone preset vs sequence is fixed at create; edit shows read-only type.
Header BPM button starts beat detection when the detector is stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-19 00:23:15 +12:00
parent b140aedf00
commit baec87068a
4 changed files with 63 additions and 59 deletions

View File

@@ -134,7 +134,11 @@ class Zone(Model):
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False 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) self[id_str].update(patch)
if "content_kind" in patch: if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str]) self._enforce_content_kind_invariants(self[id_str])

View File

@@ -1,5 +1,6 @@
(() => { (() => {
let pollTimer = null; let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0; let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = ""; let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
@@ -161,13 +162,37 @@
function updateSequenceSyncControls(zoneSeqActive) { function updateSequenceSyncControls(zoneSeqActive) {
const topSync = el("audio-top-beat-sync"); 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"); const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive; if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn"); const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive; 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) { async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", { const res = await fetch("/sequences/sync-phase", {
method: "POST", method: "POST",
@@ -250,6 +275,7 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() { async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false); setTopBpmVisible(false);
setNavResetVisible(false); setNavResetVisible(false);
clearBeatPhaseTimers(); clearBeatPhaseTimers();
@@ -285,6 +311,7 @@
node.textContent = String(status.error).trim().slice(0, 120); node.textContent = String(status.error).trim().slice(0, 120);
} }
updateBeatReadoutDisplays({}); updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null); updateBpmDisplay(null);
setTopBpmVisible(!!status.running); setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running); setNavResetVisible(!!status.running);
@@ -294,6 +321,7 @@
} }
return; return;
} }
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status); const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive); setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running); 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-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass"); bindSync(el("audio-sync-pass-btn"), "pass");
@@ -501,11 +534,14 @@
const res = await fetch("/api/audio/status", { cache: "no-store" }); const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
const status = data?.status || {}; const status = data?.status || {};
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) { if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0); lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus(); await pollStatus();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
} }
} catch (e) { } catch (e) {
console.warn("audio resume poll check failed", e); console.warn("audio resume poll check failed", e);

View File

@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
} }
/** /**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only). * Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
*/ */
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone); 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) ? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: []; : [];
if (!gids.length) { if (!gids.length) {
return names.slice(); return [];
} }
const zoneMacSet = new Set( const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12), macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) {
return 'presets'; 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} */ /** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) { function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId; void zoneId;
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
return effectiveZoneContentKind(zoneDoc) === 'presets'; return effectiveZoneContentKind(zoneDoc) === 'presets';
} }
/** @returns {boolean} */ /** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) { function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId; void zoneId;
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
return effectiveZoneContentKind(zoneDoc) === 'sequences'; return effectiveZoneContentKind(zoneDoc) === 'sequences';
} }
@@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) { for (const zoneId of tabOrder) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
const disp = zone.name || `Zone ${zoneId}`; const disp = zone.name || `Zone ${zoneId}`;
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
@@ -1183,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData); const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => { const typeLabel = document.getElementById('edit-zone-type-label');
radio.checked = radio.value === kind; if (typeLabel) {
}); typeLabel.textContent =
kind === 'sequences'
? 'Zone type: Sequences (set when the zone was created)'
: 'Zone type: Presets (set when the zone was created)';
}
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind); applyZoneContentKindEditModal(kind);
@@ -1196,13 +1171,11 @@ async function openEditZoneModal(zoneId, zone) {
} }
// Update an existing zone (name, group list; devices come from groups only). // Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows, contentKind) { async function updateZone(zoneId, name, groupRows) {
try { try {
const gids = Array.isArray(groupRows) const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
let existing = {}; let existing = {};
try { try {
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, { const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
@@ -1215,6 +1188,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
} catch (_) { } catch (_) {
/* use empty existing */ /* use empty existing */
} }
const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -1229,7 +1203,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
existing.preset_group_ids && typeof existing.preset_group_ids === 'object' existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids ? existing.preset_group_ids
: {}, : {},
content_kind: ck, content_kind: lockedKind,
}) })
}); });
@@ -1238,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
// Reload tabs list // Reload tabs list
await loadZonesModal(); await loadZonesModal();
await loadZones(); await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal // Close modal
document.getElementById('edit-zone-modal').classList.remove('active'); document.getElementById('edit-zone-modal').classList.remove('active');
return true; return true;
@@ -1369,18 +1346,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form // Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form'); const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) { if (editZoneForm) {
@@ -1394,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
const groupRows = window.__editTabGroupRows || []; const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) { if (zoneId && name) {
await updateZone(zoneId, name, groupRows, editModalContentKindSelected()); await updateZone(zoneId, name, groupRows);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1441,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
window.selectZone = selectZone;
// Export for use in other scripts // Export for use in other scripts
window.zonesManager = { window.zonesManager = {
loadZones, loadZones,

View File

@@ -119,10 +119,7 @@
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-id">
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<div class="zone-content-kind-row muted-text"> <p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="edit-zone-block-groups"> <div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div> <div id="edit-zone-groups-editor" class="zone-devices-editor"></div>