openscribe/server/app/main.py
Laurence 19c3e156a0
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
feat(web): Nightjar web app - Plaud-style record/transcribe/summarise UI
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>
2026-07-06 09:57:42 +01:00

136 lines
4.4 KiB
Python

# SPDX-License-Identifier: GPL-3.0-only
"""Nightjar web app - a Plaud-style interface plus the JSON API.
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
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 .pipeline import process
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
@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:
from .providers import build_summariser, build_transcriber
try:
summariser = build_summariser().name
except Exception as exc:
summariser = f"unavailable ({type(exc).__name__})"
return {
"status": "ok",
"transcriber": build_transcriber().name,
"summariser": summariser,
}
@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/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/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.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)
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")