commit 0f9e9d4c5cec43df477078c8033337bbd50c840d Author: KansaiGaijin <83641841+KansaiGaijin@users.noreply.github.com> Date: Mon Jul 6 23:45:54 2026 +1200 Initial commit: Nat20 Notes — TTRPG session transcription & summarization diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12c802e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +node_modules/ +dist/ +*.db +.env +docker-compose.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..823fa9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Nat20 Notes — agent guide + +## Commands + +- `docker compose up -d --build` — build & launch both services (backend :8000, frontend :8020) +- `cd frontend && npm run dev` — Vite dev server (proxies `/api` → `localhost:8000`) +- **No tests, no lint/typecheck scripts exist.** Don't look for them. + +## Architecture + +- Two Docker Compose services: `backend` (Python 3.11, FastAPI, SQLite, WhisperX as lib) and `frontend` (React 18 + Vite + Tailwind, served by nginx which proxies `/api` → backend). +- Data lives in Docker volume `app_data` at `/data` (SQLite DB + `uploads/audio/transcripts/notes` subdirs). No external DB. +- Background jobs (transcribe, summarize) run via in-process `ThreadPoolExecutor(max_workers=2)` — no Redis/Celery. +- Frontend serves on port **8020** (d20 nod). + +## Critical convention: flat settings schema + +Settings are a flat key/value table in SQLite. **Every layer must share exact same keys:** +`backend/app/config.py:DEFAULT_SETTINGS`, `frontend/src/api.ts:AppSettings`, the settings router, and Setup/Settings pages. Changing a key without updating all layers silently desyncs the UI. + +## Docker / framework quirks + +- Torch pinned to `2.3.1` with `cu121` wheels (`--extra-index-url https://download.pytorch.org/whl/cu121`) — cuDNN ABI compat with `ctranslate2`. Don't bump unpinned. +- Backend Dockerfile is two-stage (builder + runtime). ffmpeg required in runtime for audio extraction. +- nginx `client_max_body_size` 500M, proxy timeouts 3600s. Files >15MB auto-chunked by frontend `createSession()`. +- Frontend changes in Compose mode require a rebuild (`docker compose up -d --build`). Vite dev server is for local-only dev. + +## Tailwind theme + +Custom colors (`deep`, `panel`, `brass`, `ember`, etc.) and fonts (`Fraunces`, `Inter`, `IBM Plex Mono`) in `tailwind.config.js`. Reusable component classes in `frontend/src/styles.css` (`.card`, `.btn-primary`, `.btn-secondary`, `.input`, `.eyebrow`) — use these over raw Tailwind utilities. + +## Constraints + +- Requires NVIDIA Container Toolkit + HuggingFace token for speaker diarization. +- First run: setup wizard, then `onboarding_completed` flag gates access to main UI. +- Default Ollama endpoint: `http://localhost:11434`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ff12780 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nat20 Notes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..95dc6b3 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Nat20 Notes + +Turn a recorded tabletop RPG session (audio or video) into two documents: +a full GM/DM session log, and a spoiler-free player recap — using local +transcription (WhisperX) and either a local LLM (Ollama) or any +OpenAI-compatible hosted API for summarization. + +## Requirements + +- Docker + Docker Compose +- An NVIDIA GPU with drivers + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed on the host (for transcription; CPU-only works but is much slower) +- A free [HuggingFace token](https://huggingface.co/settings/tokens) — needed for speaker diarization. You'll also need to accept the terms on the gated pyannote model page it links you to on first run. +- Either: [Ollama](https://ollama.com) running somewhere reachable from this app (local or LAN), **or** an API key for a hosted LLM (OpenAI, or any OpenAI-compatible provider) + +## Quick start + +```bash +git clone +cd nat20-notes +# Review docker-compose.yml — see Configuration below for env vars +docker compose up -d --build +``` + +Then open `http://:8020` (yes, that port's a nod to the d20) and follow the setup wizard: + +1. Choose a Whisper model size based on your GPU's available VRAM (guidance shown in-app) +2. Choose local Ollama or a hosted API for summarization, and paste your HF token +3. Optionally paste campaign/world context (NPC names, places) so summaries recognize them correctly + +A reference compose file using named volumes only (no host paths) is at +[`docker-compose.example.yml`](./docker-compose.example.yml). + +## Using it + +1. **Upload** a recording (audio or video — video is auto-converted, audio-only files skip that step and are much faster). Files over **90 MB** are auto-chunked with 3-way concurrent uploads. +2. **Transcribe** — runs in the background. On completion you're **automatically taken** to the speaker-naming screen. +3. **Name your speakers** — a waveform-style "session reel" shows each detected speaker's segments; click any point to jump the audio there and hear who's talking, then type in their name. Use the checkboxes to **merge** speakers (e.g. when diarization over-splits one person into `SPEAKER_00`, `SPEAKER_05`, etc.). +4. **Generate notes** — click "Done naming" and confirm; notes generation starts automatically and you're taken to the job progress screen. When finished, navigate to the notes viewer. +5. **Review & tweak** — regenerate notes, rename speakers, or delete jobs/sessions from the session detail page. + +## Data layout + +The app stores everything under `/data` (inside the container), which by default +is a [bind mount](./docker-compose.yml) to a host path of your choice: + +| Directory / File | Contents | +|---|---| +| `audio/` | Uploaded recordings and extracted audio | +| `transcriptions/` | Per-session transcript JSON files | +| `notes/` | Generated notes (GM log + player recap) | +| `app.db` | SQLite database (sessions, speakers, settings, jobs) | + +## Configuration + +### Prefilling the setup wizard + +Set `NAT20_*` environment variables under the `backend` service in +`docker-compose.yml` — the wizard will pick them up as defaults: + +```yaml +environment: + NAT20_HF_TOKEN: "hf_..." + NAT20_WHISPER_MODEL: medium + NAT20_OLLAMA_HOST: http://192.168.0.16:11434 + NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt +``` + +See the `environment:` block in `docker-compose.yml` for the full list. + +### Campaign context + +You can paste context directly in the Settings page, or point to a file +inside the container using the `world_context_path` setting (or the +`NAT20_WORLD_CONTEXT_PATH` env var). The file path version is useful for +large campaign bibles that you update independently. + +## Notes on hardware + +Transcription is GPU-bound and by far the slowest step for long sessions. +Summarization is comparatively light — a 7-8B parameter local model is +sufficient for most groups; only step up in size if you have the VRAM +headroom after Whisper's footprint (they don't run at the same time, so +you only need enough VRAM for whichever is currently running, plus normal +system overhead from other GPU-using services). + +## Architecture + +- `backend/` — FastAPI, SQLite (no external DB needed), WhisperX as a library (no nested Docker), in-process `ThreadPoolExecutor` background jobs (no Redis/Celery) +- `frontend/` — React 18 + Vite + Tailwind, served via nginx (listens on port **8020**) which proxies `/api` to the backend + +Both run as standard Docker Compose services — no special orchestration needed beyond GPU passthrough for the backend. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..fa1b3ca --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +*.egg-info/ +.git/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..87423ea --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,52 @@ +# ========================================== +# STAGE 1: Builder Environment +# ========================================== +FROM python:3.11-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + patchelf \ + && rm -rf /var/lib/apt/lists/* + +ENV PIP_NO_CACHE_DIR=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + +COPY requirements.txt . + +RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt + +# Silence the "Lightning auto-upgraded checkpoint" warning on every diarization run +RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true + +RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so* + +RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \ + && find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \ + && find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \; + +# ========================================== +# STAGE 2: Lightweight Final Runtime +# ========================================== +FROM python:3.11-slim AS runtime + +WORKDIR /app + +# ffmpeg is mandatory for the audio-extraction step. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Copy the (now stripped) site-packages from the builder stage. None of the +# build-essential/gcc toolchain from Stage 1 ends up here. +COPY --from=builder /root/.local /root/.local +COPY app /app/app + +ENV PATH=/root/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0d0ca9d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,49 @@ +""" +App-wide configuration. Everything is either an environment variable (set once, +at deploy time) or a row in the `settings` table (editable at runtime via the +setup wizard / settings page) - never a hardcoded path or host. +""" +import os +from pathlib import Path + +# Deploy-time config (env vars) - where things live on disk inside the container. +DATA_DIR = Path(os.environ.get("APP_DATA_DIR", "/data")) +UPLOAD_DIR = DATA_DIR / "audio" +AUDIO_DIR = DATA_DIR / "audio" +TRANSCRIPT_DIR = DATA_DIR / "transcriptions" +NOTES_DIR = DATA_DIR / "notes" +DB_PATH = DATA_DIR / "app.db" + +for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR): + d.mkdir(parents=True, exist_ok=True) + +# Runtime-editable settings (stored in DB, these are just first-run defaults). +# NOTE: this flat key/value schema is the single source of truth for settings - +# it's what every consumer (pipeline/summarize.py, pipeline/transcribe.py, +# routers/models.py, routers/diagnostics.py) actually reads. The settings +# router and frontend must mirror these exact keys - a nested schema would +# silently disconnect the Settings/Setup UI from the pipeline. +DEFAULT_SETTINGS = { + # Onboarding + "onboarding_completed": "false", + + # Transcription + "whisper_model": "medium", # tiny|base|small|medium|large-v3 - user picks based on their hardware + "whisper_compute_type": "int8", + "hf_token": "", # required for diarization (pyannote gated models) + + # Summarization backend: "ollama" (local) or "api" (hosted, OpenAI-compatible) + "llm_mode": "ollama", + "ollama_host": "http://localhost:11434", + "ollama_model": "qwen2.5:7b", + "api_base_url": "https://api.openai.com/v1", + "api_key": "", + "api_model": "gpt-4o-mini", + + # Chunking for long transcripts (map-reduce summarization) + "chunk_word_target": "2500", + + # Optional world context injected into every summarization prompt + "world_context": "", + "world_context_path": "", +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d2c47ee --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,136 @@ +import json +import sqlite3 +import threading +import time +import uuid +from contextlib import contextmanager + +from . import config + +_local = threading.local() + + +def get_conn(): + if not hasattr(_local, "conn"): + _local.conn = sqlite3.connect(config.DB_PATH, check_same_thread=False) + _local.conn.row_factory = sqlite3.Row + _local.conn.execute("PRAGMA foreign_keys = ON") + return _local.conn + + +@contextmanager +def tx(): + conn = get_conn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + +def init_db(): + with tx() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + original_filename TEXT NOT NULL, + video_path TEXT, + audio_path TEXT, + transcript_path TEXT, + status TEXT NOT NULL DEFAULT 'uploaded', + created_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS speakers ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + raw_label TEXT NOT NULL, + display_name TEXT, + UNIQUE(session_id, raw_label) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS notes ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + dm_notes TEXT, + player_recap TEXT, + generated_at REAL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + job_type TEXT NOT NULL, -- 'transcribe' | 'summarize' + status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|error + progress TEXT, -- free-text progress message + error TEXT, + error_stage TEXT, -- which pipeline stage failed, e.g. 'transcription' + error_detail TEXT, -- full traceback, for logs/advanced view only + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + """) + # seed defaults if empty + existing = {r["key"] for r in conn.execute("SELECT key FROM settings")} + for k, v in config.DEFAULT_SETTINGS.items(): + if k not in existing: + conn.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (k, v)) + + +def get_settings() -> dict: + conn = get_conn() + rows = conn.execute("SELECT key, value FROM settings").fetchall() + return {r["key"]: r["value"] for r in rows} + + +def update_settings(patch: dict): + with tx() as conn: + for k, v in patch.items(): + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (k, str(v)), + ) + + +def new_id() -> str: + return uuid.uuid4().hex[:12] + + +def now() -> float: + return time.time() + + +def create_job(session_id: str, job_type: str) -> str: + job_id = new_id() + with tx() as conn: + conn.execute( + "INSERT INTO jobs (id, session_id, job_type, status, created_at, updated_at) " + "VALUES (?, ?, ?, 'queued', ?, ?)", + (job_id, session_id, job_type, now(), now()), + ) + return job_id + + +def update_job(job_id: str, **fields): + if not fields: + return + fields["updated_at"] = now() + cols = ", ".join(f"{k} = ?" for k in fields) + with tx() as conn: + conn.execute(f"UPDATE jobs SET {cols} WHERE id = ?", (*fields.values(), job_id)) + + +def get_job(job_id: str) -> dict | None: + row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() + return dict(row) if row else None diff --git a/backend/app/diagnostics.py b/backend/app/diagnostics.py new file mode 100644 index 0000000..f943dd2 --- /dev/null +++ b/backend/app/diagnostics.py @@ -0,0 +1,73 @@ +import shutil +import subprocess + +import requests + +from . import database as db, config + + +def _check_ffmpeg() -> dict: + path = shutil.which("ffmpeg") + if not path: + return {"name": "ffmpeg", "ok": False, "message": "ffmpeg not found on PATH. This should be baked into the Docker image - if you're seeing this, the image build is broken."} + return {"name": "ffmpeg", "ok": True, "message": path} + + +def _check_gpu() -> dict: + try: + import torch + if torch.cuda.is_available(): + name = torch.cuda.get_device_name(0) + free, total = torch.cuda.mem_get_info(0) + free_gb, total_gb = free / 1e9, total / 1e9 + return {"name": "gpu", "ok": True, "message": f"{name} — {free_gb:.1f}GB free of {total_gb:.1f}GB"} + return {"name": "gpu", "ok": False, "message": "No CUDA GPU visible. Check that the container was started with --gpus all / GPU passthrough in compose, and that host NVIDIA drivers + Container Toolkit are installed. Transcription will fall back to CPU, which is much slower."} + except Exception as e: + return {"name": "gpu", "ok": False, "message": f"Could not query GPU: {e}"} + + +def _check_hf_token() -> dict: + settings = db.get_settings() + token = settings.get("hf_token", "") + if not token: + return {"name": "huggingface_token", "ok": False, "message": "No HF token set. Diarization (speaker separation) will fail without one. Add it in Settings."} + return {"name": "huggingface_token", "ok": True, "message": "Token is set (not validated against HF until first diarization run)."} + + +def _check_llm_backend() -> dict: + settings = db.get_settings() + if settings.get("llm_mode") == "api": + if not settings.get("api_key"): + return {"name": "llm_backend", "ok": False, "message": "Hosted API selected but no API key set."} + return {"name": "llm_backend", "ok": True, "message": f"Hosted API configured ({settings.get('api_base_url')})"} + + host = settings.get("ollama_host", "http://localhost:11434") + model = settings.get("ollama_model", "") + try: + resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5) + resp.raise_for_status() + models = [m["name"] for m in resp.json().get("models", [])] + if model not in models: + return {"name": "llm_backend", "ok": False, "message": f"Reached Ollama at {host}, but model '{model}' isn't pulled there. Available: {', '.join(models) or '(none)'}. Run: ollama pull {model}"} + return {"name": "llm_backend", "ok": True, "message": f"Ollama reachable at {host}, model '{model}' available"} + except requests.exceptions.ConnectionError: + return {"name": "llm_backend", "ok": False, "message": f"Could not connect to Ollama at {host}. Check the host/port in Settings, and that Ollama is running and reachable from this container."} + except Exception as e: + return {"name": "llm_backend", "ok": False, "message": f"Error checking Ollama: {e}"} + + +def _check_disk_space() -> dict: + usage = shutil.disk_usage(config.DATA_DIR) + free_gb = usage.free / 1e9 + ok = free_gb > 5 + return {"name": "disk_space", "ok": ok, "message": f"{free_gb:.1f}GB free in {config.DATA_DIR}" + ("" if ok else " — this is low, recordings and model caches need room")} + + +def run_diagnostics() -> list[dict]: + return [ + _check_ffmpeg(), + _check_gpu(), + _check_hf_token(), + _check_llm_backend(), + _check_disk_space(), + ] diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..c51d5b2 --- /dev/null +++ b/backend/app/errors.py @@ -0,0 +1,29 @@ +class PipelineError(Exception): + """Base class for pipeline failures. `stage` and `message` are shown to the + user directly; the original exception (if any) is kept for logs only.""" + + def __init__(self, stage: str, message: str, cause: Exception | None = None): + self.stage = stage + self.message = message + self.cause = cause + super().__init__(f"[{stage}] {message}") + + +class AudioExtractionError(PipelineError): + def __init__(self, message: str, cause: Exception | None = None): + super().__init__("audio_extraction", message, cause) + + +class TranscriptionError(PipelineError): + def __init__(self, message: str, cause: Exception | None = None): + super().__init__("transcription", message, cause) + + +class DiarizationError(PipelineError): + def __init__(self, message: str, cause: Exception | None = None): + super().__init__("diarization", message, cause) + + +class SummarizationError(PipelineError): + def __init__(self, message: str, cause: Exception | None = None): + super().__init__("summarization", message, cause) diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..65b0ae5 --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,41 @@ +""" +Minimal background job runner. +""" +import traceback +from concurrent.futures import ThreadPoolExecutor + +from . import database as db +from .errors import PipelineError +from .logging_config import get_logger + +log = get_logger(__name__) +_executor = ThreadPoolExecutor(max_workers=2) # transcription is GPU-bound anyway; no benefit to more workers + + +def submit(job_id: str, fn, *args, **kwargs): + def _run(): + log.info("Job %s starting", job_id) + db.update_job(job_id, status="running", progress="Starting...") + try: + def progress_cb(msg): + log.info("Job %s: %s", job_id, msg) + db.update_job(job_id, progress=msg) + fn(*args, progress_cb=progress_cb, **kwargs) + db.update_job(job_id, status="done", progress="Complete") + log.info("Job %s complete", job_id) + except PipelineError as e: + log.error("Job %s failed at stage '%s': %s", job_id, e.stage, e.message) + db.update_job( + job_id, status="error", + error=e.message, error_stage=e.stage, + error_detail=traceback.format_exc(), + ) + except Exception as e: + log.exception("Job %s failed with unexpected error", job_id) + db.update_job( + job_id, status="error", + error=f"Unexpected error: {e}", error_stage="unknown", + error_detail=traceback.format_exc(), + ) + + _executor.submit(_run) diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..023ac3b --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,18 @@ +import logging +import sys + + +def setup_logging(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stdout, + ) + # quiet down noisy libraries so pipeline logs are easy to spot + for noisy in ("httpx", "urllib3", "pyannote"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..fa68697 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,67 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi import HTTPException + +from . import database as db, config +from .logging_config import setup_logging, get_logger +from .errors import PipelineError +from .routers import settings as settings_router +from .routers import sessions as sessions_router +from .routers import speakers as speakers_router +from .routers import notes as notes_router +from .routers import jobs as jobs_router +from .routers import models as models_router +from .routers import diagnostics as diagnostics_router + +setup_logging() +log = get_logger(__name__) + +app = FastAPI(title="Nat20 Notes") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(PipelineError) +async def pipeline_error_handler(request: Request, exc: PipelineError): + log.error("Pipeline error in %s: %s", exc.stage, exc.message, exc_info=exc.cause or exc) + return JSONResponse(status_code=502, content={"stage": exc.stage, "message": exc.message}) + + +@app.exception_handler(Exception) +async def unhandled_error_handler(request: Request, exc: Exception): + log.exception("Unhandled error on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"stage": "unknown", "message": f"Unexpected error: {exc}"}) + + +@app.on_event("startup") +def on_startup(): + db.init_db() + log.info("Database initialized at %s", config.DB_PATH) + + +app.include_router(settings_router.router) +app.include_router(sessions_router.router) +app.include_router(speakers_router.router) +app.include_router(notes_router.router) +app.include_router(jobs_router.router) +app.include_router(models_router.router) +app.include_router(diagnostics_router.router) + + +@app.get("/api/sessions/{session_id}/audio") +def get_audio(session_id: str): + audio_path = config.AUDIO_DIR / f"{session_id}.wav" + if not audio_path.exists(): + raise HTTPException(404, "Audio not available for this session") + return FileResponse(audio_path, media_type="audio/wav") + + +@app.get("/api/health") +def health(): + return {"ok": True} \ No newline at end of file diff --git a/backend/app/pipeline/__init__.py b/backend/app/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pipeline/audio.py b/backend/app/pipeline/audio.py new file mode 100644 index 0000000..299129e --- /dev/null +++ b/backend/app/pipeline/audio.py @@ -0,0 +1,35 @@ +import subprocess +from pathlib import Path + +from ..errors import AudioExtractionError +from ..logging_config import get_logger + +log = get_logger(__name__) + + +def extract_audio(video_path: Path, audio_path: Path) -> Path: + """Extract 16kHz mono wav from any video/audio container.""" + if not video_path.exists(): + raise AudioExtractionError(f"Uploaded file not found on disk at {video_path}. It may not have finished uploading, or the upload volume isn't mounted correctly.") + + if audio_path.exists(): + log.info("Audio already extracted at %s, skipping", audio_path) + return audio_path + + log.info("Extracting audio: %s -> %s", video_path, audio_path) + result = subprocess.run( + [ + "ffmpeg", "-y", "-i", str(video_path), + "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", + str(audio_path), + ], + capture_output=True, text=True, + ) + if result.returncode != 0: + # surface ffmpeg's own error line rather than the whole verbose log + stderr_tail = "\n".join(result.stderr.strip().splitlines()[-5:]) + raise AudioExtractionError( + f"ffmpeg failed to extract audio (exit code {result.returncode}). " + f"This usually means the file is corrupt or not a supported format. Last output:\n{stderr_tail}" + ) + return audio_path diff --git a/backend/app/pipeline/summarize.py b/backend/app/pipeline/summarize.py new file mode 100644 index 0000000..da5555f --- /dev/null +++ b/backend/app/pipeline/summarize.py @@ -0,0 +1,152 @@ +""" +Map-reduce summarization: chunk transcript -> per-chunk DM + player summaries +-> combine into final notes. Backend-agnostic: works against a local Ollama +instance or any OpenAI-compatible hosted API, selected via settings. +""" +from pathlib import Path + +import requests + +from .turns import chunk_turns, turns_to_text, fmt_time +from ..errors import SummarizationError +from ..logging_config import get_logger + +log = get_logger(__name__) + +DM_CHUNK_PROMPT = """You are summarizing a chunk of a tabletop RPG session transcript for the Game Master's private notes. +{world_context_block} +Extract concise bullet points: +- Major plot events and decisions made +- NPC interactions (names, what was said/promised/revealed) +- Combat outcomes (who fought what, notable rolls, deaths/near-deaths) +- Loot, rewards, or resources gained/lost +- Any GM secrets, foreshadowing, or plot threads revealed at the table +- Open questions or hooks left dangling + +Be concise and factual. Use the speaker names given. Do not invent anything not in the transcript. + +Transcript chunk (time range {time_range}): +{chunk_text} +""" + +PLAYER_CHUNK_PROMPT = """You are summarizing a chunk of a tabletop RPG session transcript for a player-facing recap. +{world_context_block} +Extract concise bullet points of what happened IN THE STORY from the players' in-character perspective: +- Where the party went, who they met, what was said in-character +- Combat encounters and outcomes +- Loot/items the party found +- Decisions the party made and their in-fiction consequences + +Exclude out-of-character rules discussion, GM asides, and anything that reads as a secret not yet revealed to characters. +If unsure whether something is a spoiler, leave it out. + +Transcript chunk (time range {time_range}): +{chunk_text} +""" + +DM_FINAL_PROMPT = """Combine these chunk summaries from a single session into one cohesive GM session log. +Organize into sections: Recap, NPC Interactions, Combat & Encounters, Loot & Rewards, Decisions & Consequences, Open Threads / Hooks for Next Session. +Write in clear prose/bullets, no fluff, no repetition across chunks. + +Chunk summaries: +{summaries} +""" + +PLAYER_FINAL_PROMPT = """Combine these chunk summaries into a single "previously on" style recap for the players, in-character perspective only. +Write it like a story recap they could reread before the next session. No GM secrets, no OOC content. Keep it engaging but concise. + +Chunk summaries: +{summaries} +""" + + +def _call_ollama(host: str, model: str, prompt: str) -> str: + url = f"{host.rstrip('/')}/api/generate" + try: + resp = requests.post( + url, + json={"model": model, "prompt": prompt, "stream": False, "options": {"num_ctx": 8192}}, + timeout=600, + ) + except requests.exceptions.ConnectionError as e: + raise SummarizationError( + f"Could not connect to Ollama at {host}. Check the host/port in Settings and that Ollama is running.", cause=e + ) + except requests.exceptions.Timeout as e: + raise SummarizationError(f"Ollama at {host} didn't respond within 10 minutes - it may be overloaded or stuck.", cause=e) + + if resp.status_code == 404: + raise SummarizationError( + f"Ollama returned 404 for model '{model}'. This almost always means the model isn't pulled on that host. " + f"Run: ollama pull {model}", cause=None + ) + try: + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + raise SummarizationError(f"Ollama returned an error ({resp.status_code}): {resp.text[:300]}", cause=e) + + return resp.json()["response"].strip() + + +def _call_api(base_url: str, api_key: str, model: str, prompt: str) -> str: + url = f"{base_url.rstrip('/')}/chat/completions" + try: + resp = requests.post( + url, + headers={"Authorization": f"Bearer {api_key}"}, + json={"model": model, "messages": [{"role": "user", "content": prompt}]}, + timeout=600, + ) + except requests.exceptions.ConnectionError as e: + raise SummarizationError(f"Could not connect to API at {base_url}. Check the base URL in Settings.", cause=e) + except requests.exceptions.Timeout as e: + raise SummarizationError(f"API at {base_url} didn't respond within 10 minutes.", cause=e) + + if resp.status_code == 401: + raise SummarizationError("API rejected the request as unauthorized (401) - check your API key in Settings.", cause=None) + if resp.status_code == 404: + raise SummarizationError(f"API returned 404 - check the base URL and that model '{model}' exists for this provider.", cause=None) + try: + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + raise SummarizationError(f"API returned an error ({resp.status_code}): {resp.text[:300]}", cause=e) + + try: + return resp.json()["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError) as e: + raise SummarizationError(f"API response didn't have the expected shape: {resp.text[:300]}", cause=e) + + +def make_llm_caller(settings: dict): + if settings.get("llm_mode") == "api": + base_url, api_key, model = settings["api_base_url"], settings["api_key"], settings["api_model"] + return lambda prompt: _call_api(base_url, api_key, model, prompt) + host, model = settings["ollama_host"], settings["ollama_model"] + return lambda prompt: _call_ollama(host, model, prompt) + + +def summarize_session(turns: list[dict], settings: dict, progress_cb=None) -> tuple[str, str]: + call = make_llm_caller(settings) + ctx_path = settings.get("world_context_path") or "" + if ctx_path and Path(ctx_path).exists(): + world_context = Path(ctx_path).read_text(encoding="utf-8").strip() + else: + world_context = (settings.get("world_context") or "").strip() + world_block = f"\nCampaign context (use this to recognize names/places correctly):\n{world_context}\n" if world_context else "" + + chunks = chunk_turns(turns, int(settings.get("chunk_word_target", 2500))) + dm_summaries, player_summaries = [], [] + + for i, chunk in enumerate(chunks): + if progress_cb: + progress_cb(f"Summarizing chunk {i+1}/{len(chunks)}") + time_range = f"{fmt_time(chunk[0]['start'])}-{fmt_time(chunk[-1]['end'])}" + chunk_text = turns_to_text(chunk) + dm_summaries.append(call(DM_CHUNK_PROMPT.format(world_context_block=world_block, time_range=time_range, chunk_text=chunk_text))) + player_summaries.append(call(PLAYER_CHUNK_PROMPT.format(world_context_block=world_block, time_range=time_range, chunk_text=chunk_text))) + + if progress_cb: + progress_cb("Writing final notes...") + dm_final = call(DM_FINAL_PROMPT.format(summaries="\n\n".join(dm_summaries))) + player_final = call(PLAYER_FINAL_PROMPT.format(summaries="\n\n".join(player_summaries))) + return dm_final, player_final diff --git a/backend/app/pipeline/transcribe.py b/backend/app/pipeline/transcribe.py new file mode 100644 index 0000000..fa24d71 --- /dev/null +++ b/backend/app/pipeline/transcribe.py @@ -0,0 +1,104 @@ +""" +Transcription + diarization using WhisperX as a Python library. +""" +import gc +import json +from pathlib import Path + +import torch +import whisperx + +from ..errors import TranscriptionError, DiarizationError +from ..logging_config import get_logger + +log = get_logger(__name__) + + +def transcribe_and_diarize( + audio_path: Path, + output_json: Path, + model_size: str, + compute_type: str, + hf_token: str, + progress_cb=None, +) -> dict: + device = "cuda" if torch.cuda.is_available() else "cpu" + if device == "cpu": + log.warning("No CUDA GPU available - running on CPU, this will be much slower") + + def report(msg): + log.info(msg) + if progress_cb: + progress_cb(msg) + + report("Loading transcription model...") + try: + model = whisperx.load_model(model_size, device, compute_type=compute_type) + except RuntimeError as e: + if "out of memory" in str(e).lower(): + raise TranscriptionError( + f"Ran out of GPU memory loading the '{model_size}' model. " + f"Try a smaller model size in Settings (e.g. 'small' or 'medium'), " + f"or check nothing else is using the GPU right now.", cause=e + ) + raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e) + except Exception as e: + raise TranscriptionError(f"Failed to load Whisper model '{model_size}': {e}", cause=e) + + try: + report("Loading audio...") + audio = whisperx.load_audio(str(audio_path)) + + report("Transcribing...") + result = model.transcribe(audio, batch_size=16) + except RuntimeError as e: + if "out of memory" in str(e).lower(): + raise TranscriptionError( + f"Ran out of GPU memory during transcription. Try a smaller Whisper model size in Settings.", cause=e + ) + raise TranscriptionError(f"Transcription failed: {e}", cause=e) + except Exception as e: + raise TranscriptionError(f"Transcription failed: {e}", cause=e) + finally: + del model + gc.collect() + if device == "cuda": + torch.cuda.empty_cache() + + try: + report("Aligning...") + align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device) + result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False) + del align_model + gc.collect() + if device == "cuda": + torch.cuda.empty_cache() + except Exception as e: + raise TranscriptionError(f"Alignment step failed: {e}", cause=e) + + if hf_token: + try: + report("Diarizing...") + diarize_model = whisperx.diarize.DiarizationPipeline(use_auth_token=hf_token, device=device) + diarize_segments = diarize_model(audio) + result = whisperx.assign_word_speakers(diarize_segments, result) + except Exception as e: + msg = str(e) + if "gated" in msg.lower() or "403" in msg or "access" in msg.lower(): + raise DiarizationError( + "HuggingFace rejected access to the diarization model. Make sure you've accepted the " + "terms on the gated model page (linked in the error below) using the SAME account " + f"your HF token belongs to.\n{msg}", cause=e + ) + raise DiarizationError(f"Diarization failed: {msg}", cause=e) + else: + report("No HF token set - skipping speaker diarization (all speech will be unattributed)") + + try: + output_json.parent.mkdir(parents=True, exist_ok=True) + output_json.write_text(json.dumps(result, ensure_ascii=False), encoding='utf-8') + except OSError as e: + raise TranscriptionError(f"Transcribed successfully, but failed to write output to {output_json}: {e}", cause=e) + + report("Done") + return result diff --git a/backend/app/pipeline/turns.py b/backend/app/pipeline/turns.py new file mode 100644 index 0000000..d7811d2 --- /dev/null +++ b/backend/app/pipeline/turns.py @@ -0,0 +1,60 @@ +import json +from pathlib import Path + + +def fmt_time(seconds: float) -> str: + h, rem = divmod(int(seconds), 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def load_turns(transcript_json: Path, speaker_map: dict[str, str] | None = None) -> list[dict]: + """Parse whisperx json into merged (start, end, speaker, text) turns. + speaker_map maps raw labels (SPEAKER_00...) to display names; unmapped labels pass through raw.""" + data = json.loads(transcript_json.read_text()) + speaker_map = speaker_map or {} + turns = [] + for seg in data.get("segments", []): + raw = seg.get("speaker", "UNKNOWN") + speaker = speaker_map.get(raw, raw) + text = (seg.get("text") or "").strip() + if not text: + continue + if turns and turns[-1]["speaker"] == speaker: + turns[-1]["text"] += " " + text + turns[-1]["end"] = seg["end"] + else: + turns.append({"start": seg["start"], "end": seg["end"], "speaker": speaker, "text": text, "raw_speaker": raw}) + return turns + + +def distinct_speakers(transcript_json: Path) -> list[dict]: + """Return each raw speaker label with a couple of sample lines, for the naming UI.""" + turns = load_turns(transcript_json) # no map -> raw labels + seen: dict[str, list[dict]] = {} + for t in turns: + seen.setdefault(t["raw_speaker"], []).append(t) + return [ + { + "raw_label": label, + "samples": [{"start": t["start"], "text": t["text"][:140]} for t in items[:3]], + } + for label, items in sorted(seen.items()) + ] + + +def chunk_turns(turns: list[dict], word_target: int) -> list[list[dict]]: + chunks, current, word_count = [], [], 0 + for turn in turns: + current.append(turn) + word_count += len(turn["text"].split()) + if word_count >= word_target: + chunks.append(current) + current, word_count = [], 0 + if current: + chunks.append(current) + return chunks + + +def turns_to_text(turns: list[dict]) -> str: + return "\n".join(f"[{fmt_time(t['start'])}] {t['speaker']}: {t['text']}" for t in turns) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/diagnostics.py b/backend/app/routers/diagnostics.py new file mode 100644 index 0000000..e42d881 --- /dev/null +++ b/backend/app/routers/diagnostics.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from ..diagnostics import run_diagnostics + +router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"]) + + +@router.get("") +def get_diagnostics(): + checks = run_diagnostics() + return {"ok": all(c["ok"] for c in checks), "checks": checks} diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py new file mode 100644 index 0000000..ed6e679 --- /dev/null +++ b/backend/app/routers/jobs.py @@ -0,0 +1,82 @@ +import os + +from fastapi import APIRouter, HTTPException, Query + +from .. import database as db, config + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + +# Canonical job status vocabulary (matches the `jobs` table): queued|running|done|error. +# The frontend must use these exact strings, not invented ones like "pending"/"completed"/"failed". + + +@router.get("/{job_id}") +def get_job(job_id: str): + job = db.get_job(job_id) + if not job: + raise HTTPException(404, "Job not found") + return job + + +@router.post("/{job_id}/cancel") +async def cancel_job(job_id: str): + job = db.get_job(job_id) + if not job: + raise HTTPException(404, "Job not found") + if job["status"] in ("queued", "running"): + # The thread pool executor has no hard-kill hook, so this marks the job + # as cancelled; the pipeline itself does not currently check for + # cancellation mid-run, so a running job may still finish in the background. + db.update_job(job_id, status="error", error="Cancelled by user", error_stage="cancelled") + return {"status": "success", "message": f"Job {job_id} cancellation signal sent."} + + +@router.delete("/{job_id}") +async def delete_job( + job_id: str, + strategy: str = Query(..., description="One of: 'all', 'artifacts_only', 'none'"), +): + job = db.get_job(job_id) + if not job: + raise HTTPException(404, "Job not found") + + session_id = job["session_id"] + session_row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + session = dict(session_row) if session_row else None + + purged = [] + + if strategy in ("all", "artifacts_only") and session: + for path_key in ("transcript_path",): + path = session.get(path_key) + if path and os.path.exists(path): + os.remove(path) + purged.append(path_key) + with db.tx() as conn: + conn.execute("DELETE FROM notes WHERE session_id = ?", (session_id,)) + conn.execute("DELETE FROM speakers WHERE session_id = ?", (session_id,)) + purged.append("notes_and_speakers") + + if strategy == "all" and session: + for path_key in ("video_path", "audio_path"): + path = session.get(path_key) + if path and os.path.exists(path): + os.remove(path) + purged.append(path_key) + with db.tx() as conn: + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + purged.append("session_record") + elif strategy in ("all", "artifacts_only", "none"): + with db.tx() as conn: + conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,)) + purged.append("job_record") + + # If a failed job left the session stuck in 'transcribing', roll it back + if job["status"] == "error" and session and session["status"] == "transcribing": + with db.tx() as conn: + conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,)) + + if strategy not in ("all", "artifacts_only", "none"): + raise HTTPException(400, f"Unknown strategy '{strategy}'") + + return {"status": "success", "message": f"Job {job_id} cleared using strategy: {strategy}", "purged": purged} diff --git a/backend/app/routers/models.py b/backend/app/routers/models.py new file mode 100644 index 0000000..e997328 --- /dev/null +++ b/backend/app/routers/models.py @@ -0,0 +1,20 @@ +import requests +from fastapi import APIRouter + +from .. import database as db + +router = APIRouter(prefix="/api/models", tags=["models"]) + + +@router.get("/ollama") +def list_ollama_models(): + """List models already pulled on the configured Ollama host, so the setup + wizard can offer a dropdown instead of asking the user to type a tag blind.""" + settings = db.get_settings() + host = settings.get("ollama_host", "http://host.docker.internal:11434") + try: + resp = requests.get(f"{host.rstrip('/')}/api/tags", timeout=5) + resp.raise_for_status() + return {"ok": True, "models": [m["name"] for m in resp.json().get("models", [])]} + except Exception as e: + return {"ok": False, "error": str(e), "models": []} diff --git a/backend/app/routers/notes.py b/backend/app/routers/notes.py new file mode 100644 index 0000000..0bddc5b --- /dev/null +++ b/backend/app/routers/notes.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from fastapi import APIRouter, HTTPException + +from .. import database as db, jobs +from ..pipeline.turns import load_turns +from ..pipeline.summarize import summarize_session + +router = APIRouter(prefix="/api/sessions/{session_id}/notes", tags=["notes"]) + + +@router.post("/generate") +def generate_notes(session_id: str): + session = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not session: + raise HTTPException(404, "Session not found") + if not session["transcript_path"]: + raise HTTPException(400, "Session hasn't been transcribed yet") + + settings = db.get_settings() + speaker_map = {r["raw_label"]: r["display_name"] for r in + db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,)) + if r["display_name"]} + + job_id = db.create_job(session_id, "summarize") + + def run(progress_cb): + turns = load_turns(Path(session["transcript_path"]), speaker_map) + dm_notes, player_recap = summarize_session(turns, settings, progress_cb=progress_cb) + with db.tx() as conn: + conn.execute( + "INSERT INTO notes (session_id, dm_notes, player_recap, generated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(session_id) DO UPDATE SET dm_notes=excluded.dm_notes, " + "player_recap=excluded.player_recap, generated_at=excluded.generated_at", + (session_id, dm_notes, player_recap, db.now()), + ) + conn.execute("UPDATE sessions SET status = 'complete' WHERE id = ?", (session_id,)) + + jobs.submit(job_id, run) + return {"job_id": job_id} + + +@router.get("") +def get_notes(session_id: str): + row = db.get_conn().execute("SELECT * FROM notes WHERE session_id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Notes not generated yet") + return dict(row) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py new file mode 100644 index 0000000..bd58fce --- /dev/null +++ b/backend/app/routers/sessions.py @@ -0,0 +1,190 @@ +import json +import os +import shutil +from pathlib import Path + +from fastapi import APIRouter, UploadFile, File, Form, HTTPException + +from .. import database as db, config, jobs +from ..errors import PipelineError +from ..pipeline.audio import extract_audio +from ..pipeline.transcribe import transcribe_and_diarize + + +def _dedup_path(path: Path) -> Path: + """Append a counter suffix like ` (1)`, ` (2)` if the file exists.""" + if not path.exists(): + return path + stem = path.stem + suffix = path.suffix + parent = path.parent + counter = 1 + while True: + new = parent / f"{stem} ({counter}){suffix}" + if not new.exists(): + return new + counter += 1 + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + +# All uploaded/derived files live under config.UPLOAD_DIR / config.AUDIO_DIR / +# config.TRANSCRIPT_DIR (all under config.DATA_DIR, the /data volume) - never +# a hardcoded /app/storage path, which isn't backed by any volume the rest of +# the app agrees on. + + +def _row_to_dict(row) -> dict: + return dict(row) + + +@router.get("") +def list_sessions(): + rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall() + sessions = [] + for r in rows: + s = _row_to_dict(r) + job = db.get_conn().execute( + "SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (s["id"],) + ).fetchone() + s["latest_job"] = _row_to_dict(job) if job else None + sessions.append(s) + return sessions + + +@router.get("/{session_id}") +def get_session(session_id: str): + row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Session not found") + session = _row_to_dict(row) + job = db.get_conn().execute( + "SELECT * FROM jobs WHERE session_id = ? ORDER BY created_at DESC LIMIT 1", (session_id,) + ).fetchone() + session["latest_job"] = _row_to_dict(job) if job else None + + # Stats computed from transcript + if session.get("transcript_path"): + tp = Path(session["transcript_path"]) + if tp.exists(): + try: + data = json.loads(tp.read_text()) + segments = data.get("segments", []) + if segments: + session["audio_duration"] = segments[-1]["end"] + session["word_count"] = sum(len(s.get("text", "").split()) for s in segments) + session["language"] = data.get("language") + except (json.JSONDecodeError, OSError): + pass + + return session + + +def _run_transcription(session_id: str, video_path: Path, audio_path: Path, transcript_path: Path): + settings = db.get_settings() + + def run(progress_cb): + db.get_conn() # ensure thread-local connection exists in this worker thread + with db.tx() as conn: + conn.execute("UPDATE sessions SET status = 'transcribing' WHERE id = ?", (session_id,)) + progress_cb("Extracting audio...") + try: + extract_audio(video_path, audio_path) + transcribe_and_diarize( + audio_path, + transcript_path, + model_size=settings.get("whisper_model", "medium"), + compute_type=settings.get("whisper_compute_type", "int8"), + hf_token=settings.get("hf_token", ""), + progress_cb=progress_cb, + ) + except PipelineError: + with db.tx() as conn: + conn.execute("UPDATE sessions SET status = 'uploaded' WHERE id = ?", (session_id,)) + raise + with db.tx() as conn: + conn.execute( + "UPDATE sessions SET audio_path = ?, transcript_path = ?, status = 'transcribed' WHERE id = ?", + (str(audio_path), str(transcript_path), session_id), + ) + + job_id = db.create_job(session_id, "transcribe") + jobs.submit(job_id, run) + return job_id + + +@router.post("") +async def create_session(name: str = Form(...), file: UploadFile = File(None), upload_path: str = Form(None)): + session_id = db.new_id() + + if upload_path: + src = Path(upload_path) + if not src.exists(): + raise HTTPException(400, f"Uploaded file not found at {upload_path}") + safe_filename = os.path.basename(src) + video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}" + shutil.move(str(src), str(video_path)) + elif file: + safe_filename = os.path.basename(file.filename or f"{session_id}") + video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}" + with open(video_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + else: + raise HTTPException(400, "Either a file upload or upload_path is required") + + with db.tx() as conn: + conn.execute( + "INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) " + "VALUES (?, ?, ?, ?, 'uploaded', ?)", + (session_id, name, safe_filename, str(video_path), db.now()), + ) + + audio_path = config.AUDIO_DIR / f"{session_id}.wav" + transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" + job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) + + return {"session_id": session_id, "job_id": job_id} + + +@router.post("/{session_id}/transcribe") +def retry_transcription(session_id: str): + """Re-run transcription for a session, e.g. after a failed job.""" + row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Session not found") + session = _row_to_dict(row) + video_path = Path(session["video_path"]) + audio_path = config.AUDIO_DIR / f"{session_id}.wav" + transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" + job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) + return {"job_id": job_id} + + +@router.post("/upload-chunk") +async def upload_chunk( + file: UploadFile = File(...), + chunk_index: int = Form(...), + total_chunks: int = Form(...), + filename: str = Form(...), + upload_id: str = Form(...), +): + """Assemble a large upload sent in chunks (bypasses nginx's client_max_body_size + for files bigger than that). Returns a filepath under UPLOAD_DIR that can later + be referenced when creating a session, for very large recordings.""" + safe_filename = os.path.basename(filename) + temp_dir = config.UPLOAD_DIR / f"tmp_{upload_id}" + temp_dir.mkdir(parents=True, exist_ok=True) + + chunk_path = temp_dir / f"chunk_{chunk_index}" + with open(chunk_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + if len(os.listdir(temp_dir)) == total_chunks: + final_file_path = _dedup_path(config.UPLOAD_DIR / safe_filename) + with open(final_file_path, "wb") as final_file: + for i in range(total_chunks): + with open(temp_dir / f"chunk_{i}", "rb") as chunk_f: + final_file.write(chunk_f.read()) + shutil.rmtree(temp_dir) + return {"status": "completed", "filepath": str(final_file_path), "filename": safe_filename} + + return {"status": "chunk_received", "chunk_index": chunk_index} diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..71d3ead --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,62 @@ +import os + +from fastapi import APIRouter + +from .. import database as db, config + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +# Settings are stored as flat key/value rows (see database.py / config.DEFAULT_SETTINGS). +# This router is a thin pass-through over that table - it must not invent its own +# schema, or the Settings/Setup pages will silently stop affecting the actual +# transcription/summarization pipeline, which reads these same flat keys. + +# Env var overrides — set these in docker-compose.yml to prefill the setup wizard +_ENV_OVERRIDES = { + "hf_token": "NAT20_HF_TOKEN", + "whisper_model": "NAT20_WHISPER_MODEL", + "whisper_compute_type": "NAT20_WHISPER_COMPUTE_TYPE", + "ollama_host": "NAT20_OLLAMA_HOST", + "ollama_model": "NAT20_OLLAMA_MODEL", + "api_base_url": "NAT20_API_BASE_URL", + "api_key": "NAT20_API_KEY", + "api_model": "NAT20_API_MODEL", + "chunk_word_target": "NAT20_CHUNK_WORD_TARGET", + "world_context": "NAT20_WORLD_CONTEXT", + "world_context_path": "NAT20_WORLD_CONTEXT_PATH", +} + +_BOOL_KEYS = {"onboarding_completed"} + + +def _coerce(key: str, value: str): + if key in _BOOL_KEYS: + return str(value).lower() in ("1", "true", "yes") + return value + + +def _merge_with_env(raw: dict) -> dict: + """DB values win, env vars fill gaps, DEFAULT_SETTINGS fill remaining gaps.""" + merged = {**config.DEFAULT_SETTINGS, **raw} + for key, env_name in _ENV_OVERRIDES.items(): + if not raw.get(key, "").strip(): + val = os.environ.get(env_name) + if val is not None: + merged[key] = val + return merged + + +@router.get("") +async def get_settings(): + raw = db.get_settings() + merged = _merge_with_env(raw) + return {k: _coerce(k, v) for k, v in merged.items()} + + +@router.post("") +async def update_settings(patch: dict): + clean = {k: str(v) for k, v in patch.items() if k in config.DEFAULT_SETTINGS} + db.update_settings(clean) + raw = db.get_settings() + merged = _merge_with_env(raw) + return {k: _coerce(k, v) for k, v in merged.items()} diff --git a/backend/app/routers/speakers.py b/backend/app/routers/speakers.py new file mode 100644 index 0000000..1aae79a --- /dev/null +++ b/backend/app/routers/speakers.py @@ -0,0 +1,91 @@ +import json +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from .. import database as db, config +from ..pipeline.turns import distinct_speakers, load_turns + +router = APIRouter(prefix="/api/sessions/{session_id}/speakers", tags=["speakers"]) + + +def _get_session_or_404(session_id: str): + row = db.get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Session not found") + return dict(row) + + +@router.get("") +def get_speakers(session_id: str): + session = _get_session_or_404(session_id) + if not session["transcript_path"]: + raise HTTPException(400, "Session hasn't been transcribed yet") + + speakers = distinct_speakers(Path(session["transcript_path"])) + + existing = {r["raw_label"]: r["display_name"] for r in + db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,))} + + for sp in speakers: + sp["display_name"] = existing.get(sp["raw_label"], "") + return speakers + + +class SpeakerName(BaseModel): + raw_label: str + display_name: str + + +@router.put("") +def set_speaker_name(session_id: str, body: SpeakerName): + _get_session_or_404(session_id) + with db.tx() as conn: + conn.execute( + "INSERT INTO speakers (id, session_id, raw_label, display_name) VALUES (?, ?, ?, ?) " + "ON CONFLICT(session_id, raw_label) DO UPDATE SET display_name = excluded.display_name", + (db.new_id(), session_id, body.raw_label, body.display_name), + ) + return {"ok": True} + + +class MergeSpeakersBody(BaseModel): + target_label: str + source_labels: list[str] + + +@router.post("/merge") +def merge_speakers(session_id: str, body: MergeSpeakersBody): + session = _get_session_or_404(session_id) + if not session["transcript_path"]: + raise HTTPException(400, "Session hasn't been transcribed yet") + if body.target_label in body.source_labels: + raise HTTPException(400, "Target label cannot also be a source label") + + tp = Path(session["transcript_path"]) + data = json.loads(tp.read_text()) + changed = 0 + for seg in data.get("segments", []): + if seg.get("speaker") in body.source_labels: + seg["speaker"] = body.target_label + changed += 1 + tp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + with db.tx() as conn: + for label in body.source_labels: + conn.execute("DELETE FROM speakers WHERE session_id = ? AND raw_label = ?", (session_id, label)) + + return {"ok": True, "segments_reassigned": changed} + + +@router.get("/turns") +def get_turns(session_id: str): + """Full transcript with display names applied - powers the review/playback screen.""" + session = _get_session_or_404(session_id) + if not session["transcript_path"]: + raise HTTPException(400, "Session hasn't been transcribed yet") + speaker_map = {r["raw_label"]: r["display_name"] for r in + db.get_conn().execute("SELECT raw_label, display_name FROM speakers WHERE session_id = ?", (session_id,)) + if r["display_name"]} + return load_turns(Path(session["transcript_path"]), speaker_map) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5ba868c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 +requests==2.32.3 + +# Pin torch/torchaudio explicitly to the cu121 wheels (see --extra-index-url in +# the Dockerfile). If left unpinned, `pip install whisperx` resolves its +# loose "torch>=2" constraint against whatever the newest release is +# (currently a cu128 build) - that's both a much bigger download (duplicate/ +# newer CUDA libs) and known to be ABI-incompatible with the cuDNN version +# ctranslate2 (a whisperx dependency) expects on older pins. Pinning fixes +# both the image size and a real "libcudnn_ops_infer.so.8: cannot open +# shared object file" crash some torch/ctranslate2 combos hit. +torch==2.3.1 +torchaudio==2.3.1 + +whisperx==3.3.1 +ctranslate2==4.4.0 +faster-whisper==1.1.0 +pyannote.audio==3.3.2 +nltk==3.9.1 +pandas==2.2.3 +transformers==4.44.2 +matplotlib diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..4ab7dda --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,33 @@ +# Example Docker Compose — uses a named volume so data persists across rebuilds +# without needing a specific host path. Copy to docker-compose.yml and customise. +services: + backend: + build: ./backend + restart: unless-stopped + volumes: + - app_data:/data + - hf_cache:/root/.cache/huggingface + - torch_cache:/root/.cache/torch + + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + ports: + - "8000:8000" + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "8020:8020" + +volumes: + app_data: + hf_cache: + torch_cache: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..dcb0dd8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8020 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..37f2561 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + Nat20 Notes + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e2bb2bb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 8020; + + client_max_body_size 500M; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..933eaf5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "session-notes-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.11", + "typescript": "^5.6.2", + "vite": "^5.4.6" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4712279 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import Setup from './pages/Setup'; +import Sessions from './pages/Sessions'; +import SessionDetail from './pages/SessionDetail'; +import Speakers from './pages/Speakers'; +import Notes from './pages/Notes'; +import Settings from './pages/Settings'; +import Diagnostics from './pages/Diagnostics'; +import { api } from './api'; + +function App() { + const [loading, setLoading] = useState(true); + const [isConfigured, setIsConfigured] = useState(false); + + const checkSettings = () => { + api.getSettings() + .then(data => { + setIsConfigured(!!(data && data.onboarding_completed)); + }) + .catch(() => setIsConfigured(false)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + checkSettings(); + }, []); + + if (loading) { + return
Loading...
; + } + + return ( + + {/* If not configured, force them to the Setup wizard */} + : } + /> + + {/* If configured, serve the main sessions workspace */} + {isConfigured ? ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + } /> + )} + + ); +} + +export default App; diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..64eb59b --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,190 @@ +const BASE_URL = '/api'; + +// Flat settings schema - must mirror backend/app/config.py's DEFAULT_SETTINGS +// exactly. This is the single source of truth; don't reintroduce a nested +// shape here without updating config.py, routers/settings.py, Setup.tsx and +// Settings.tsx together. +export interface AppSettings { + onboarding_completed: boolean; + whisper_model: string; + whisper_compute_type: string; + hf_token: string; + llm_mode: 'ollama' | 'api'; + ollama_host: string; + ollama_model: string; + api_base_url: string; + api_key: string; + api_model: string; + chunk_word_target: string; + world_context: string; + world_context_path: string; +} + +export type JobStatus = 'queued' | 'running' | 'done' | 'error'; + +export interface Job { + id: string; + session_id: string; + job_type: 'transcribe' | 'summarize'; + status: JobStatus; + progress: string | null; + error: string | null; + error_stage: string | null; + created_at: number; + updated_at: number; +} + +export type DeleteStrategy = 'all' | 'artifacts_only' | 'none'; + +export const jobApi = { + getJob: async (jobId: string): Promise => { + const res = await fetch(`${BASE_URL}/jobs/${jobId}`); + if (!res.ok) throw new Error('Failed to fetch job'); + return res.json(); + }, + cancelJob: async (jobId: string): Promise => { + const res = await fetch(`${BASE_URL}/jobs/${jobId}/cancel`, { method: 'POST' }); + return res.json(); + }, + deleteJob: async (jobId: string, strategy: DeleteStrategy): Promise => { + const res = await fetch(`${BASE_URL}/jobs/${jobId}?strategy=${strategy}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete job'); + return res.json(); + }, +}; + +export const api = { + // Settings + getSettings: async (): Promise => { + const res = await fetch(`${BASE_URL}/settings`); + return res.json(); + }, + updateSettings: async (settings: Partial): Promise => { + const res = await fetch(`${BASE_URL}/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + return res.json(); + }, + // kept as an alias since some pages call it by this name + saveSettings: async (settings: Partial): Promise => { + return api.updateSettings(settings); + }, + + // Sessions + listSessions: async (): Promise => { + const res = await fetch(`${BASE_URL}/sessions`); + return res.json(); + }, + createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => { + const CHUNK_THRESHOLD = 90 * 1024 * 1024; + if (file.size > CHUNK_THRESHOLD) { + const result = await api.uploadFileInChunks(file, onProgress || (() => {})); + const formData = new FormData(); + formData.append('name', name); + formData.append('upload_path', result.filepath); + const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Failed to create session'); + return res.json(); + } + const formData = new FormData(); + formData.append('name', name); + formData.append('file', file); + const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Failed to create session'); + return res.json(); + }, + getSession: async (sessionId: string): Promise => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}`); + if (!res.ok) throw new Error('Failed to fetch session'); + return res.json(); + }, + retryTranscription: async (sessionId: string): Promise<{ job_id: string }> => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/transcribe`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start transcription'); + return res.json(); + }, + audioUrl: (sessionId: string): string => `${BASE_URL}/sessions/${sessionId}/audio`, + + // Speakers + getSpeakers: async (sessionId: string): Promise => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`); + return res.json(); + }, + getTurns: async (sessionId: string): Promise => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers/turns`); + return res.json(); + }, + setSpeakerName: async (sessionId: string, raw_label: string, display_name: string): Promise => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ raw_label, display_name }), + }); + if (!res.ok) throw new Error('Failed to save speaker name'); + }, + + // Notes + getNotes: async (sessionId: string): Promise => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes`); + if (!res.ok) throw new Error('Notes not generated yet'); + return res.json(); + }, + generateNotes: async (sessionId: string): Promise<{ job_id: string }> => { + const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes/generate`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start note generation'); + return res.json(); + }, + + // Diagnostics + getDiagnostics: async (): Promise<{ ok: boolean; checks: any[] }> => { + const res = await fetch(`${BASE_URL}/diagnostics`); + if (!res.ok) throw new Error('Failed to reach backend'); + return res.json(); + }, + + // Jobs (re-exported here so pages that only import `api` still work) + deleteJob: jobApi.deleteJob, + cancelJob: jobApi.cancelJob, + getJob: jobApi.getJob, + + // Large-file (chunked) upload, used automatically by createSession() + // when the file exceeds 90MB. Sends up to 3 chunks concurrently. + uploadFileInChunks: async ( + file: File, + onProgress: (percent: number) => void + ): Promise => { + const CHUNK_SIZE = 1024 * 1024 * 15; + const CONCURRENCY = 3; + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const uploadId = crypto.randomUUID(); + + const uploadOne = async (chunkIndex: number): Promise => { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + const formData = new FormData(); + formData.append('file', chunk, file.name); + formData.append('chunk_index', chunkIndex.toString()); + formData.append('total_chunks', totalChunks.toString()); + formData.append('filename', file.name); + formData.append('upload_id', uploadId); + const res = await fetch(`${BASE_URL}/sessions/upload-chunk`, { method: 'POST', body: formData }); + if (!res.ok) throw new Error(`Failed to upload chunk ${chunkIndex}`); + return res.json(); + }; + + let lastResult: any = null; + for (let i = 0; i < totalChunks; i += CONCURRENCY) { + const batch = []; + for (let j = i; j < Math.min(i + CONCURRENCY, totalChunks); j++) { + batch.push(uploadOne(j)); + } + const results = await Promise.all(batch); + lastResult = results[results.length - 1]; + onProgress(Math.round((Math.min(i + CONCURRENCY, totalChunks) / totalChunks) * 100)); + } + return lastResult; + }, +}; diff --git a/frontend/src/components/SessionReel.tsx b/frontend/src/components/SessionReel.tsx new file mode 100644 index 0000000..b29c741 --- /dev/null +++ b/frontend/src/components/SessionReel.tsx @@ -0,0 +1,66 @@ +import { useMemo, useRef } from "react"; + +const SPEAKER_COLORS = ["#C9A227", "#5F7A61", "#4E6A87", "#7A5670", "#B4523A", "#8A8F5C"]; + +function colorFor(label: string, palette: Map) { + if (!palette.has(label)) { + palette.set(label, SPEAKER_COLORS[palette.size % SPEAKER_COLORS.length]); + } + return palette.get(label)!; +} + +export function SessionReel({ + turns, + duration, + onSeek, + currentTime, +}: { + turns: { start: number; end: number; raw_speaker: string }[]; + duration: number; + onSeek: (t: number) => void; + currentTime: number; +}) { + const palette = useMemo(() => new Map(), []); + const trackRef = useRef(null); + + const valid = duration > 0 && isFinite(duration); + + const handleClick = (e: React.MouseEvent) => { + if (!trackRef.current || !valid) return; + const rect = trackRef.current.getBoundingClientRect(); + const frac = (e.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(duration, frac * duration))); + }; + + const playheadPct = valid ? (currentTime / duration) * 100 : 0; + + return ( +
+ {valid && + turns.map((t, i) => { + const left = (t.start / duration) * 100; + const width = Math.max(((t.end - t.start) / duration) * 100, 0.15); + return ( +
+ ); + })} +
+
+ ); +} + +export { colorFor, SPEAKER_COLORS }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5e74ac7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/frontend/src/pages/Diagnostics.tsx b/frontend/src/pages/Diagnostics.tsx new file mode 100644 index 0000000..4773c71 --- /dev/null +++ b/frontend/src/pages/Diagnostics.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api"; + +const LABELS: Record = { + ffmpeg: "ffmpeg", + gpu: "GPU / CUDA", + huggingface_token: "HuggingFace token", + llm_backend: "Summarization backend", + disk_space: "Disk space", +}; + +export default function Diagnostics() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const run = async () => { + setLoading(true); + const r = await api.getDiagnostics().catch((e) => ({ ok: false, checks: [], _error: e.message })); + setResult(r); + setLoading(false); + }; + + useEffect(() => { + run(); + }, []); + + return ( +
+ ← Back +

