fixed error

This commit is contained in:
2026-04-09 01:50:19 -06:00
parent 1d17a8f19a
commit 140b7a5319
2 changed files with 126 additions and 54 deletions

View File

@ -122,13 +122,13 @@ async def get_waveform_audio(request: Request, path: str = Query(...)):
tmp_dir = tempfile.mkdtemp(prefix="talkedit_waveform_") tmp_dir = tempfile.mkdtemp(prefix="talkedit_waveform_")
out_wav = Path(tmp_dir) / f"{cache_key}.wav" out_wav = Path(tmp_dir) / f"{cache_key}.wav"
# Downsample to mono 22050 Hz — enough for waveform drawing, small file # Downsample to mono 8000 Hz — enough for waveform drawing and much smaller payloads
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-i", str(file_path), "-i", str(file_path),
"-vn", # drop video "-vn", # drop video
"-ac", "1", # mono "-ac", "1", # mono
"-ar", "22050", # 22 kHz sample rate "-ar", "8000", # 8 kHz sample rate
"-acodec", "pcm_s16le", # 16-bit PCM WAV "-acodec", "pcm_s16le", # 16-bit PCM WAV
str(out_wav), str(out_wav),
] ]

View File

@ -4,6 +4,85 @@ import { AlertTriangle } from 'lucide-react';
const RULER_H = 20; // px reserved at top of canvas for the time ruler const RULER_H = 20; // px reserved at top of canvas for the time ruler
type WaveformData = {
samples: Float32Array;
sampleRate: number;
duration: number;
};
function parsePcm16Wav(arrayBuffer: ArrayBuffer): WaveformData {
const view = new DataView(arrayBuffer);
if (view.byteLength < 44) {
throw new Error('WAV file too small');
}
const text = (offset: number, length: number) => {
let s = '';
for (let i = 0; i < length; i++) {
s += String.fromCharCode(view.getUint8(offset + i));
}
return s;
};
if (text(0, 4) !== 'RIFF' || text(8, 4) !== 'WAVE') {
throw new Error('Not a RIFF/WAVE file');
}
let fmtOffset = -1;
let dataOffset = -1;
let dataSize = 0;
let offset = 12;
while (offset + 8 <= view.byteLength) {
const chunkId = text(offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const chunkDataStart = offset + 8;
if (chunkId === 'fmt ') {
fmtOffset = chunkDataStart;
} else if (chunkId === 'data') {
dataOffset = chunkDataStart;
dataSize = chunkSize;
break;
}
offset = chunkDataStart + chunkSize + (chunkSize % 2);
}
if (fmtOffset < 0 || dataOffset < 0) {
throw new Error('Missing WAV fmt/data chunk');
}
const audioFormat = view.getUint16(fmtOffset, true);
const channels = view.getUint16(fmtOffset + 2, true);
const sampleRate = view.getUint32(fmtOffset + 4, true);
const bitsPerSample = view.getUint16(fmtOffset + 14, true);
if (audioFormat !== 1 || bitsPerSample !== 16) {
throw new Error(`Unsupported WAV format (format=${audioFormat}, bits=${bitsPerSample})`);
}
if (channels < 1) {
throw new Error('Invalid channel count in WAV');
}
const bytesPerSample = bitsPerSample / 8;
const frameCount = Math.floor(dataSize / (channels * bytesPerSample));
const samples = new Float32Array(frameCount);
let p = dataOffset;
for (let i = 0; i < frameCount; i++) {
const sample = view.getInt16(p, true);
samples[i] = sample / 32768;
p += channels * bytesPerSample;
}
return {
samples,
sampleRate,
duration: frameCount / sampleRate,
};
}
function formatTime(secs: number): string { function formatTime(secs: number): string {
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
const s = secs % 60; const s = secs % 60;
@ -45,8 +124,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const audioContextRef = useRef<AudioContext | null>(null); const waveformDataRef = useRef<WaveformData | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
const scrollSecsRef = useRef(0); // seconds scrolled from left const scrollSecsRef = useRef(0); // seconds scrolled from left
const rafRef = useRef(0); const rafRef = useRef(0);
@ -66,17 +144,18 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
if (!videoUrl || !videoPath) return; if (!videoUrl || !videoPath) return;
setAudioError(null); setAudioError(null);
let cancelled = false;
const controller = new AbortController();
const loadAudio = async () => { const loadAudio = async () => {
const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
try { try {
const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`; const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`;
console.log('[WaveformTimeline] req=', requestId, 'backendUrl=', backendUrl, 'videoPath=', videoPath); console.log('[WaveformTimeline] req=', requestId, 'backendUrl=', backendUrl, 'videoPath=', videoPath);
console.log('[WaveformTimeline] req=', requestId, 'fetching=', waveformUrl); console.log('[WaveformTimeline] req=', requestId, 'fetching=', waveformUrl);
const ctx = new AudioContext();
audioContextRef.current = ctx;
const startedAt = performance.now(); const startedAt = performance.now();
const response = await fetch(waveformUrl); const response = await fetch(waveformUrl, { signal: controller.signal });
if (cancelled) return;
const elapsedMs = Math.round(performance.now() - startedAt); const elapsedMs = Math.round(performance.now() - startedAt);
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ''); const body = await response.text().catch(() => '');
@ -99,39 +178,27 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
); );
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
console.log(`[WaveformTimeline] req=${requestId} arrayBuffer size: ${arrayBuffer.byteLength} bytes`); console.log(`[WaveformTimeline] req=${requestId} arrayBuffer size: ${arrayBuffer.byteLength} bytes`);
if (arrayBuffer.byteLength === 0) { if (arrayBuffer.byteLength === 0) {
throw new Error('Server returned an empty file'); throw new Error('Server returned an empty file');
} }
let audioBuffer: AudioBuffer; const waveformData = parsePcm16Wav(arrayBuffer);
try {
audioBuffer = await ctx.decodeAudioData(arrayBuffer);
} catch (decodeErr) {
console.error(
'[WaveformTimeline] decodeAudioData failed — browser cannot decode this format.',
{
requestId,
contentType,
byteLength: arrayBuffer.byteLength,
videoPath,
error: decodeErr,
}
);
throw new Error(
`Browser could not decode audio (${contentType}). ` +
`For best compatibility use MP4/AAC or WebM/Opus. Raw error: ${decodeErr}`
);
}
console.log( console.log(
`[WaveformTimeline] req=${requestId} decoded ok — duration: ${audioBuffer.duration.toFixed(2)}s, ` + `[WaveformTimeline] req=${requestId} parsed wav ok — duration: ${waveformData.duration.toFixed(2)}s, ` +
`channels: ${audioBuffer.numberOfChannels}, sampleRate: ${audioBuffer.sampleRate}Hz` `sampleRate: ${waveformData.sampleRate}Hz, samples: ${waveformData.samples.length}`
); );
audioBufferRef.current = audioBuffer; if (cancelled) return;
waveformDataRef.current = waveformData;
drawStaticWaveform(); drawStaticWaveform();
} catch (err) { } catch (err) {
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
return;
}
console.error('[WaveformTimeline] waveform load failed', { console.error('[WaveformTimeline] waveform load failed', {
requestId, requestId,
error: err, error: err,
@ -147,14 +214,15 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
loadAudio(); loadAudio();
return () => { return () => {
audioContextRef.current?.close(); cancelled = true;
controller.abort();
}; };
}, [videoUrl, videoPath, backendUrl]); }, [videoUrl, videoPath, backendUrl]);
const drawStaticWaveform = useCallback(() => { const drawStaticWaveform = useCallback(() => {
const canvas = waveCanvasRef.current; const canvas = waveCanvasRef.current;
const buffer = audioBufferRef.current; const waveformData = waveformDataRef.current;
if (!canvas || !buffer) return; if (!canvas || !waveformData) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
@ -167,12 +235,12 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const width = rect.width; const width = rect.width;
const height = rect.height; const height = rect.height;
const dur = buffer.duration; const dur = waveformData.duration;
const zoom = zoomRef.current; const zoom = zoomRef.current;
const scroll = scrollSecsRef.current; const scroll = scrollSecsRef.current;
const pxPerSec = (width * zoom) / dur; const pxPerSec = (width * zoom) / dur;
const sampleRate = buffer.sampleRate; const sampleRate = waveformData.sampleRate;
const channelData = buffer.getChannelData(0); const channelData = waveformData.samples;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
@ -344,9 +412,8 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const ctx = headCanvas.getContext('2d'); const ctx = headCanvas.getContext('2d');
if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; } if (!ctx) { rafRef.current = requestAnimationFrame(tick); return; }
const buffer = audioBufferRef.current;
const video = document.querySelector('video') as HTMLVideoElement | null; const video = document.querySelector('video') as HTMLVideoElement | null;
const dur = buffer?.duration ?? 0; const dur = waveformDataRef.current?.duration ?? 0;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = headCanvas.getBoundingClientRect(); const rect = headCanvas.getBoundingClientRect();
@ -399,10 +466,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
const buffer = audioBufferRef.current;
const canvas = waveCanvasRef.current; const canvas = waveCanvasRef.current;
if (!buffer || !canvas) return; if (!canvas) return;
const dur = buffer.duration; const dur = waveformDataRef.current?.duration;
if (!dur) return;
const width = canvas.getBoundingClientRect().width; const width = canvas.getBoundingClientRect().width;
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@ -428,37 +495,37 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
}, [drawStaticWaveform]); }, [drawStaticWaveform]);
const seekToClientX = useCallback((clientX: number) => { const seekToClientX = useCallback((clientX: number) => {
const buffer = audioBufferRef.current;
const canvas = headCanvasRef.current; const canvas = headCanvasRef.current;
if (!canvas || !buffer) return; const dur = waveformDataRef.current?.duration;
if (!canvas || !dur) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left; const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / buffer.duration; const pxPerSec = (rect.width * zoomRef.current) / dur;
const newTime = Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec)); const newTime = Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
setCurrentTime(newTime); setCurrentTime(newTime);
const video = document.querySelector('video') as HTMLVideoElement | null; const video = document.querySelector('video') as HTMLVideoElement | null;
if (video) video.currentTime = newTime; if (video) video.currentTime = newTime;
}, [setCurrentTime]); }, [setCurrentTime]);
const clientXToTime = useCallback((clientX: number): number => { const clientXToTime = useCallback((clientX: number): number => {
const buffer = audioBufferRef.current;
const canvas = headCanvasRef.current; const canvas = headCanvasRef.current;
if (!canvas || !buffer) return 0; const dur = waveformDataRef.current?.duration;
if (!canvas || !dur) return 0;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left; const x = clientX - rect.left;
const pxPerSec = (rect.width * zoomRef.current) / buffer.duration; const pxPerSec = (rect.width * zoomRef.current) / dur;
return Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec)); return Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
}, []); }, []);
const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => { const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => {
const buffer = audioBufferRef.current; const dur = waveformDataRef.current?.duration;
const canvas = waveCanvasRef.current; const canvas = waveCanvasRef.current;
if (!canvas || !buffer) return null; if (!canvas || !dur) return null;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left; const x = clientX - rect.left;
const y = clientY - rect.top; const y = clientY - rect.top;
const pxPerSec = (rect.width * zoomRef.current) / buffer.duration; const pxPerSec = (rect.width * zoomRef.current) / dur;
const scroll = scrollSecsRef.current; const scroll = scrollSecsRef.current;
const waveTop = RULER_H + 1; const waveTop = RULER_H + 1;
const waveH = canvas.height - waveTop; const waveH = canvas.height - waveTop;
@ -760,9 +827,14 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
</span> </span>
</div> </div>
{audioError ? ( {audioError ? (
<div className="flex-1 flex items-center justify-center gap-2 text-editor-text-muted text-xs"> <div className="flex-1 flex items-start justify-center gap-2 text-editor-text-muted text-xs p-3 overflow-auto">
<AlertTriangle className="w-4 h-4 text-yellow-500" /> <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
<span>{audioError}</span> <pre
className="select-text cursor-text whitespace-pre-wrap break-all leading-relaxed"
title="Highlight this text to copy"
>
{audioError}
</pre>
</div> </div>
) : ( ) : (
<div className="flex-1 relative"> <div className="flex-1 relative">