Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Optional local whisper.cpp server endpoint. Leave empty to store recordings
|
||||||
|
# without automatic transcription.
|
||||||
|
WHISPER_SERVER_URL=
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.env
|
||||||
|
data/
|
||||||
+11
@@ -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"]
|
||||||
@@ -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
@@ -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")
|
||||||
@@ -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
@@ -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(() => {});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Voice KB",
|
||||||
|
"short_name": "Voice KB",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f5f3ed",
|
||||||
|
"theme_color": "#f5f3ed",
|
||||||
|
"icons": []
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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('/'))),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
httpx>=0.27,<1
|
||||||
Reference in New Issue
Block a user