const dom = { statusBanner: document.getElementById("statusBanner"), deviceSelect: document.getElementById("deviceSelect"), durationInput: document.getElementById("durationInput"), tagInput: document.getElementById("tagInput"), captureButton: document.getElementById("captureButton"), trainButton: document.getElementById("trainButton"), refreshButton: document.getElementById("refreshButton"), refreshPendingButton: document.getElementById("refreshPendingButton"), trainingOutput: document.getElementById("trainingOutput"), samplesList: document.getElementById("samplesList"), pendingList: document.getElementById("pendingList"), overnightHours: document.getElementById("overnightHours"), overnightClipSeconds: document.getElementById("overnightClipSeconds"), overnightIntervalSeconds: document.getElementById("overnightIntervalSeconds"), overnightAutoWatch: document.getElementById("overnightAutoWatch"), overnightStartButton: document.getElementById("overnightStartButton"), overnightStopButton: document.getElementById("overnightStopButton"), overnightOutput: document.getElementById("overnightOutput"), }; const LABELS = ["snore", "not_snore", "unclear"]; function ensureArray(value) { return Array.isArray(value) ? value : []; } function numberOr(defaultValue, rawValue) { const parsed = Number(rawValue); if (!Number.isFinite(parsed)) { return defaultValue; } return parsed; } function setStatus(message, kind = "ok") { if (!dom.statusBanner) { return; } dom.statusBanner.textContent = message; dom.statusBanner.classList.toggle("status-error", kind === "error"); dom.statusBanner.classList.toggle("status-ok", kind === "ok"); } async function api(path, options = {}) { const init = { ...options, headers: { ...(options.headers || {}), }, }; if (init.body !== undefined && !init.headers["Content-Type"]) { init.headers["Content-Type"] = "application/json"; } const response = await fetch(path, init); let payload = null; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { try { payload = await response.json(); } catch { payload = null; } } if (!response.ok) { const detail = payload && payload.detail ? payload.detail : `HTTP ${response.status}`; throw new Error(detail); } return payload; } function formatDate(isoTimestamp) { if (!isoTimestamp) { return "-"; } const date = new Date(isoTimestamp); return date.toLocaleString(); } function formatProposal(sample) { if (!sample || !sample.proposed_label) { return "-"; } if (typeof sample.proposed_confidence !== "number") { return String(sample.proposed_label); } const percent = (sample.proposed_confidence * 100).toFixed(1); return `${sample.proposed_label} (${percent}%)`; } function formatReviewState(sample) { if (!sample) { return "not reviewed"; } if (sample.review_state === "approved") { if (sample.proposed_label && sample.label && sample.proposed_label !== sample.label) { return "corrected + approved"; } return "approved for training"; } if (sample.review_state === "pending") { return "pending thumbs"; } if (sample.review_state === "rejected") { return "rejected"; } if (sample.review_state === "manual") { return sample.training_approved ? "manual approved" : "manual not used"; } return "not reviewed"; } function buildLabelEditor(sample) { const wrapper = document.createElement("div"); wrapper.className = "actions"; const select = document.createElement("select"); select.dataset.action = "label-select"; select.dataset.sampleId = sample.sample_id; for (const label of LABELS) { const option = document.createElement("option"); option.value = label; option.textContent = label; option.selected = (sample.label || "unclear") === label; select.appendChild(option); } const saveButton = document.createElement("button"); saveButton.type = "button"; saveButton.textContent = "Save"; saveButton.className = "btn"; saveButton.dataset.action = "save-label"; saveButton.dataset.sampleId = sample.sample_id; wrapper.append(select, saveButton); return wrapper; } function buildWatchButton(sample) { const wrapper = document.createElement("div"); wrapper.className = "actions"; const watchButton = document.createElement("button"); watchButton.type = "button"; watchButton.textContent = "Run Watch"; watchButton.className = "btn"; watchButton.dataset.action = "propose-watch"; watchButton.dataset.sampleId = sample.sample_id; const output = document.createElement("span"); output.textContent = formatReviewState(sample); output.style.fontFamily = "JetBrains Mono, monospace"; output.style.fontSize = "0.75rem"; wrapper.append(watchButton, output); return wrapper; } function renderSamples(samples) { if (!dom.samplesList) { return; } dom.samplesList.innerHTML = ""; if (!samples.length) { const empty = document.createElement("p"); empty.className = "empty-state"; empty.textContent = "No samples yet. Record your first sample above."; dom.samplesList.appendChild(empty); return; } for (const sample of samples) { const card = document.createElement("article"); card.className = "record-card sample-card"; const header = document.createElement("div"); header.className = "record-header"; const sampleId = document.createElement("code"); sampleId.textContent = String(sample.sample_id || "").slice(0, 8); const recorded = document.createElement("span"); recorded.className = "meta-text"; recorded.textContent = formatDate(sample.created_at); const tag = document.createElement("span"); tag.className = "chip"; tag.textContent = sample.tag || "untagged"; header.append(sampleId, recorded, tag); const media = document.createElement("div"); media.className = "record-media"; const listenCell = document.createElement("div"); const audio = document.createElement("audio"); audio.controls = true; audio.preload = "none"; audio.src = sample.audio_url; listenCell.appendChild(audio); const spectrogramCell = document.createElement("div"); const image = document.createElement("img"); image.className = "spectrogram"; image.src = sample.spectrogram_url; image.alt = `Spectrogram for ${sample.sample_id}`; spectrogramCell.appendChild(image); media.append(listenCell, spectrogramCell); const stats = document.createElement("div"); stats.className = "record-meta-row"; const proposal = document.createElement("span"); proposal.className = "meta-text"; proposal.textContent = `Proposal: ${formatProposal(sample)}`; const state = document.createElement("span"); state.className = "meta-text"; state.textContent = `State: ${formatReviewState(sample)}`; stats.append(proposal, state); const actions = document.createElement("div"); actions.className = "record-actions"; const labelEditor = buildLabelEditor(sample); const watchEditor = buildWatchButton(sample); actions.append(labelEditor, watchEditor); card.append(header, media, stats, actions); dom.samplesList.appendChild(card); } } function buildPendingReviewCard(sample) { const card = document.createElement("article"); card.className = "record-card pending-card"; const header = document.createElement("div"); header.className = "record-header"; const code = document.createElement("code"); code.textContent = String(sample.sample_id || "").slice(0, 8); const recorded = document.createElement("span"); recorded.className = "meta-text"; recorded.textContent = formatDate(sample.created_at); const proposal = document.createElement("span"); proposal.className = "chip proposal-chip"; proposal.textContent = formatProposal(sample); header.append(code, recorded, proposal); const media = document.createElement("div"); media.className = "record-media"; const listenCell = document.createElement("div"); const audio = document.createElement("audio"); audio.controls = true; audio.preload = "none"; audio.src = sample.audio_url; listenCell.appendChild(audio); const spectrogramCell = document.createElement("div"); const image = document.createElement("img"); image.className = "spectrogram"; image.src = sample.spectrogram_url; image.alt = `Spectrogram for ${sample.sample_id}`; spectrogramCell.appendChild(image); media.append(listenCell, spectrogramCell); const reviewCell = document.createElement("div"); reviewCell.className = "record-actions"; const actionGroup = document.createElement("div"); actionGroup.className = "actions"; const thumbsUp = document.createElement("button"); thumbsUp.type = "button"; thumbsUp.className = "btn success"; thumbsUp.textContent = "Thumbs Up"; thumbsUp.dataset.action = "pending-up"; thumbsUp.dataset.sampleId = sample.sample_id; const thumbsDown = document.createElement("button"); thumbsDown.type = "button"; thumbsDown.className = "btn danger"; thumbsDown.textContent = "Thumbs Down (Invert)"; thumbsDown.dataset.action = "pending-down"; thumbsDown.dataset.sampleId = sample.sample_id; actionGroup.append(thumbsUp, thumbsDown); reviewCell.appendChild(actionGroup); card.append(header, media, reviewCell); return card; } function renderPendingReviews(samples) { if (!dom.pendingList) { return; } dom.pendingList.innerHTML = ""; if (!samples.length) { const empty = document.createElement("p"); empty.className = "empty-state"; empty.textContent = "No pending watch proposals."; dom.pendingList.appendChild(empty); return; } for (const sample of samples) { dom.pendingList.appendChild(buildPendingReviewCard(sample)); } } function renderOvernightStatus(status) { if (!dom.overnightOutput || !dom.overnightStartButton || !dom.overnightStopButton) { return; } dom.overnightStartButton.disabled = Boolean(status && status.running); dom.overnightStopButton.disabled = !Boolean(status && status.running); if (!status || !status.session_id) { dom.overnightOutput.textContent = "No active overnight session."; return; } dom.overnightOutput.textContent = [ `running: ${status.running}`, `session: ${String(status.session_id).slice(0, 8)}`, `started: ${formatDate(status.started_at)}`, `planned_end: ${formatDate(status.planned_end_at)}`, `next_capture: ${formatDate(status.next_capture_at)}`, `captured_samples: ${status.captured_samples}`, `pending_reviews: ${status.pending_reviews}`, `auto_watch: ${status.auto_watch}`, `last_error: ${status.last_error || "-"}`, ].join("\n"); } async function loadDevices() { if (!dom.deviceSelect) { return; } try { const devices = ensureArray(await api("/api/audio/devices")); dom.deviceSelect.innerHTML = ""; if (!devices.length) { const fallback = document.createElement("option"); fallback.value = ""; fallback.textContent = "No input devices found"; dom.deviceSelect.appendChild(fallback); return; } for (const device of devices) { const option = document.createElement("option"); option.value = String(device.index); option.textContent = `#${device.index} ${device.name}`; dom.deviceSelect.appendChild(option); } } catch (error) { dom.deviceSelect.innerHTML = ""; const fallback = document.createElement("option"); fallback.value = ""; fallback.textContent = "Device lookup failed"; dom.deviceSelect.appendChild(fallback); setStatus(`Device load failed: ${error.message}`, "error"); } } async function loadSamples() { try { const samples = ensureArray(await api("/api/samples")); renderSamples(samples); } catch (error) { renderSamples([]); setStatus(`Samples load failed: ${error.message}`, "error"); } } async function loadPendingReviews() { try { const pending = ensureArray(await api("/api/watch/pending")); renderPendingReviews(pending); } catch (error) { renderPendingReviews([]); setStatus(`Pending queue load failed: ${error.message}`, "error"); } } async function loadOvernightStatus() { try { const status = await api("/api/overnight/status"); renderOvernightStatus(status); } catch (error) { renderOvernightStatus(null); setStatus(`Overnight status failed: ${error.message}`, "error"); } } async function refreshMainViews() { await Promise.allSettled([loadSamples(), loadPendingReviews(), loadOvernightStatus()]); } async function recordSample() { if (!dom.captureButton || !dom.durationInput || !dom.deviceSelect || !dom.tagInput) { return; } const payload = { duration_seconds: numberOr(10, dom.durationInput.value), device_index: dom.deviceSelect.value === "" ? null : Number(dom.deviceSelect.value), tag: dom.tagInput.value.trim() || null, }; dom.captureButton.disabled = true; setStatus("Recording in progress...", "ok"); try { await api("/api/samples/capture", { method: "POST", body: JSON.stringify(payload), }); dom.tagInput.value = ""; setStatus("Sample captured successfully", "ok"); await refreshMainViews(); } catch (error) { setStatus(`Recording failed: ${error.message}`, "error"); } finally { dom.captureButton.disabled = false; } } async function saveLabel(sampleId) { const select = document.querySelector( `select[data-action='label-select'][data-sample-id='${sampleId}']` ); if (!select) { return; } try { await api(`/api/samples/${sampleId}/label`, { method: "POST", body: JSON.stringify({ label: select.value }), }); setStatus(`Label updated for ${sampleId.slice(0, 8)}`, "ok"); await refreshMainViews(); } catch (error) { setStatus(`Label update failed: ${error.message}`, "error"); } } async function queueWatchProposal(sampleId) { try { await api(`/api/watch/${sampleId}/propose`, { method: "POST", }); setStatus(`Watch proposal queued for ${sampleId.slice(0, 8)}`, "ok"); await refreshMainViews(); } catch (error) { setStatus(`Watch proposal failed: ${error.message}`, "error"); } } async function reviewPending(sampleId, approve) { const endpoint = approve ? "thumbs-up" : "thumbs-down"; try { await api(`/api/watch/${sampleId}/${endpoint}`, { method: "POST", }); const actionText = approve ? "Thumbs up approved label" : "Thumbs down inverted label and approved"; setStatus(`${actionText} saved for ${sampleId.slice(0, 8)}`, "ok"); await refreshMainViews(); } catch (error) { setStatus(`Review action failed: ${error.message}`, "error"); } } async function trainModel() { if (!dom.trainButton || !dom.trainingOutput) { return; } dom.trainButton.disabled = true; dom.trainingOutput.textContent = "Training local model..."; try { const result = await api("/api/train", { method: "POST", body: JSON.stringify({ test_size: 0.25 }), }); const accuracyText = result && result.accuracy === null ? "n/a (dataset too small for holdout)" : Number(result.accuracy).toFixed(3); dom.trainingOutput.textContent = [ `model: ${result.model_path}`, `trained_samples: ${result.trained_samples}`, `classes: ${result.classes.join(", ")}`, `accuracy: ${accuracyText}`, ].join("\n"); setStatus("Training completed", "ok"); } catch (error) { dom.trainingOutput.textContent = `Training failed: ${error.message}`; setStatus(`Training failed: ${error.message}`, "error"); } finally { dom.trainButton.disabled = false; } } async function startOvernight() { if ( !dom.overnightStartButton || !dom.overnightHours || !dom.overnightClipSeconds || !dom.overnightIntervalSeconds || !dom.deviceSelect || !dom.overnightAutoWatch ) { return; } dom.overnightStartButton.disabled = true; const payload = { duration_hours: numberOr(8, dom.overnightHours.value), clip_duration_seconds: numberOr(20, dom.overnightClipSeconds.value), interval_seconds: numberOr(30, dom.overnightIntervalSeconds.value), device_index: dom.deviceSelect.value === "" ? null : Number(dom.deviceSelect.value), tag_prefix: "overnight", auto_watch: Boolean(dom.overnightAutoWatch.checked), }; try { await api("/api/overnight/start", { method: "POST", body: JSON.stringify(payload), }); setStatus("Overnight gathering started", "ok"); await refreshMainViews(); } catch (error) { setStatus(`Overnight start failed: ${error.message}`, "error"); } finally { dom.overnightStartButton.disabled = false; } } async function stopOvernight() { if (!dom.overnightStopButton) { return; } dom.overnightStopButton.disabled = true; try { await api("/api/overnight/stop", { method: "POST", }); setStatus("Overnight gathering stopped", "ok"); await refreshMainViews(); } catch (error) { setStatus(`Overnight stop failed: ${error.message}`, "error"); } finally { dom.overnightStopButton.disabled = false; } } function wireEventHandlers() { if (dom.samplesList) { dom.samplesList.addEventListener("click", async (event) => { const button = event.target.closest("button[data-action]"); if (!button) { return; } const action = button.dataset.action; const sampleId = button.dataset.sampleId; if (!sampleId) { return; } if (action === "save-label") { await saveLabel(sampleId); } if (action === "propose-watch") { await queueWatchProposal(sampleId); } }); } if (dom.pendingList) { dom.pendingList.addEventListener("click", async (event) => { const button = event.target.closest("button[data-action]"); if (!button) { return; } const action = button.dataset.action; const sampleId = button.dataset.sampleId; if (!sampleId) { return; } if (action === "pending-up") { await reviewPending(sampleId, true); } if (action === "pending-down") { await reviewPending(sampleId, false); } }); } if (dom.captureButton) { dom.captureButton.addEventListener("click", recordSample); } if (dom.trainButton) { dom.trainButton.addEventListener("click", trainModel); } if (dom.refreshButton) { dom.refreshButton.addEventListener("click", loadSamples); } if (dom.refreshPendingButton) { dom.refreshPendingButton.addEventListener("click", loadPendingReviews); } if (dom.overnightStartButton) { dom.overnightStartButton.addEventListener("click", startOvernight); } if (dom.overnightStopButton) { dom.overnightStopButton.addEventListener("click", stopOvernight); } } async function boot() { try { const health = await api("/api/health"); setStatus(`Server online at ${health.sample_rate}Hz / ${health.channels}ch`, "ok"); } catch (error) { setStatus(`Startup error: ${error.message}`, "error"); } await Promise.allSettled([ loadDevices(), loadSamples(), loadPendingReviews(), loadOvernightStatus(), ]); wireEventHandlers(); // Keep overnight status current without requiring manual refreshes. window.setInterval(() => { loadOvernightStatus().catch(() => { // Status errors are handled inside loadOvernightStatus. }); }, 15000); } boot();