diff --git a/backend/app/main.py b/backend/app/main.py index 56e8e74..7260cce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -84,13 +84,21 @@ def get_audio(session_id: str): if not row: raise HTTPException(404, "Session not found") - # Prefer the original uploaded file — it's a compressed format the browser - # can seek in efficiently. Fall back to the extracted WAV. + # Priority: Opus playback > original upload > extracted WAV. + # The Opus file is tiny (~65 MB for 3 hours) so the frontend's Blob + # download completes in seconds on any modern connection. candidates = [] + + if row["audio_path"]: + opus = Path(row["audio_path"]).with_suffix(".opus") + if opus.exists(): + candidates.append(opus) + if row["video_path"]: p = Path(row["video_path"]) if p.exists(): candidates.append(p) + if row["audio_path"]: p = Path(row["audio_path"]) if p.exists(): diff --git a/backend/app/pipeline/audio.py b/backend/app/pipeline/audio.py index 299129e..c4d8f6d 100644 --- a/backend/app/pipeline/audio.py +++ b/backend/app/pipeline/audio.py @@ -9,13 +9,13 @@ 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 + 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.") + log.info("Extracting audio: %s -> %s", video_path, audio_path) result = subprocess.run( [ @@ -33,3 +33,30 @@ def extract_audio(video_path: Path, audio_path: Path) -> Path: f"This usually means the file is corrupt or not a supported format. Last output:\n{stderr_tail}" ) return audio_path + + +def generate_playback_audio(video_path: Path, playback_path: Path) -> Path | None: + """Generate a compressed Opus file for browser playback (~48 kbps). + + This is done alongside the WAV extraction so the frontend can download a + small file for instant-seeking Blob playback. Existing sessions that lack + the Opus file will fall back to the original upload or the extracted WAV. + """ + if playback_path.exists(): + log.info("Playback audio already exists at %s, skipping", playback_path) + return playback_path + + log.info("Generating playback audio: %s -> %s", video_path, playback_path) + result = subprocess.run( + [ + "ffmpeg", "-y", "-i", str(video_path), + "-vn", "-c:a", "libopus", "-b:a", "48k", + str(playback_path), + ], + capture_output=True, text=True, + ) + if result.returncode != 0: + stderr_tail = "\n".join(result.stderr.strip().splitlines()[-5:]) + log.warning("Failed to generate playback audio (non-fatal): %s", stderr_tail) + return None + return playback_path diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index e7f8ddc..23b34f5 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, UploadFile, File, Form, HTTPException from .. import database as db, config, jobs from ..config import merge_campaign_settings_with_env from ..errors import PipelineError -from ..pipeline.audio import extract_audio +from ..pipeline.audio import extract_audio, generate_playback_audio from ..pipeline.transcribe import transcribe_and_diarize @@ -96,6 +96,7 @@ def _run_transcription(session_id: str, video_path: Path, audio_path: Path, tran progress_cb("Extracting audio...") try: extract_audio(video_path, audio_path) + generate_playback_audio(video_path, audio_path.with_suffix(".opus")) transcribe_and_diarize( audio_path, transcript_path, @@ -114,6 +115,16 @@ def _run_transcription(session_id: str, video_path: Path, audio_path: Path, tran (str(audio_path), str(transcript_path), session_id), ) + # Original upload no longer needed — Opus serves playback, WAV serves + # re-transcription. Reclaim storage. + if video_path.exists(): + try: + video_path.unlink() + with db.tx() as conn: + conn.execute("UPDATE sessions SET video_path = NULL WHERE id = ?", (session_id,)) + except OSError: + pass # non-fatal + job_id = db.create_job(session_id, "transcribe") jobs.submit(job_id, run, requires_gpu=True) return job_id diff --git a/frontend/src/components/SessionReel.tsx b/frontend/src/components/SessionReel.tsx index b29c741..770f186 100644 --- a/frontend/src/components/SessionReel.tsx +++ b/frontend/src/components/SessionReel.tsx @@ -14,35 +14,44 @@ export function SessionReel({ duration, onSeek, currentTime, + disabled, }: { turns: { start: number; end: number; raw_speaker: string }[]; duration: number; onSeek: (t: number) => void; currentTime: number; + disabled?: boolean; }) { const palette = useMemo(() => new Map(), []); const trackRef = useRef(null); - const valid = duration > 0 && isFinite(duration); + const ready = duration > 0 && isFinite(duration); + const canSeek = ready && !disabled; const handleClick = (e: React.MouseEvent) => { - if (!trackRef.current || !valid) return; + if (!trackRef.current || !canSeek) 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; + const playheadPct = ready ? (currentTime / duration) * 100 : 0; return (
- {valid && + {ready && turns.map((t, i) => { const left = (t.start / duration) * 100; const width = Math.max(((t.end - t.start) / duration) * 100, 0.15); @@ -55,10 +64,12 @@ export function SessionReel({ /> ); })} -
+ {ready && ( +
+ )}
); } diff --git a/frontend/src/pages/Notes.tsx b/frontend/src/pages/Notes.tsx index fedbb07..25b85a9 100644 --- a/frontend/src/pages/Notes.tsx +++ b/frontend/src/pages/Notes.tsx @@ -15,7 +15,7 @@ export default function Notes() { return (
- ← Back + ← Back

Session notes

diff --git a/frontend/src/pages/Speakers.tsx b/frontend/src/pages/Speakers.tsx index 43b3848..3fbc777 100644 --- a/frontend/src/pages/Speakers.tsx +++ b/frontend/src/pages/Speakers.tsx @@ -17,6 +17,12 @@ export default function Speakers() { const [showConfirm, setShowConfirm] = useState(false); const audioRef = useRef(null); const paletteRef = useRef(new Map()); + const blobUrlRef = useRef(null); + + const [audioLoading, setAudioLoading] = useState(true); + const [audioProgress, setAudioProgress] = useState(0); + const [totalKnown, setTotalKnown] = useState(false); + const [audioError, setAudioError] = useState(null); const load = () => { api.getSpeakers(id!).then((sp) => { @@ -29,15 +35,78 @@ export default function Speakers() { }; useEffect(load, [id]); + useEffect(() => { + if (!id) return; + setAudioLoading(true); + setAudioProgress(0); + setTotalKnown(false); + setAudioError(null); + let cancelled = false; + const url = api.audioUrl(id); + + (async () => { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const total = parseInt(res.headers.get("Content-Length") || "0", 10); + setTotalKnown(total > 0); + const reader = res.body!.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (total) setAudioProgress(received / total); + } + if (cancelled) return; + + const blob = new Blob(chunks, { type: res.headers.get("Content-Type") || "audio/mpeg" }); + const blobUrl = URL.createObjectURL(blob); + blobUrlRef.current = blobUrl; + + const audio = audioRef.current; + if (!audio || cancelled) { + URL.revokeObjectURL(blobUrl); + return; + } + + audio.src = blobUrl; + audio.load(); + + await new Promise((resolve, reject) => { + audio.onloadedmetadata = () => resolve(); + audio.onerror = () => reject(new Error("Audio decode failed")); + }); + + if (!cancelled) setAudioLoading(false); + } catch (err: any) { + if (!cancelled) { + setAudioError(err.message || "Failed to load audio"); + setAudioLoading(false); + } + } + })(); + + return () => { + cancelled = true; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, [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(); + if (!audio || audioLoading) return; audio.currentTime = t; const p = audio.play(); if (p) p.catch(() => {}); @@ -75,13 +144,40 @@ export default function Speakers() { return (
- ← Back + ← Back

Name your speakers

Click anywhere on the reel to jump to that moment and hear who's talking.

+ {audioLoading && !audioError && ( +
+
+ Loading audio for instant playback… + {totalKnown && ( + {Math.round(audioProgress * 100)}% + )} +
+
+
+
+
+ )} + + {audioError && ( +
+

Failed to load audio: {audioError}

+
+ )} +