Files
SnoreStopper_v2/web/app.js
spencer 28012e70e0 feat: add storage and training modules for snore detection
- Implemented `storage.py` for managing metadata storage, including sample addition, retrieval, and review state management.
- Created `training.py` for training a local model using Random Forest, including functions for training and predicting samples.
- Developed a web interface in `app.js` for capturing audio samples, managing labels, and training the model.
- Added HTML structure in `index.html` for the SnoreStopper control room with sections for sample capture, overnight gathering, training, and status display.
- Styled the application with `styles.css` to enhance user experience and interface aesthetics.
2026-03-12 13:35:17 -04:00

690 lines
19 KiB
JavaScript

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();