Initial commit: Nat20 Notes — TTRPG session transcription & summarization

This commit is contained in:
KansaiGaijin
2026-07-06 23:45:54 +12:00
commit 0f9e9d4c5c
49 changed files with 2926 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
node_modules/
dist/
*.db
.env
docker-compose.yml

36
AGENTS.md Normal file
View File

@@ -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`.

21
LICENSE Normal file
View File

@@ -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.

91
README.md Normal file
View File

@@ -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 <this-repo>
cd nat20-notes
# Review docker-compose.yml — see Configuration below for env vars
docker compose up -d --build
```
Then open `http://<your-server>: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.

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.venv/
venv/
*.egg-info/
.git/
.env

52
backend/Dockerfile Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

49
backend/app/config.py Normal file
View File

@@ -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": "",
}

136
backend/app/database.py Normal file
View File

@@ -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

View File

@@ -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(),
]

29
backend/app/errors.py Normal file
View File

@@ -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)

41
backend/app/jobs.py Normal file
View File

@@ -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)

View File

@@ -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)

67
backend/app/main.py Normal file
View File

@@ -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}

View File

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

View File

@@ -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}

View File

@@ -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}

View File

@@ -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": []}

View File

@@ -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)

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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)

24
backend/requirements.txt Normal file
View File

@@ -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

View File

@@ -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:

11
frontend/Dockerfile Normal file
View File

@@ -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

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nat20 Notes</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body class="bg-deep text-ink font-body">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@@ -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;
}
}

26
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

59
frontend/src/App.tsx Normal file
View File

@@ -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 <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>;
}
return (
<Routes>
{/* If not configured, force them to the Setup wizard */}
<Route
path="/setup"
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
/>
{/* If configured, serve the main sessions workspace */}
{isConfigured ? (
<>
<Route path="/" element={<Sessions />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} />
<Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} />
</>
) : (
<Route path="*" element={<Navigate to="/setup" />} />
)}
</Routes>
);
}
export default App;

190
frontend/src/api.ts Normal file
View File

@@ -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<Job> => {
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<any> => {
const res = await fetch(`${BASE_URL}/jobs/${jobId}/cancel`, { method: 'POST' });
return res.json();
},
deleteJob: async (jobId: string, strategy: DeleteStrategy): Promise<any> => {
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<AppSettings> => {
const res = await fetch(`${BASE_URL}/settings`);
return res.json();
},
updateSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
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<AppSettings>): Promise<AppSettings> => {
return api.updateSettings(settings);
},
// Sessions
listSessions: async (): Promise<any[]> => {
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<any> => {
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<any[]> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`);
return res.json();
},
getTurns: async (sessionId: string): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers/turns`);
return res.json();
},
setSpeakerName: async (sessionId: string, raw_label: string, display_name: string): Promise<void> => {
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<any> => {
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<any> => {
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<any> => {
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;
},
};

View File

@@ -0,0 +1,66 @@
import { useMemo, useRef } from "react";
const SPEAKER_COLORS = ["#C9A227", "#5F7A61", "#4E6A87", "#7A5670", "#B4523A", "#8A8F5C"];
function colorFor(label: string, palette: Map<string, string>) {
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<string, string>(), []);
const trackRef = useRef<HTMLDivElement>(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 (
<div
ref={trackRef}
onClick={handleClick}
className={"relative h-14 rounded-md border overflow-hidden " + (valid ? "bg-deep border-white/10 cursor-pointer" : "bg-panel2 border-white/5")}
role="slider"
aria-label="Session timeline"
>
{valid &&
turns.map((t, i) => {
const left = (t.start / duration) * 100;
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
return (
<div
key={i}
className="absolute top-1.5 bottom-1.5 rounded-sm opacity-70 hover:opacity-100 transition"
style={{ left: `${left}%`, width: `${width}%`, background: colorFor(t.raw_speaker, palette) }}
title={`${t.raw_speaker} @ ${Math.floor(t.start)}s`}
/>
);
})}
<div
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)]"
style={{ left: `${playheadPct}%` }}
/>
</div>
);
}
export { colorFor, SPEAKER_COLORS };

13
frontend/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
const LABELS: Record<string, string> = {
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<any>(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 (
<div className="max-w-xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
<button className="btn-secondary mb-6" onClick={run} disabled={loading}>
{loading ? "Checking..." : "Run checks again"}
</button>
{result?._error && (
<div className="card p-4 text-ember">Couldn't reach the backend: {result._error}</div>
)}
<div className="space-y-3">
{result?.checks?.map((c: any) => (
<div key={c.name} className="card p-4 flex gap-3">
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${c.ok ? "bg-moss" : "bg-ember"}`} />
<div>
<div className="font-medium">{LABELS[c.name] || c.name}</div>
<div className="text-sm text-ink/60 whitespace-pre-wrap">{c.message}</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -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<any>(null);
const [tab, setTab] = useState<"dm" | "player">("dm");
useEffect(() => {
api.getNotes(id!).then(setNotes);
}, [id]);
if (!notes) return null;
return (
<div className="max-w-3xl mx-auto py-16 px-4">
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
<div className="flex gap-2 mb-6">
<button className={tab === "dm" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("dm")}>
DM notes
</button>
<button className={tab === "player" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("player")}>
Player recap
</button>
</div>
<div className="card p-6 whitespace-pre-wrap leading-relaxed">
{tab === "dm" ? notes.dm_notes : notes.player_recap}
</div>
</div>
);
}

