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:
|
if not row:
|
||||||
raise HTTPException(404, "Session not found")
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
# Prefer the original uploaded file — it's a compressed format the browser
|
# Priority: Opus playback > original upload > extracted WAV.
|
||||||
# can seek in efficiently. Fall back to the 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 = []
|
candidates = []
|
||||||
|
|
||||||
|
if row["audio_path"]:
|
||||||
|
opus = Path(row["audio_path"]).with_suffix(".opus")
|
||||||
|
if opus.exists():
|
||||||
|
candidates.append(opus)
|
||||||
|
|
||||||
if row["video_path"]:
|
if row["video_path"]:
|
||||||
p = Path(row["video_path"])
|
p = Path(row["video_path"])
|
||||||
if p.exists():
|
if p.exists():
|
||||||
candidates.append(p)
|
candidates.append(p)
|
||||||
|
|
||||||
if row["audio_path"]:
|
if row["audio_path"]:
|
||||||
p = Path(row["audio_path"])
|
p = Path(row["audio_path"])
|
||||||
if p.exists():
|
if p.exists():
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ log = get_logger(__name__)
|
|||||||
|
|
||||||
def extract_audio(video_path: Path, audio_path: Path) -> Path:
|
def extract_audio(video_path: Path, audio_path: Path) -> Path:
|
||||||
"""Extract 16kHz mono wav from any video/audio container."""
|
"""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():
|
if audio_path.exists():
|
||||||
log.info("Audio already extracted at %s, skipping", audio_path)
|
log.info("Audio already extracted at %s, skipping", audio_path)
|
||||||
return 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)
|
log.info("Extracting audio: %s -> %s", video_path, audio_path)
|
||||||
result = subprocess.run(
|
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}"
|
f"This usually means the file is corrupt or not a supported format. Last output:\n{stderr_tail}"
|
||||||
)
|
)
|
||||||
return audio_path
|
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 .. import database as db, config, jobs
|
||||||
from ..config import merge_campaign_settings_with_env
|
from ..config import merge_campaign_settings_with_env
|
||||||
from ..errors import PipelineError
|
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
|
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...")
|
progress_cb("Extracting audio...")
|
||||||
try:
|
try:
|
||||||
extract_audio(video_path, audio_path)
|
extract_audio(video_path, audio_path)
|
||||||
|
generate_playback_audio(video_path, audio_path.with_suffix(".opus"))
|
||||||
transcribe_and_diarize(
|
transcribe_and_diarize(
|
||||||
audio_path,
|
audio_path,
|
||||||
transcript_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),
|
(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")
|
job_id = db.create_job(session_id, "transcribe")
|
||||||
jobs.submit(job_id, run, requires_gpu=True)
|
jobs.submit(job_id, run, requires_gpu=True)
|
||||||
return job_id
|
return job_id
|
||||||
|
|||||||
@@ -14,35 +14,44 @@ export function SessionReel({
|
|||||||
duration,
|
duration,
|
||||||
onSeek,
|
onSeek,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
turns: { start: number; end: number; raw_speaker: string }[];
|
turns: { start: number; end: number; raw_speaker: string }[];
|
||||||
duration: number;
|
duration: number;
|
||||||
onSeek: (t: number) => void;
|
onSeek: (t: number) => void;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const palette = useMemo(() => new Map<string, string>(), []);
|
const palette = useMemo(() => new Map<string, string>(), []);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const valid = duration > 0 && isFinite(duration);
|
const ready = duration > 0 && isFinite(duration);
|
||||||
|
const canSeek = ready && !disabled;
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (!trackRef.current || !valid) return;
|
if (!trackRef.current || !canSeek) return;
|
||||||
const rect = trackRef.current.getBoundingClientRect();
|
const rect = trackRef.current.getBoundingClientRect();
|
||||||
const frac = (e.clientX - rect.left) / rect.width;
|
const frac = (e.clientX - rect.left) / rect.width;
|
||||||
onSeek(Math.max(0, Math.min(duration, frac * duration)));
|
onSeek(Math.max(0, Math.min(duration, frac * duration)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const playheadPct = valid ? (currentTime / duration) * 100 : 0;
|
const playheadPct = ready ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={"relative h-14 rounded-md border overflow-hidden " + (valid ? "bg-deep border-white/10 cursor-pointer" : "bg-panel2 border-white/5")}
|
className={
|
||||||
|
"relative h-14 rounded-md border overflow-hidden transition " +
|
||||||
|
(canSeek
|
||||||
|
? "bg-deep border-white/10 cursor-pointer hover:border-brass/40"
|
||||||
|
: "bg-panel2 border-white/5 cursor-default")
|
||||||
|
}
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-label="Session timeline"
|
aria-label="Session timeline"
|
||||||
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
{valid &&
|
{ready &&
|
||||||
turns.map((t, i) => {
|
turns.map((t, i) => {
|
||||||
const left = (t.start / duration) * 100;
|
const left = (t.start / duration) * 100;
|
||||||
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
|
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
|
||||||
@@ -55,10 +64,12 @@ export function SessionReel({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div
|
{ready && (
|
||||||
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)]"
|
<div
|
||||||
style={{ left: `${playheadPct}%` }}
|
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)] pointer-events-none transition-[left]"
|
||||||
/>
|
style={{ left: `${playheadPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Notes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto py-16 px-4">
|
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||||
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
|
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export default function Speakers() {
|
|||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const paletteRef = useRef(new Map<string, string>());
|
const paletteRef = useRef(new Map<string, string>());
|
||||||
|
const blobUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const [audioLoading, setAudioLoading] = useState(true);
|
||||||
|
const [audioProgress, setAudioProgress] = useState(0);
|
||||||
|
const [totalKnown, setTotalKnown] = useState(false);
|
||||||
|
const [audioError, setAudioError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
api.getSpeakers(id!).then((sp) => {
|
api.getSpeakers(id!).then((sp) => {
|
||||||
@@ -29,15 +35,78 @@ export default function Speakers() {
|
|||||||
};
|
};
|
||||||
useEffect(load, [id]);
|
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<void>((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) => {
|
const save = async (raw_label: string) => {
|
||||||
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
|
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const playAt = (t: number) => {
|
const playAt = (t: number) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio || audioLoading) return;
|
||||||
if (audio.readyState < 2) return;
|
|
||||||
audio.pause();
|
|
||||||
audio.currentTime = t;
|
audio.currentTime = t;
|
||||||
const p = audio.play();
|
const p = audio.play();
|
||||||
if (p) p.catch(() => {});
|
if (p) p.catch(() => {});
|
||||||
@@ -75,13 +144,40 @@ export default function Speakers() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-16 px-4">
|
<div className="max-w-4xl mx-auto py-16 px-4">
|
||||||
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
<h1 className="font-display text-3xl mt-4 mb-2">Name your speakers</h1>
|
<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>
|
<p className="text-ink/60 mb-6">Click anywhere on the reel to jump to that moment and hear who's talking.</p>
|
||||||
|
|
||||||
|
{audioLoading && !audioError && (
|
||||||
|
<div className="card p-4 mb-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-ink/60">Loading audio for instant playback…</span>
|
||||||
|
{totalKnown && (
|
||||||
|
<span className="text-xs font-mono text-ink/40">{Math.round(audioProgress * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-deep overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"h-full rounded-full bg-brass " +
|
||||||
|
(totalKnown
|
||||||
|
? "transition-all duration-200"
|
||||||
|
: "w-1/2 animate-pulse rounded-full")
|
||||||
|
}
|
||||||
|
style={totalKnown ? { width: `${audioProgress * 100}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{audioError && (
|
||||||
|
<div className="card p-4 mb-3 border-red/40">
|
||||||
|
<p className="text-sm text-red-400">Failed to load audio: {audioError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={api.audioUrl(id!)}
|
|
||||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||||
controls
|
controls
|
||||||
@@ -89,7 +185,13 @@ export default function Speakers() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<SessionReel turns={turns} duration={duration} currentTime={currentTime} onSeek={playAt} />
|
<SessionReel
|
||||||
|
turns={turns}
|
||||||
|
duration={duration}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onSeek={playAt}
|
||||||
|
disabled={audioLoading || !!audioError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected.size >= 2 && (
|
{selected.size >= 2 && (
|
||||||
@@ -142,7 +244,8 @@ export default function Speakers() {
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => playAt(s.start)}
|
onClick={() => playAt(s.start)}
|
||||||
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate"
|
disabled={audioLoading}
|
||||||
|
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate disabled:opacity-30 disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
▶ {s.text}
|
▶ {s.text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user