System check

+

Run this before your first session, or any time something isn't working.

+ + + + {result?._error && ( +
Couldn't reach the backend: {result._error}
+ )} + +
+ {result?.checks?.map((c: any) => ( +
+ +
+
{LABELS[c.name] || c.name}
+
{c.message}
+
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Notes.tsx b/frontend/src/pages/Notes.tsx new file mode 100644 index 0000000..c55ca33 --- /dev/null +++ b/frontend/src/pages/Notes.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../api"; + +export default function Notes() { + const { id } = useParams(); + const [notes, setNotes] = useState(null); + const [tab, setTab] = useState<"dm" | "player">("dm"); + + useEffect(() => { + api.getNotes(id!).then(setNotes); + }, [id]); + + if (!notes) return null; + + return ( +
+ ← Back +

Session notes

+ +
+ + +
+ +
+ {tab === "dm" ? notes.dm_notes : notes.player_recap} +
+
+ ); +} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx new file mode 100644 index 0000000..0e516f7 --- /dev/null +++ b/frontend/src/pages/SessionDetail.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { api, Job } from '../api'; + +function Toast({ message, onDone }: { message: string; onDone: () => void }) { + useEffect(() => { const t = setTimeout(onDone, 2500); return () => clearTimeout(t); }, [onDone]); + return ( +
+ {message} +
+ ); +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function ElapsedTimer({ createdAt, endedAt }: { createdAt: number; endedAt?: number }) { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (endedAt !== undefined) { + setElapsed(Math.floor(endedAt - createdAt)); + return; + } + const tick = () => setElapsed(Math.floor(Date.now() / 1000 - createdAt)); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [createdAt, endedAt]); + return {formatDuration(elapsed)}; +} + +const STATUS_LABEL: Record = { + uploaded: 'Ready to transcribe', + transcribing: 'Transcribing...', + transcribed: 'Ready to name speakers', + complete: 'Notes ready', +}; + +function JobStatusCard({ job, session, onRefresh, onDeleted }: { job: Job; session?: any; onRefresh: () => void; onDeleted: (strategy: string) => void }) { + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [busy, setBusy] = useState(false); + + const handleCancel = async () => { + if (!confirm('Are you sure you want to halt this job midway?')) return; + setBusy(true); + await api.cancelJob(job.id); + onRefresh(); + setBusy(false); + }; + + const executeDeletion = async (strategy: 'all' | 'artifacts_only' | 'none') => { + setBusy(true); + await api.deleteJob(job.id, strategy); + setShowDeleteModal(false); + setBusy(false); + onDeleted(strategy); + }; + + const badgeClass = + job.status === 'done' + ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' + : job.status === 'error' + ? 'bg-rose-500/20 text-rose-400 border border-rose-500/30' + : 'bg-amber-500/20 text-amber-400 border border-amber-500/30 animate-pulse'; + + return ( +
+
+

{job.job_type === 'transcribe' ? 'Transcription' : 'Note generation'}

+ + {job.status === 'error' ? 'Failed' : job.status} + +
+ + {job.status === 'error' ? ( +
+ Error{job.error_stage ? ` [${job.error_stage}]` : ''}: {job.error || 'Unexpected error.'} +
+ ) : ( +

{job.progress || 'Waiting...'}

+ )} + + {job.status !== 'queued' && job.created_at && ( +
+ + +
+ )} + + {session?.audio_duration && ( +
+ Audio {formatDuration(session.audio_duration)} + {session.word_count ? ` \u2022 ${session.word_count.toLocaleString()} words` : ''} + {session.language ? ` \u2022 ${session.language}` : ''} +
+ )} + +
+ {(job.status === 'queued' || job.status === 'running') && ( + + )} + {job.status === 'error' && job.job_type === 'transcribe' && ( + + )} + +
+ + {showDeleteModal && ( +
+
+

Clean up session data?

+

+ Choose what to remove along with this job's tracking record. +

+
+ + + +
+
+ +
+
+
+ )} +
+ ); +} + +export default function SessionDetail() { + const { id } = useParams(); + const nav = useNavigate(); + const [session, setSession] = useState(null); + const [job, setJob] = useState(null); + const [toast, setToast] = useState(null); + + const load = useCallback(() => { + if (!id) return; + api.getSession(id).then((s) => { + setSession(s); + setJob(s.latest_job || null); + }); + }, [id]); + + useEffect(load, [load]); + + // Poll while a job is actively running so status/progress stay live. + useEffect(() => { + if (!job || (job.status !== 'queued' && job.status !== 'running')) return; + const interval = setInterval(load, 2500); + return () => clearInterval(interval); + }, [job, load]); + + // Preload audio as soon as transcription finishes so it's buffered for the speakers page + useEffect(() => { + if (session?.status !== 'transcribed' || !id) return; + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'audio'; + link.href = api.audioUrl(id); + document.head.appendChild(link); + return () => link.remove(); + }, [session?.status, id]); + + // Auto-redirect to speakers page once transcription completes + const autoRedirected = useRef(false); + useEffect(() => { + if (session?.status === 'transcribed') { + if (!autoRedirected.current) { + autoRedirected.current = true; + nav(`/sessions/${id}/speakers`); + } + } else { + autoRedirected.current = false; + } + }, [session?.status, id, nav]); + + const retryTranscription = async () => { + if (!id) return; + await api.retryTranscription(id); + load(); + }; + + const generateNotes = async () => { + if (!id) return; + await api.generateNotes(id); + load(); + }; + + if (!session) return null; + + return ( +
+ {toast && setToast(null)} />} + ← Back +

