Initial commit

This commit is contained in:
2026-09-26 11:34:15 +02:00
commit 9d0c7777a5
13 changed files with 716 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Optional local whisper.cpp server endpoint. Leave empty to store recordings
# without automatic transcription.
WHISPER_SERVER_URL=
+7
View File
@@ -0,0 +1,7 @@
# Voice KB project notes
- Keep audio, transcripts, and indexes on infrastructure controlled by the user.
- Never add a cloud speech, LLM, or geocoding dependency.
- Preserve original audio and raw transcripts when adding correction workflows.
- PWA uploads must remain queued until the API acknowledges durable storage.
- Treat iOS background execution and browser storage as best-effort; communicate limits in the UI/docs.
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.py[cod]
.env
data/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /srv/voice-kb
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY frontend ./frontend
ENV DATA_DIR=/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+43
View File
@@ -0,0 +1,43 @@
# Voice KB
A self-hosted voice-note inbox for an iPhone-installed web app. Recordings are saved in the browser first, uploaded when connectivity is available, and stored with transcripts on your own server.
## Run locally
```sh
docker compose up --build
```
Open `http://localhost:8000` for a desktop smoke test. For iPhone use, serve the app over HTTPS through your VPN/reverse proxy. iOS only grants microphone access in a secure context. Keep the service reachable only through your VPN and firewall; this first version has no login layer.
In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen**. Sign in to the VPN before opening the app if the server is only reachable there.
## Capture and offline behavior
- Recording data and its timestamp are committed to IndexedDB before upload is attempted.
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
- Service worker caching keeps the app shell available offline. Searching the server-side archive requires a connection.
- iOS may suspend a PWA and can evict website data under storage pressure. Background uploads are not guaranteed; reopen the PWA while online to resume. Keep the phone powered and avoid clearing Safari website data for stronger practical retention.
## Local speech-to-text
The API can use a local `whisper.cpp` server when `WHISPER_SERVER_URL` is configured. Audio is sent only to that local endpoint; with the variable unset, uploads remain stored as `awaiting_transcription` and can still be searched after a transcript is edited or populated.
For NVIDIA GPU transcription, run a CUDA-enabled `whisper.cpp` server with a model on the same machine, then set `WHISPER_SERVER_URL` to its local inference endpoint, for example `http://whisper:8080/inference`. Use a model that fits the RTX 3070's VRAM; start with `small` or a quantized `medium` model and measure with your audio/language. Do not expose the transcription server outside the private Docker network. See the [whisper.cpp NVIDIA instructions](https://github.com/ggml-org/whisper.cpp#nvidia-gpu-support) for CUDA builds.
This version keeps raw audio and transcript separate. A future local correction model can propose punctuation and name fixes without replacing the original transcription. Proper-name matching and offline geolocation data are not enabled yet.
## Data and backups
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and original audio in `./data/audio/`. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
## Development
```sh
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
The web app is served by FastAPI at the root URL. SQLite FTS5 indexes transcript text for plain full-text search.
+168
View File
@@ -0,0 +1,168 @@
import os
import re
import sqlite3
import uuid
from datetime import datetime, timezone
from pathlib import Path
import httpx
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
AUDIO_DIR = DATA_DIR / "audio"
DB_PATH = DATA_DIR / "voice-kb.sqlite3"
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
DATA_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Voice KB")
def connect_db() -> sqlite3.Connection:
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
return connection
def initialize_db() -> None:
with connect_db() as connection:
connection.execute(
"""CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
filename TEXT NOT NULL,
audio_path TEXT NOT NULL,
transcript TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL
)"""
)
connection.execute(
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
USING fts5(note_id UNINDEXED, transcript)"""
)
initialize_db()
def update_transcript(note_id: str, transcript: str, status: str) -> None:
with connect_db() as connection:
connection.execute(
"UPDATE notes SET transcript = ?, status = ? WHERE id = ?",
(transcript, status, note_id),
)
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
if transcript.strip():
connection.execute(
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
(note_id, transcript.strip()),
)
async def transcribe_note(note_id: str, audio_path: Path) -> None:
if not WHISPER_SERVER_URL:
return
try:
async with httpx.AsyncClient(timeout=900) as client:
with audio_path.open("rb") as audio_file:
response = await client.post(
WHISPER_SERVER_URL,
files={"file": (audio_path.name, audio_file, "application/octet-stream")},
data={"response_format": "json"},
)
response.raise_for_status()
transcript = response.json().get("text", "").strip()
update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript")
except Exception:
update_transcript(note_id, "", "transcription_failed")
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/notes", status_code=201)
async def create_note(
background_tasks: BackgroundTasks,
recording_id: str = Form(...),
audio: UploadFile = File(...),
created_at: str = Form(...),
) -> dict[str, str]:
try:
note_id = str(uuid.UUID(recording_id))
except ValueError as error:
raise HTTPException(status_code=400, detail="Invalid recording ID") from error
with connect_db() as connection:
existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
if existing:
return {"id": note_id, "status": existing["status"]}
suffix = Path(audio.filename or "recording.webm").suffix.lower()
if not suffix or len(suffix) > 10:
suffix = ".audio"
audio_path = AUDIO_DIR / f"{note_id}{suffix}"
try:
with audio_path.open("wb") as destination:
while chunk := await audio.read(1024 * 1024):
destination.write(chunk)
except Exception as error:
audio_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Could not store audio") from error
finally:
await audio.close()
try:
parsed_date = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
created_at = parsed_date.astimezone(timezone.utc).isoformat()
except ValueError:
created_at = datetime.now(timezone.utc).isoformat()
status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing"
with connect_db() as connection:
connection.execute(
"INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)",
(note_id, created_at, audio.filename or "recording.audio", str(audio_path), status),
)
if WHISPER_SERVER_URL:
background_tasks.add_task(transcribe_note, note_id, audio_path)
return {"id": note_id, "status": status}
@app.get("/api/notes")
def list_notes(q: str = "") -> list[dict[str, str]]:
with connect_db() as connection:
search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE)
if search_terms:
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
(query,),
).fetchall()
else:
rows = connection.execute(
"""SELECT id, created_at, filename, transcript, status
FROM notes ORDER BY created_at DESC LIMIT 100"""
).fetchall()
return [dict(row) for row in rows]
@app.put("/api/notes/{note_id}/transcript")
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
transcript = payload.get("transcript", "").strip()
with connect_db() as connection:
exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Note not found")
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
+11
View File
@@ -0,0 +1,11 @@
services:
voice-kb:
build: .
ports:
- "8000:8000"
environment:
DATA_DIR: /data
WHISPER_SERVER_URL: ${WHISPER_SERVER_URL:-}
volumes:
- ./data:/data
restart: unless-stopped
+283
View File
@@ -0,0 +1,283 @@
const DB_NAME = 'voice-kb-local';
const STORE_NAME = 'recordings';
const MAX_RECORDING_MS = 30 * 60 * 1000;
const elements = Object.fromEntries([
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'search-input',
'network-state', 'date-label', 'storage-status',
].map((id) => [id, document.getElementById(id)]));
let databasePromise;
let mediaRecorder;
let activeStream;
let recordingChunks = [];
let recordingStartedAt = 0;
let recordingTimer;
let searchTimer;
let syncing = false;
function openDatabase() {
if (databasePromise) return databasePromise;
databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return databasePromise;
}
async function queueTransaction(mode, operation) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
const store = transaction.objectStore(STORE_NAME);
const request = operation(store);
transaction.oncomplete = () => resolve(request?.result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id));
function setMessage(message, isError = false) {
elements['capture-message'].textContent = message;
elements['capture-message'].style.color = isError ? 'var(--coral)' : '';
}
function updateNetwork() {
const online = navigator.onLine;
elements['network-state'].classList.toggle('online', online);
elements['network-state'].innerHTML = `<i></i>${online ? 'Connected' : 'Offline'}`;
elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.';
}
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
function preferredMimeType() {
return ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
.find((type) => window.MediaRecorder?.isTypeSupported(type));
}
async function startRecording() {
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true);
return;
}
try {
activeStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = preferredMimeType();
mediaRecorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined);
recordingChunks = [];
mediaRecorder.ondataavailable = (event) => { if (event.data.size) recordingChunks.push(event.data); };
mediaRecorder.onstop = saveRecording;
mediaRecorder.start(1000);
recordingStartedAt = Date.now();
elements['record-button'].classList.add('recording');
elements['record-button'].setAttribute('aria-label', 'Stop recording');
elements['record-title'].textContent = 'Recording in progress';
elements['record-help'].textContent = 'Tap the square to finish and save';
setMessage('Audio is being recorded on this device.');
recordingTimer = window.setInterval(() => {
const elapsed = Date.now() - recordingStartedAt;
elements['record-time'].textContent = formatDuration(elapsed);
elements['record-meter-fill'].style.width = `${Math.min(100, elapsed / MAX_RECORDING_MS * 100)}%`;
if (elapsed >= MAX_RECORDING_MS) stopRecording();
}, 250);
} catch (error) {
setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true);
}
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
activeStream?.getTracks().forEach((track) => track.stop());
activeStream = undefined;
clearInterval(recordingTimer);
elements['record-button'].classList.remove('recording');
elements['record-button'].setAttribute('aria-label', 'Start recording');
elements['record-title'].textContent = 'Recording saved';
elements['record-help'].textContent = 'Adding it to the upload queue';
elements['record-time'].textContent = '00:00';
elements['record-meter-fill'].style.width = '0';
}
async function saveRecording() {
const blob = new Blob(recordingChunks, { type: mediaRecorder.mimeType || 'audio/mp4' });
if (!blob.size) {
setMessage('No audio was captured. Please try again.', true);
return;
}
const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : 'm4a';
const createdAt = new Date(recordingStartedAt).toISOString();
const recording = {
id: crypto.randomUUID(),
blob,
createdAt,
filename: `voice-note-${createdAt.replaceAll(':', '-')}.${extension}`,
durationMs: Date.now() - recordingStartedAt,
createdAtMs: recordingStartedAt,
};
try {
await putQueued(recording);
setMessage(navigator.onLine ? 'Saved on device. Uploading now…' : 'Saved on device. It will upload when you reconnect.');
await renderQueue();
await syncQueue();
} catch {
setMessage('Could not save this recording in browser storage. Keep this page open and try again.', true);
}
}
async function syncQueue() {
if (syncing || !navigator.onLine) return;
syncing = true;
elements['sync-button'].disabled = true;
try {
const recordings = await getQueued();
for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) {
const form = new FormData();
form.append('recording_id', recording.id);
form.append('audio', recording.blob, recording.filename);
form.append('created_at', recording.createdAt);
try {
const response = await fetch('/api/notes', { method: 'POST', body: form });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
await deleteQueued(recording.id);
setMessage('Server confirmed the recording is stored.');
} catch {
setMessage('Upload paused. Your recording is still saved on this device.');
break;
}
}
} finally {
syncing = false;
elements['sync-button'].disabled = false;
await renderQueue();
await loadArchive(elements['search-input'].value.trim());
}
}
function prettyDate(value) {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
async function renderQueue() {
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs);
elements['queue-list'].replaceChildren();
elements['queue-count'].textContent = recordings.length;
elements['queue-empty'].hidden = recordings.length > 0;
for (const recording of recordings) {
const item = document.createElement('li');
item.className = 'queue-item';
const icon = document.createElement('span');
icon.className = 'queue-icon';
icon.textContent = '◖';
const body = document.createElement('div');
body.className = 'item-body';
const top = document.createElement('div');
top.className = 'item-top';
const name = document.createElement('span');
name.className = 'item-name';
name.textContent = recording.filename;
const status = document.createElement('span');
status.className = 'item-status';
status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE';
const meta = document.createElement('div');
meta.className = 'item-meta';
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
top.append(name, status);
body.append(top, meta);
item.append(icon, body);
elements['queue-list'].append(item);
}
}
function renderArchive(notes) {
elements['archive-list'].replaceChildren();
elements['archive-empty'].hidden = notes.length > 0;
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to browse or search stored notes.';
else elements['archive-empty'].textContent = elements['search-input'].value.trim() ? 'No matching transcripts.' : 'Your stored notes will appear here.';
for (const note of notes) {
const item = document.createElement('article');
item.className = 'archive-item';
const date = document.createElement('time');
date.className = 'archive-date';
date.textContent = prettyDate(note.created_at);
const body = document.createElement('div');
body.className = 'item-body';
const name = document.createElement('div');
name.className = 'item-name';
name.textContent = note.filename;
const transcript = document.createElement('textarea');
transcript.className = 'archive-transcript';
transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
transcript.value = note.transcript;
transcript.setAttribute('aria-label', `Transcript for ${note.filename}`);
const save = document.createElement('button');
save.className = 'save-transcript';
save.type = 'button';
save.textContent = 'SAVE TRANSCRIPT';
save.addEventListener('click', async () => {
save.disabled = true;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}/transcript`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
});
if (!response.ok) throw new Error('Save failed');
save.textContent = 'SAVED';
await loadArchive(elements['search-input'].value.trim());
} catch {
save.textContent = 'RETRY SAVE';
} finally {
save.disabled = false;
}
});
const status = document.createElement('div');
status.className = 'archive-status';
status.textContent = note.status.replaceAll('_', ' ').toUpperCase();
body.append(name, transcript, save, status);
item.append(date, body);
elements['archive-list'].append(item);
}
}
async function loadArchive(query = '') {
if (!navigator.onLine) {
renderArchive([]);
return;
}
try {
const url = new URL('/api/notes', window.location.origin);
if (query) url.searchParams.set('q', query);
const response = await fetch(url);
if (!response.ok) throw new Error('Archive unavailable');
renderArchive(await response.json());
} catch {
elements['archive-list'].replaceChildren();
elements['archive-empty'].hidden = false;
elements['archive-empty'].textContent = 'Could not reach the archive. Pending recordings remain on this device.';
}
}
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
elements['sync-button'].addEventListener('click', syncQueue);
elements['search-input'].addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadArchive(elements['search-input'].value.trim()), 250);
});
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
window.addEventListener('offline', updateNetwork);
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); });
elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase();
updateNetwork();
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
loadArchive();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f5f3ed" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>Voice KB</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Voice KB home"><span class="brand-mark">V</span><span>voice <b>kb</b></span></a>
<span id="network-state" class="network-state"><i></i> Checking</span>
</header>
<main>
<section class="capture" aria-labelledby="capture-title">
<div class="section-kicker"><span>PERSONAL ARCHIVE</span><span id="date-label"></span></div>
<h1 id="capture-title">Catch the thought<br /><em>before it goes.</em></h1>
<p class="intro">Private voice notes, kept on your own server.</p>
<div class="recorder-row">
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
<span class="record-dot"></span>
</button>
<div class="record-copy">
<strong id="record-title">Ready when you are</strong>
<span id="record-help">Tap to record a voice note</span>
</div>
<time id="record-time">00:00</time>
</div>
<div class="record-meter"><span id="record-meter-fill"></span></div>
<div id="capture-message" class="capture-message" role="status"></div>
</section>
<section class="queue-section" aria-labelledby="queue-title">
<div class="section-heading">
<div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div>
<button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button>
</div>
<ul id="queue-list" class="queue-list"></ul>
<p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p>
</section>
<section class="archive-section" aria-labelledby="archive-title">
<div class="section-heading archive-heading">
<div><span class="section-kicker">YOUR LIBRARY</span><h2 id="archive-title">Recent notes</h2></div>
<label class="search-box"><span aria-hidden="true">⌕</span><input id="search-input" type="search" placeholder="Search transcripts" autocomplete="off" /></label>
</div>
<div id="archive-list" class="archive-list"></div>
<p id="archive-empty" class="empty-state">Your stored notes will appear here.</p>
</section>
</main>
<footer><span>LOCAL FIRST</span><span id="storage-status">Audio stays on this device until uploaded.</span></footer>
<script src="/app.js" defer></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Voice KB",
"short_name": "Voice KB",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f3ed",
"theme_color": "#f5f3ed",
"icons": []
}
+86
View File
@@ -0,0 +1,86 @@
:root {
color-scheme: light;
--paper: #f5f3ed;
--ink: #202724;
--muted: #77807a;
--line: #d9dcd4;
--moss: #52735c;
--moss-dark: #355a43;
--coral: #c45b47;
--yellow: #e5c966;
--sans: 'Avenir Next', 'Trebuchet MS', sans-serif;
--serif: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
--mono: ui-monospace, 'SFMono-Regular', Consolas, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); }
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
.brand b { color: var(--moss); }
.brand-mark { width: 28px; height: 28px; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
.network-state { font: 11px var(--mono); color: var(--muted); display: flex; align-items: center; gap: 8px; }
.network-state i { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); }
.network-state.online i { background: #57986b; }
main { max-width: 1040px; margin: auto; padding: 0 24px; }
.capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; }
.section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; }
h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; }
h1 em { color: var(--moss); font-weight: 500; }
.intro { color: #606b63; margin: 0 0 28px; font-size: 14px; }
.recorder-row { max-width: 590px; min-height: 78px; padding: 14px 18px 14px 14px; display: flex; align-items: center; gap: 16px; background: #fffefa; border: 1px solid var(--line); }
.record-button { width: 50px; height: 50px; border: 0; border-radius: 50%; background: var(--coral); display: grid; place-items: center; cursor: pointer; flex: 0 0 auto; transition: background .2s ease, transform .2s ease; }
.record-button:hover { transform: scale(1.04); }
.record-button.recording { background: var(--ink); }
.record-dot { width: 14px; height: 14px; border-radius: 50%; background: white; }
.record-button.recording .record-dot { width: 15px; height: 15px; border-radius: 3px; }
.record-copy { display: grid; gap: 4px; flex: 1; }
.record-copy strong { font-size: 14px; font-weight: 600; }
.record-copy span { color: var(--muted); font-size: 12px; }
#record-time { color: var(--muted); font: 12px var(--mono); font-variant-numeric: tabular-nums; }
.record-meter { height: 2px; max-width: 590px; background: #e4e3dc; }
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
.capture-message { height: 20px; max-width: 590px; padding-top: 6px; color: var(--muted); font-size: 11px; }
.queue-section, .archive-section { padding: 30px 0 34px; border-bottom: 1px solid var(--line); }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.section-heading .section-kicker { justify-content: flex-start; }
h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
#queue-count { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 5px; border-radius: 50%; background: #e6e7df; color: var(--moss-dark); font: 11px var(--mono); vertical-align: 3px; }
.icon-button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; color: var(--moss-dark); font-size: 22px; cursor: pointer; }
.icon-button:disabled { opacity: .4; cursor: wait; }
.queue-list, .archive-list { list-style: none; margin: 16px 0 0; padding: 0; }
.queue-item, .archive-item { display: flex; align-items: flex-start; gap: 15px; padding: 14px 0; border-top: 1px solid #e3e4dd; animation: reveal .35s ease both; }
.queue-icon { width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; background: #e5e8df; color: var(--moss-dark); font-size: 14px; }
.item-body { flex: 1; min-width: 0; }
.item-top { display: flex; justify-content: space-between; gap: 12px; }
.item-name { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.item-meta { margin-top: 5px; color: var(--muted); font: 10px var(--mono); }
.item-status { color: var(--moss-dark); font: 10px var(--mono); white-space: nowrap; }
.empty-state { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
.archive-heading { align-items: end; }
.search-box { width: min(280px, 52vw); height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--line); background: #fffefa; color: var(--muted); }
.search-box span { font-size: 22px; line-height: 1; }
.search-box input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font: 12px var(--sans); }
.search-box input::placeholder { color: #899088; }
.archive-item { padding: 16px 0; }
.archive-date { width: 72px; flex: 0 0 auto; color: var(--muted); font: 10px/1.5 var(--mono); }
.archive-transcript { width: 100%; min-height: 56px; resize: vertical; border: 0; border-bottom: 1px solid #e2e3dc; outline: 0; padding: 0 0 9px; background: transparent; color: var(--ink); font: 13px/1.55 var(--sans); }
.archive-transcript:focus { border-bottom-color: var(--moss); }
.save-transcript { margin-top: 7px; border: 0; padding: 0; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
.archive-status { color: var(--muted); font: 10px var(--mono); margin-top: 7px; }
footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font: 9px var(--mono); }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 600px) {
.topbar { height: 58px; padding: 0 18px; }
main { padding: 0 18px; }
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
h1 { font-size: 48px; }
.recorder-row { gap: 12px; padding-right: 12px; }
.record-button { width: 46px; height: 46px; }
.record-copy strong { font-size: 12px; }
.record-copy span { font-size: 10px; }
h2 { font-size: 23px; }
.archive-date { width: 60px; font-size: 9px; }
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
+26
View File
@@ -0,0 +1,26 @@
const CACHE_NAME = 'voice-kb-shell-v1';
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url);
if (event.request.method !== 'GET' || requestUrl.origin !== self.location.origin || requestUrl.pathname.startsWith('/api/')) return;
event.respondWith(
fetch(event.request).then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
}).catch(async () => (await caches.match(event.request)) || (await caches.match('/'))),
);
});
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
python-multipart>=0.0.9
httpx>=0.27,<1