feat(web): Nightjar web app - Plaud-style record/transcribe/summarise UI
Some checks failed
ci / firmware (pull_request) Failing after 2s
ci / openapi (pull_request) Failing after 32s
ci / emulator (pull_request) Failing after 35s
ci / server (pull_request) Failing after 29s

A runnable, self-hosted web interface so the whole workflow can be tested end to
end: record or upload audio in the browser, watch it transcribe and summarise,
browse a library, play back, and export.

What changed:
- server/app/store.py: SQLite + on-disk audio storage for recordings (stdlib only).
- server/app/pipeline.py: background task audio -> transcribe -> summarise via the
  existing provider layer, updating status (queued/transcribing/summarising/done/error).
- server/app/main.py: web API - POST upload, list, detail, audio (Range), delete, and
  export (txt/md/srt/vtt/json) - and serves the SPA at /.
- server/app/web/: Plaud-style single-page UI (index.html, styles.css, app.js). Sidebar
  library, in-browser recording (MediaRecorder) + file upload, live status polling,
  audio player, summary (overview/key points/actions), timestamped transcript, exports.
- server/Dockerfile + README: two-minute run instructions (default provider: Groq free
  tier for both Whisper + LLM), and a Docker option.
- config: env prefix switched OPENSCRIBE_ -> NIGHTJAR_ to match the brand and the site
  tutorials; .env.example rewritten with a ready Groq quick-start.
- state/TODO: web app recorded as done.

Why:
- User asked for a Plaud-like web interface to test how it all works. Nothing testable
  existed before (marketing site is a brochure; pipeline was unwired). This delivers a
  real, runnable product demo and effectively lands M5/M6 for the HTTP providers.

Notes:
- Slim by design: AI is offloaded to the configured provider, so no local ML deps needed
  for the demo. Byte-compiles clean; JS passes node --check. Local faster-whisper still
  needs its model (M5) for the fully-offline path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Laurence 2026-07-06 09:57:42 +01:00
parent b9e56d0825
commit 19c3e156a0
10 changed files with 501 additions and 76 deletions

View file