View File

@@ -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 (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-emerald-700 text-white px-5 py-3 rounded-xl shadow-2xl text-sm font-medium">
{message}
</div>
);
}
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 <span className="font-mono text-xs">{formatDuration(elapsed)}</span>;
}
const STATUS_LABEL: Record<string, string> = {
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 (
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{job.job_type === 'transcribe' ? 'Transcription' : 'Note generation'}</h3>
<span className={`px-2.5 py-1 text-xs font-bold rounded-full uppercase ${badgeClass}`}>
{job.status === 'error' ? 'Failed' : job.status}
</span>
</div>
{job.status === 'error' ? (
<div className="bg-rose-950/40 border border-rose-900/50 text-rose-300 text-sm p-3 rounded mb-3 font-mono">
<strong>Error{job.error_stage ? ` [${job.error_stage}]` : ''}:</strong> {job.error || 'Unexpected error.'}
</div>
) : (
<p className="text-sm text-ink/60 mb-3">{job.progress || 'Waiting...'}</p>
)}
{job.status !== 'queued' && job.created_at && (
<div className="flex items-center gap-1 text-ink/40 mb-3">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<ElapsedTimer
createdAt={job.created_at}
endedAt={job.status === 'done' || job.status === 'error' ? job.updated_at : undefined}
/>
</div>
)}
{session?.audio_duration && (
<div className="text-xs text-ink/40 mb-3 font-mono">
Audio {formatDuration(session.audio_duration)}
{session.word_count ? ` \u2022 ${session.word_count.toLocaleString()} words` : ''}
{session.language ? ` \u2022 ${session.language}` : ''}
</div>
)}
<div className="flex gap-3">
{(job.status === 'queued' || job.status === 'running') && (
<button disabled={busy} onClick={handleCancel} className="btn-secondary text-sm">
Cancel
</button>
)}
{job.status === 'error' && job.job_type === 'transcribe' && (
<button
disabled={busy}
onClick={async () => { setBusy(true); await api.retryTranscription(job.session_id); setBusy(false); onRefresh(); }}
className="btn-primary text-sm"
>
Restart transcription
</button>
)}
<button disabled={busy} onClick={() => setShowDeleteModal(true)} className="btn-secondary text-sm ml-auto">
Delete...
</button>
</div>
{showDeleteModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
<h4 className="text-lg font-bold mb-2">Clean up session data?</h4>
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
Choose what to remove along with this job's tracking record.
</p>
<div className="space-y-2">
<button onClick={() => executeDeletion('all')} className="w-full text-left card p-3 text-sm">
🗑️ <strong>Wipe everything</strong>
<span className="block text-xs text-ink/40 mt-0.5">Session record, notes, transcript, and the source audio/video.</span>
</button>
<button onClick={() => executeDeletion('artifacts_only')} className="w-full text-left card p-3 text-sm">
📝 <strong>Keep source file</strong>
<span className="block text-xs text-ink/40 mt-0.5">Clears notes/transcript but keeps the original recording.</span>
</button>
<button onClick={() => executeDeletion('none')} className="w-full text-left card p-3 text-sm">
❌ <strong>Just clear this job</strong>
<span className="block text-xs text-ink/40 mt-0.5">Only removes the job tracking row. No files touched.</span>
</button>
</div>
<div className="mt-5 flex justify-end">
<button onClick={() => setShowDeleteModal(false)} className="text-sm text-ink/50 hover:text-ink">
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function SessionDetail() {
const { id } = useParams();
const nav = useNavigate();
const [session, setSession] = useState<any>(null);
const [job, setJob] = useState<Job | null>(null);
const [toast, setToast] = useState<string | null>(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 (
<div className="max-w-2xl mx-auto py-16 px-4">
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
<p className="text-brass mb-8">{STATUS_LABEL[session.status] ?? session.status}</p>
{job && (
<div className="mb-8">
<JobStatusCard job={job} session={session} onRefresh={load} onDeleted={(strategy) => {
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();
}
}} />
</div>
)}
{session.status === 'uploaded' && !job && (
<button className="btn-primary" onClick={retryTranscription}>Start transcription</button>
)}
{session.status === 'complete' && (
<div className="mt-4 flex gap-3">
<button className="btn-secondary" onClick={generateNotes}>Regenerate notes</button>
<Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link>
</div>
)}
</div>
);
}

View File

@@ -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<string, string> = {
uploaded: "Ready to transcribe",
transcribing: "Transcribing...",
transcribed: "Ready to name speakers",
complete: "Notes ready",
};
export default function Sessions() {
const [sessions, setSessions] = useState<any[]>([]);
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(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 (
<div className="max-w-3xl mx-auto py-16 px-4">
<div className="eyebrow mb-2">Nat20 Notes</div>
<h1 className="font-display text-4xl mb-8">Your Transcriptions</h1>
<div className="card p-6 mb-8">
<h2 className="font-display text-xl mb-4">New transcription</h2>
<div className="flex flex-col gap-3">
<input
className="input"
placeholder="Session name, e.g. Session 1 — The Frozen Crypt"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
type="file"
accept="audio/*,video/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="text-sm text-ink/70"
/>
<button className="btn-primary self-start" disabled={!file || !name || uploadProgress !== null} onClick={upload}>
{uploadProgress !== null ? `Uploading ${uploadProgress}%` : "Upload"}
</button>
</div>
</div>
<div className="space-y-3">
{sessions.length === 0 && (
<p className="text-ink/40">No sessions yet upload a recording to get started.</p>
)}
{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 (
<Link key={s.id} to={`/sessions/${s.id}`} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent block">
<div>
<div className="font-medium">{s.name}</div>
<div className="text-xs text-ink/40 font-mono">{s.original_filename}</div>
</div>
<div className={"text-sm " + statusColor}>{displayStatus}</div>
</Link>
);
})}
</div>
<div className="mt-10 flex gap-4">
<Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link>
<Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
</div>
</div>
);
}

View File

@@ -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<any>(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 (
<div className="max-w-xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
<div className="card p-6 space-y-4 mb-6">
<h2 className="eyebrow">Transcription</h2>
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
<option value="tiny">Tiny</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large-v3">Large v3</option>
</select>
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
</div>
<div className="card p-6 space-y-4 mb-6">
<h2 className="eyebrow">Summarization</h2>
<div className="flex gap-3">
<button className={settings.llm_mode === "ollama" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "ollama")}>Local (Ollama)</button>
<button className={settings.llm_mode === "api" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "api")}>Hosted API</button>
</div>
{settings.llm_mode === "ollama" ? (
<>
<input className="input w-full" value={settings.ollama_host} onChange={(e) => set("ollama_host", e.target.value)} />
<input className="input w-full" value={settings.ollama_model} onChange={(e) => set("ollama_model", e.target.value)} />
</>
) : (
<>
<input className="input w-full" value={settings.api_base_url} onChange={(e) => set("api_base_url", e.target.value)} />
<input className="input w-full" type="password" value={settings.api_key} onChange={(e) => set("api_key", e.target.value)} />
<input className="input w-full" value={settings.api_model} onChange={(e) => set("api_model", e.target.value)} />
</>
)}
</div>
<div className="card p-6 space-y-3 mb-6">
<h2 className="eyebrow">Campaign context</h2>
<textarea className="input w-full h-32" value={settings.world_context} onChange={(e) => set("world_context", e.target.value)} placeholder="Paste world context directly..." />
<input className="input w-full" value={settings.world_context_path} onChange={(e) => set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" />
</div>
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import { api, AppSettings } from '../api';
const DEFAULT_SETTINGS: AppSettings = {
onboarding_completed: false,
whisper_model: 'medium',
whisper_compute_type: 'int8',
hf_token: '',
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',
chunk_word_target: '2500',
world_context: '',
world_context_path: '',
};
export default function Setup({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState(1);
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
useEffect(() => {
api.getSettings().then(data => {
if (data) {
setSettings({ ...DEFAULT_SETTINGS, ...data });
}
}).catch(err => {
console.error('Failed to connect to settings endpoint:', err);
});
}, []);
const handleNext = () => setStep(prev => prev + 1);
const handleBack = () => setStep(prev => prev - 1);
const handleSave = async () => {
await api.saveSettings({ ...settings, onboarding_completed: true });
onComplete();
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 flex items-center justify-center p-6">
<div className="w-full max-w-2xl bg-slate-800 border border-slate-700 rounded-xl p-8 shadow-2xl">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-2xl font-bold tracking-tight text-teal-400">Nat20Notes Configuration Wizard</h1>
<span className="text-sm text-slate-400 font-mono">Step {step} of 2</span>
</div>
{step === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2">1. Audio & Diarization Engine</h2>
<p className="text-slate-400 text-sm mb-4">Set up your local Whisper sizing and secure deep speaker parsing models.</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Whisper Model Size</label>
<select
value={settings.whisper_model}
onChange={e => setSettings({ ...settings, whisper_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-white"
>
<option value="tiny">Tiny (~39M params)</option>
<option value="base">Base (~74M params)</option>
<option value="small">Small (~244M params)</option>
<option value="medium">Medium (~769M params)</option>
<option value="large-v3">Large V3 (~1.5B params - Recommended GPU)</option>
</select>
</div>
<div className="bg-slate-900 p-4 rounded border border-slate-700 space-y-3">
<label className="block text-sm font-medium">Hugging Face Access Token</label>
<input
type="password"
placeholder="hf_..."
value={settings.hf_token}
onChange={e => setSettings({ ...settings, hf_token: e.target.value })}
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white font-mono"
/>
<div className="text-xs text-slate-400 leading-relaxed">
💡 <strong>Diarization Requirements:</strong> To differentiate between multiple speakers, you must register a token:
<ol className="list-decimal list-inside ml-2 mt-1 space-y-1">
<li>Create an account on <a href="https://huggingface.co" target="_blank" rel="noreferrer" className="text-teal-400 underline">huggingface.co</a></li>
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/speaker-diarization-3.1" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/speaker-diarization-3.1</a></li>
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/segmentation-3.0" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/segmentation-3.0</a></li>
<li>Generate a <strong>Read</strong> token in Settings Access Tokens.</li>
</ol>
</div>
</div>
<div className="flex justify-end pt-4">
<button onClick={handleNext} className="bg-teal-600 hover:bg-teal-500 text-white px-5 py-2 rounded font-medium">
Continue Next
</button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2">2. Summarization Engine (LLM)</h2>
<p className="text-slate-400 text-sm mb-4">Choose where your post-transcription formatting and smart summaries are generated.</p>
</div>
<div className="flex gap-4 border-b border-slate-700 pb-4">
{(['ollama', 'api'] as const).map(mode => (
<button
key={mode}
onClick={() => setSettings({ ...settings, llm_mode: mode })}
className={`px-4 py-2 rounded uppercase text-xs font-bold tracking-wider ${settings.llm_mode === mode ? 'bg-teal-600 text-white' : 'bg-slate-900 text-slate-400'}`}
>
{mode === 'ollama' ? 'Local (Ollama)' : 'Hosted API'}
</button>
))}
</div>
{settings.llm_mode === 'ollama' ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Ollama Host Address</label>
<input
type="text"
value={settings.ollama_host}
onChange={e => setSettings({ ...settings, ollama_host: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Model Name</label>
<input
type="text"
placeholder="qwen2.5:7b"
value={settings.ollama_model}
onChange={e => setSettings({ ...settings, ollama_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Must match your local CLI entry for running "ollama pull &lt;model&gt;".</p>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">API Base URL</label>
<input
type="text"
value={settings.api_base_url}
onChange={e => setSettings({ ...settings, api_base_url: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">API Key</label>
<input
type="password"
value={settings.api_key}
onChange={e => setSettings({ ...settings, api_key: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-white"
placeholder="sk-..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Model Name</label>
<input
type="text"
value={settings.api_model}
onChange={e => setSettings({ ...settings, api_model: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
/>
</div>
</div>
)}
<div className="border-t border-slate-700 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-300">Campaign context (optional)</h3>
<textarea
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 h-24"
placeholder="Paste world/campaign context directly..."
value={settings.world_context}
onChange={e => setSettings({ ...settings, world_context: e.target.value })}
/>
<input
type="text"
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-sm"
placeholder="...or point to a local file"
value={settings.world_context_path}
onChange={e => setSettings({ ...settings, world_context_path: e.target.value })}
/>
</div>
<div className="flex justify-between pt-4 border-t border-slate-700">
<button onClick={handleBack} className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded font-medium">
Back
</button>
<button onClick={handleSave} className="bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2 rounded font-medium">
Complete Setup & Launch
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { SessionReel, colorFor } from "../components/SessionReel";
export default function Speakers() {
const { id } = useParams();
const nav = useNavigate();
const [speakers, setSpeakers] = useState<any[]>([]);
const [turns, setTurns] = useState<any[]>([]);
const [names, setNames] = useState<Record<string, string>>({});
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [mergeTarget, setMergeTarget] = useState("");
const [busy, setBusy] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const paletteRef = useRef(new Map<string, string>());
const load = () => {
api.getSpeakers(id!).then((sp) => {
setSpeakers(sp);
const n: Record<string, string> = {};
sp.forEach((s: any) => (n[s.raw_label] = s.display_name || ""));
setNames(n);
});
api.getTurns(id!).then(setTurns);
};
useEffect(load, [id]);
const save = async (raw_label: string) => {
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
};
const playAt = (t: number) => {
const audio = audioRef.current;
if (!audio) return;
if (audio.readyState < 2) return;
audio.pause();
audio.currentTime = t;
const p = audio.play();
if (p) p.catch(() => {});
};
const toggleSelected = (label: string) => {
const next = new Set(selected);
if (next.has(label)) next.delete(label); else next.add(label);
setSelected(next);
};
const doMerge = async () => {
if (!mergeTarget || selected.size < 2) return;
setBusy(true);
const sourceLabels = [...selected].filter(l => l !== mergeTarget);
await fetch(`/api/sessions/${id}/speakers/merge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target_label: mergeTarget, source_labels: sourceLabels }),
});
setSelected(new Set());
setMergeTarget("");
setBusy(false);
load();
};
const handleDone = async () => {
setBusy(true);
await api.generateNotes(id!);
setBusy(false);
nav(`/sessions/${id}`);
};
const selectedArr = [...selected];
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">Name your speakers</h1>
<p className="text-ink/60 mb-6">Click anywhere on the reel to jump to that moment and hear who's talking.</p>
<audio
ref={audioRef}
src={api.audioUrl(id!)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
controls
className="w-full mb-3"
/>
<div className="mb-8">
<SessionReel turns={turns} duration={duration} currentTime={currentTime} onSeek={playAt} />
</div>
{selected.size >= 2 && (
<div className="card p-4 mb-6 flex items-center gap-3 flex-wrap">
<span className="text-sm text-ink/60">{selected.size} selected</span>
<select
className="input text-sm flex-1 min-w-[200px]"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
>
<option value="">Merge into...</option>
{selectedArr.map((l) => (
<option key={l} value={l}>{l} {names[l] ? `(${names[l]})` : ""}</option>
))}
</select>
<button className="btn-primary text-sm" disabled={!mergeTarget || busy} onClick={doMerge}>
{busy ? "Merging..." : "Merge"}
</button>
<button className="btn-secondary text-sm" onClick={() => { setSelected(new Set()); setMergeTarget(""); }}>
Cancel
</button>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{speakers.map((sp) => (
<div key={sp.raw_label} className={"card p-4 " + (selected.has(sp.raw_label) ? "ring-2 ring-brass" : "")}>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={selected.has(sp.raw_label)}
onChange={() => toggleSelected(sp.raw_label)}
className="accent-brass"
/>
<span
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
style={{ background: colorFor(sp.raw_label, paletteRef.current) }}
/>
<span className="font-mono text-xs text-ink/40">{sp.raw_label}</span>
</div>
<input
className="input w-full mb-3"
placeholder="Player or character name"
value={names[sp.raw_label] || ""}
onChange={(e) => setNames({ ...names, [sp.raw_label]: e.target.value })}
onBlur={() => save(sp.raw_label)}
/>
<div className="space-y-1">
{sp.samples.map((s: any, i: number) => (
<button
key={i}
onClick={() => playAt(s.start)}
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate"
>
{s.text}
</button>
))}
</div>
</div>
))}
</div>
<div className="mt-8">
<button className="btn-primary" onClick={() => setShowConfirm(true)}>
Done naming
</button>
</div>
{showConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
<h4 className="text-lg font-bold mb-2">Generate notes now?</h4>
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
This will use your speaker names to create session notes. Are you sure?
</p>
<div className="flex gap-3 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancel
</button>
<button disabled={busy} onClick={handleDone} className="btn-primary">
{busy ? "Starting..." : "Yes, generate notes"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

32
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
::selection {
background: #C9A227;
color: #14171F;
}
:focus-visible {
outline: 2px solid #C9A227;
outline-offset: 2px;
}
}
@layer components {
.card {
@apply bg-panel border border-white/5 rounded-lg;
}
.btn-primary {
@apply bg-brass text-deep font-medium px-4 py-2 rounded-md hover:brightness-110 transition disabled:opacity-40 disabled:pointer-events-none;
}
.btn-secondary {
@apply bg-panel2 text-ink font-medium px-4 py-2 rounded-md border border-white/10 hover:border-brass/50 transition;
}
.input {
@apply bg-deep border border-white/10 rounded-md px-3 py-2 text-ink placeholder:text-ink/30 focus:border-brass/60;
}
.eyebrow {
@apply font-mono text-xs uppercase tracking-widest text-brass/80;
}
}

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
deep: "#14171F",
panel: "#1E2230",
panel2: "#262B3D",
ink: "#EDE6D6",
inkdim: "#A9A4956e".slice(0, 7),
brass: "#C9A227",
ember: "#B4523A",
moss: "#5F7A61",
dusk: "#4E6A87",
plum: "#7A5670",
},
fontFamily: {
display: ["Fraunces", "Georgia", "serif"],
body: ["Inter", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "monospace"],
},
},
},
plugins: [],
};

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});