Preload audio as Blob for instant seeking; generate Opus playback file; delete original upload after extraction
Some checks failed
Build and Push / build (push) Has been cancelled
Some checks failed
Build and Push / build (push) Has been cancelled
- Download full audio as Blob on mount so seeking is instant (no network range-requests) - Remove readyState guard from playAt() that was silently blocking seek attempts - Add disabled/ready state to SessionReel for reliable click-to-seek - Generate 48kbps Opus playback file during extraction (~65MB for 3hrs vs 1.5GB lossless) - Serve Opus first in audio endpoint, then original upload, then extracted WAV - Delete original upload after successful transcription to reclaim storage - Reorder extract_audio checks so retry works without the original file - Fix Back links on Speakers and Notes pages to go to home
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user