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();
|
||||
Reference in New Issue
Block a user