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.
This commit is contained in:
689
web/app.js
Normal file
689
web/app.js
Normal file
@@ -0,0 +1,689 @@
|
||||
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();
|
||||
107
web/index.html
Normal file
107
web/index.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SnoreStopper Control Room</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/web/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient-backdrop" aria-hidden="true"></div>
|
||||
<main class="layout">
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Self-hosted sleep signal lab</p>
|
||||
<h1>SnoreStopper</h1>
|
||||
<p>
|
||||
Capture ambient sleep audio, label snoring events, and train a local model from your own
|
||||
nights.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="panel controls">
|
||||
<h2>Capture Sample</h2>
|
||||
<div class="row">
|
||||
<label for="deviceSelect">Input device</label>
|
||||
<select id="deviceSelect"></select>
|
||||
</div>
|
||||
<div class="row split">
|
||||
<div>
|
||||
<label for="durationInput">Duration (seconds)</label>
|
||||
<input id="durationInput" type="number" min="2" max="90" value="10" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tagInput">Tag</label>
|
||||
<input id="tagInput" type="text" maxlength="80" placeholder="night-1, side-sleep" />
|
||||
</div>
|
||||
</div>
|
||||
<button id="captureButton" class="btn primary">Record Sample</button>
|
||||
</section>
|
||||
|
||||
<section class="panel overnight">
|
||||
<h2>Overnight Gatherer</h2>
|
||||
<div class="row split overnight-grid">
|
||||
<div>
|
||||
<label for="overnightHours">Duration (hours)</label>
|
||||
<input id="overnightHours" type="number" min="1" max="12" value="8" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="overnightClipSeconds">Clip length (seconds)</label>
|
||||
<input id="overnightClipSeconds" type="number" min="2" max="90" value="20" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="overnightIntervalSeconds">Interval (seconds)</label>
|
||||
<input id="overnightIntervalSeconds" type="number" min="2" max="90" value="30" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-row" for="overnightAutoWatch">
|
||||
<input id="overnightAutoWatch" type="checkbox" checked />
|
||||
Run snore watch on each overnight clip
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="overnightStartButton" class="btn primary">Start Overnight</button>
|
||||
<button id="overnightStopButton" class="btn ghost">Stop</button>
|
||||
</div>
|
||||
<pre id="overnightOutput" class="output">No active overnight session.</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel training">
|
||||
<h2>Training</h2>
|
||||
<p>
|
||||
Label samples as <code>snore</code>, <code>not_snore</code>, or <code>unclear</code>, then
|
||||
train locally.
|
||||
</p>
|
||||
<button id="trainButton" class="btn">Train Model</button>
|
||||
<pre id="trainingOutput" class="output">No training run yet.</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel status">
|
||||
<h2>Status</h2>
|
||||
<p id="statusBanner" aria-live="polite">Loading...</p>
|
||||
</section>
|
||||
|
||||
<section class="panel pending">
|
||||
<div class="samples-header">
|
||||
<h2>Snore Watch Review Queue</h2>
|
||||
<button id="refreshPendingButton" class="btn ghost">Refresh Queue</button>
|
||||
</div>
|
||||
<div id="pendingList" class="pending-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel samples">
|
||||
<div class="samples-header">
|
||||
<h2>Samples</h2>
|
||||
<button id="refreshButton" class="btn ghost">Refresh</button>
|
||||
</div>
|
||||
<div id="samplesList" class="sample-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/web/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
383
web/styles.css
Normal file
383
web/styles.css
Normal file
@@ -0,0 +1,383 @@
|
||||
:root {
|
||||
--bg-a: #0b2238;
|
||||
--bg-b: #153a2e;
|
||||
--panel: rgba(255, 255, 255, 0.92);
|
||||
--ink: #112333;
|
||||
--ink-soft: #35516b;
|
||||
--accent: #0d8d72;
|
||||
--accent-strong: #096f5a;
|
||||
--outline: rgba(17, 35, 51, 0.16);
|
||||
--danger: #b14d40;
|
||||
--success: #198754;
|
||||
--success-strong: #146c43;
|
||||
--danger-strong: #9f2f24;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Chakra Petch", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 20% 10%, #245f7e 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 20%, #2c6f47 0%, transparent 50%),
|
||||
linear-gradient(145deg, var(--bg-a), var(--bg-b));
|
||||
}
|
||||
|
||||
.ambient-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1200px, 92vw);
|
||||
margin: 2rem auto 3rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
animation: fade-in 500ms ease;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 30px rgba(9, 20, 28, 0.16);
|
||||
}
|
||||
|
||||
.hero {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0.3rem 0 0.5rem;
|
||||
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.overnight {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.training {
|
||||
grid-column: span 8;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.samples {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pending {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.overnight-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.45rem 0 0.9rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.toggle-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: #ecf3f7;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #dfeaf1;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn.success {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.success:hover {
|
||||
background: var(--success-strong);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #c0392b;
|
||||
border-color: #c0392b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0.8rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--outline);
|
||||
background: #f4f8fb;
|
||||
color: #1b384f;
|
||||
min-height: 72px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.samples-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.sample-list,
|
||||
.pending-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.record-header code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.78rem;
|
||||
background: #eff5f9;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 999px;
|
||||
background: #f3f8fb;
|
||||
color: #23475f;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.proposal-chip {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.record-media {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 230px) minmax(180px, 240px);
|
||||
gap: 0.8rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.record-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-actions .actions {
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: 10px;
|
||||
background: #fbfdff;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
img.spectrogram {
|
||||
width: min(220px, 100%);
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--outline);
|
||||
}
|
||||
|
||||
audio {
|
||||
width: min(230px, 100%);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
border: 1px dashed var(--outline);
|
||||
border-radius: 10px;
|
||||
background: #f8fbfd;
|
||||
color: var(--ink-soft);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.controls,
|
||||
.overnight,
|
||||
.training,
|
||||
.status {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overnight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.record-media {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
img.spectrogram,
|
||||
audio {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user