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>
This commit is contained in:
parent
b9e56d0825
commit
19c3e156a0
10 changed files with 501 additions and 76 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue