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:
2026-03-12 13:35:17 -04:00
commit 28012e70e0
21 changed files with 2680 additions and 0 deletions

689
web/app.js Normal file
View 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
View 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
View 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%;
}
}