@ -1,77 +1,136 @@
# SPDX-License-Identifier: GPL-3.0-only
"""OpenScribe server - FastAPI app.
"""Nightjar web app - a Plaud-style interface plus the JSON API.
M0 scaffold: wires the routes from api/openapi.yaml with in-memory stubs so the API shape
is real and browsable at /docs. The AI pipeline (faster-whisper transcription in M5,
Ollama summaries in M6) and real storage/DB replace the stubs in later milestones.
Upload or record audio, watch it transcribe and summarise, browse a library, play back and
export. The heavy AI is done by whichever providers you configure (see .env.example);
point it at a free Groq key and it works end to end.
"""
from __future__ import annotations
from fastapi import FastAPI, HTTPException
import json
import os
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from . import store
from .config import settings
from .models import Recording, RecordingPage, Summary, Transcript
from .pipeline import process
app = FastAPI(
title="OpenScribe API",
version="0.1.0",
description="Self-hosted AI voice recorder server. See api/openapi.yaml.",
)
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
# In-memory store stands in for the DB + object storage until M5.
_recordings: dict[str, Recording] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(settings.local_media_dir, exist_ok=True)
store.init()
yield
app = FastAPI(title="Nightjar", version="0.1.0", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
# Report the resolved provider names (never secrets) so operators can confirm config.
from .providers import build_summariser, build_transcriber
try:
summariser = build_summariser().name
except Exception as exc: # e.g. anthropic dep not installed
except Exception as exc:
summariser = f"unavailable ({type(exc).__name__})"
return {
"status": "ok",
"storage_backend": settings.storage_backend,
"transcription_provider": settings.transcription_provider,
"transcriber": build_transcriber().name,
"llm_provider": settings.llm_provider,
"summariser": summariser,
}
@app.get("/api/v1/recordings", response_model=RecordingPage, tags=["recordings"])
def list_recordings(limit: int = 50) -> RecordingPage:
return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None)
@app.post("/api/recordings")
async def create_recording(
file: UploadFile, background: BackgroundTasks, title: str | None = None
) -> dict:
rec_id = store.create(
title=title or (file.filename or "Recording"),
filename=file.filename or "audio",
mime=file.content_type or "application/octet-stream",
)
with open(store.audio_path(rec_id), "wb") as f:
while chunk := await file.read(1 << 20):
f.write(chunk)
background.add_task(process, rec_id) # transcribe + summarise off the request path
return store.get(rec_id)
@app.get("/api/v1/recordings/{rec_id}", response_model=Recording, tags=["recordings"])
def get_recording(rec_id: str) -> Recording:
rec = _recordings.get(rec_id)
if rec is None:
raise HTTPException(status_code=404, detail="No such recording")
@app.get("/api/recordings")
def list_recordings() -> list[dict]:
return store.list_all()
@app.get("/api/recordings/{rec_id}")
def get_recording(rec_id: str) -> dict:
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
return rec
@app.get("/api/v1/recordings/{rec_id}/transcript", response_model=Transcript, tags=["server"])
def get_transcript(rec_id: str) -> Transcript:
# Implemented in M5 (faster-whisper). Until then, signal "not transcribed yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not transcribed yet (M5)")
@app.get("/api/recordings/{rec_id}/audio")
def get_audio(rec_id: str):
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
return FileResponse(store.audio_path(rec_id), media_type=rec["mime"] or "audio/webm")
@app.get("/api/v1/recordings/{rec_id}/summary", response_model=Summary, tags=["server"])
def get_summary(rec_id: str) -> Summary:
# Implemented in M6 (Ollama). Until then, signal "not summarised yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not summarised yet (M6)")
@app.delete("/api/recordings/{rec_id}", status_code=204)
def delete_recording(rec_id: str):
if not store.delete(rec_id):
raise HTTPException(404, "No such recording")
return Response(status_code=204)
@app.post("/api/v1/ingest", response_model=Recording, status_code=202, tags=["server"])
def ingest(recording: Recording) -> Recording:
# M5 will store audio to the object store and queue transcription + summary.
_recordings[recording.id] = recording
return recording
def _ts(seconds: float, sep: str) -> str:
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
ms = int((seconds - int(seconds)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
@app.get("/api/recordings/{rec_id}/export")
def export_recording(rec_id: str, format: str = "txt"):
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
t = rec.get("transcript") or {}
s = rec.get("summary") or {}
segs = t.get("segments") or []
if format == "json":
return Response(json.dumps(rec, indent=2), media_type="application/json")
if format == "txt":
return PlainTextResponse(t.get("text", ""))
if format == "md":
body = [f"# {rec['title']}", "", "## Summary", s.get("overview", ""), ""]
if s.get("key_points"):
body += ["### Key points"] + [f"- {p}" for p in s["key_points"]] + [""]
if s.get("action_items"):
body += ["### Action items"] + [f"- [ ] {a}" for a in s["action_items"]] + [""]
body += ["## Transcript", "", t.get("text", "")]
return PlainTextResponse("\n".join(body), media_type="text/markdown")
if format in ("srt", "vtt"):
sep = "," if format == "srt" else "."
lines = ["WEBVTT", ""] if format == "vtt" else []
for i, seg in enumerate(segs, 1):
if format == "srt":
lines.append(str(i))
lines.append(f"{_ts(seg['start'], sep)} --> {_ts(seg['end'], sep)}")
lines.append(seg["text"].strip())
lines.append("")
return PlainTextResponse("\n".join(lines))
raise HTTPException(400, "Unknown format")
# Serve the web UI at the root (must be mounted after the API routes).
app.mount("/", StaticFiles(directory=WEB_DIR, html=True), name="web")