From 9d0c7777a5f7b3e55591b41d7aeabe1bd50324c5 Mon Sep 17 00:00:00 2001 From: Andrea Date: Sat, 26 Sep 2026 11:34:15 +0200 Subject: [PATCH] Initial commit --- .env.example | 3 + .github/copilot-instructions.md | 7 + .gitignore | 5 + Dockerfile | 11 ++ README.md | 43 +++++ app/main.py | 168 +++++++++++++++++++ docker-compose.yml | 11 ++ frontend/app.js | 283 ++++++++++++++++++++++++++++++++ frontend/index.html | 60 +++++++ frontend/manifest.webmanifest | 9 + frontend/styles.css | 86 ++++++++++ frontend/sw.js | 26 +++ requirements.txt | 4 + 13 files changed, 716 insertions(+) create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 docker-compose.yml create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/manifest.webmanifest create mode 100644 frontend/styles.css create mode 100644 frontend/sw.js create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7236f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Optional local whisper.cpp server endpoint. Leave empty to store recordings +# without automatic transcription. +WHISPER_SERVER_URL= \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..889e8e3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d65127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.py[cod] +.env +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2345dd --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8775e5 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c187fae --- /dev/null +++ b/app/main.py @@ -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") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b71d6d9 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..09205ac --- /dev/null +++ b/frontend/app.js @@ -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 = `${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(() => {}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..272c5e4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,60 @@ + + + + + + + + + Voice KB + + + + +
+ Vvoice kb + Checking +
+ +
+
+
PERSONAL ARCHIVE
+

Catch the thought
before it goes.

+

Private voice notes, kept on your own server.

+
+ +
+ Ready when you are + Tap to record a voice note +
+ +
+
+
+
+ +
+
+
ON THIS DEVICE

Upload queue 0

+ +
+
    +

    New recordings wait here until the server confirms they’re stored.

    +
    + +
    +
    +
    YOUR LIBRARY

    Recent notes

    + +
    +
    +

    Your stored notes will appear here.

    +
    +
    + + + + + \ No newline at end of file diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest new file mode 100644 index 0000000..0e20f12 --- /dev/null +++ b/frontend/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "Voice KB", + "short_name": "Voice KB", + "start_url": "/", + "display": "standalone", + "background_color": "#f5f3ed", + "theme_color": "#f5f3ed", + "icons": [] +} \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..2147945 --- /dev/null +++ b/frontend/styles.css @@ -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; } } \ No newline at end of file diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..ad1803a --- /dev/null +++ b/frontend/sw.js @@ -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('/'))), + ); +}); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a39c97 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115,<1 +uvicorn[standard]>=0.30,<1 +python-multipart>=0.0.9 +httpx>=0.27,<1 \ No newline at end of file