{session.name}

+

{session.original_filename}

+

{STATUS_LABEL[session.status] ?? session.status}

+ + {job && ( +
+ { + if (strategy === 'all') { + setToast('Session wiped'); + setTimeout(() => nav('/'), 600); + } else if (strategy === 'artifacts_only') { + setToast('Transcript and notes cleared — source file kept'); + load(); + } else { + setToast('Job record removed'); + load(); + } + }} /> +
+ )} + + {session.status === 'uploaded' && !job && ( + + )} + {session.status === 'complete' && ( +
+ + View notes +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx new file mode 100644 index 0000000..205b2b5 --- /dev/null +++ b/frontend/src/pages/Sessions.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState, useCallback } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { api } from "../api"; + +const STATUS_LABEL: Record = { + uploaded: "Ready to transcribe", + transcribing: "Transcribing...", + transcribed: "Ready to name speakers", + complete: "Notes ready", +}; + +export default function Sessions() { + const [sessions, setSessions] = useState([]); + const [name, setName] = useState(""); + const [file, setFile] = useState(null); + const [uploadProgress, setUploadProgress] = useState(null); + const nav = useNavigate(); + + const refresh = useCallback(() => api.listSessions().then(setSessions), []); + useEffect(() => { + refresh(); + }, [refresh]); + + // Poll while any session is actively processing + useEffect(() => { + const hasActive = sessions.some(s => s.status === 'transcribing'); + if (!hasActive) return; + const interval = setInterval(refresh, 2500); + return () => clearInterval(interval); + }, [sessions, refresh]); + + const upload = async () => { + if (!file || !name) return; + setUploadProgress(0); + const { session_id } = await api.createSession(name, file, setUploadProgress); + setUploadProgress(null); + nav(`/sessions/${session_id}`); + }; + + return ( +
+
Nat20 Notes
+

Your Transcriptions

+ +
+

New transcription

+
+ setName(e.target.value)} + /> + setFile(e.target.files?.[0] ?? null)} + className="text-sm text-ink/70" + /> + +
+
+ +
+ {sessions.length === 0 && ( +

No sessions yet — upload a recording to get started.

+ )} + {sessions.map((s) => { + const job = s.latest_job; + const progress = job?.progress; + const displayStatus = s.status === 'transcribing' && progress + ? progress + : (STATUS_LABEL[s.status] ?? s.status); + const statusColor = job?.status === 'error' ? 'text-rose-400' : 'text-brass'; + return ( + +
+
{s.name}
+
{s.original_filename}
+
+
{displayStatus}
+ + ); + })} +
+ +
+ System check + Settings +
+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..fbe77c1 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api"; + +export default function Settings() { + const [settings, setSettings] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + api.getSettings().then(setSettings); + }, []); + + if (!settings) return null; + + const set = (k: string, v: any) => setSettings({ ...settings, [k]: v }); + + const save = async () => { + await api.updateSettings(settings); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + return ( +
+ ← Back +

Settings

+ +
+

Transcription

+ + set("hf_token", e.target.value)} /> +
+ +
+

Summarization

+
+ + +
+ {settings.llm_mode === "ollama" ? ( + <> + set("ollama_host", e.target.value)} /> + set("ollama_model", e.target.value)} /> + + ) : ( + <> + set("api_base_url", e.target.value)} /> + set("api_key", e.target.value)} /> + set("api_model", e.target.value)} /> + + )} +
+ +
+

Campaign context

+