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>
136 lines
4.4 KiB
Python
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")
|