silence trimmer

This commit is contained in:
2026-04-03 12:05:44 -06:00
parent 8a7c94d594
commit d80ff847d8
5 changed files with 284 additions and 4 deletions

View File

@ -0,0 +1,184 @@
import { useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Loader2, Scissors } from 'lucide-react';
type SilenceRange = {
start: number;
end: number;
duration: number;
};
export default function SilenceTrimmerPanel() {
const { videoPath, backendUrl, addCutRange, duration } = useEditorStore();
const [minSilenceMs, setMinSilenceMs] = useState(500);
const [silenceDb, setSilenceDb] = useState(-35);
const [preBufferMs, setPreBufferMs] = useState(80);
const [postBufferMs, setPostBufferMs] = useState(120);
const [isDetecting, setIsDetecting] = useState(false);
const [ranges, setRanges] = useState<SilenceRange[]>([]);
const detectSilence = async () => {
if (!videoPath) return;
setIsDetecting(true);
setRanges([]);
try {
const res = await fetch(`${backendUrl}/audio/detect-silence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_path: videoPath,
min_silence_ms: minSilenceMs,
silence_db: silenceDb,
}),
});
if (!res.ok) {
let detail = `HTTP ${res.status} ${res.statusText}`;
try {
const err = await res.json();
if (err?.detail) detail += ` - ${String(err.detail)}`;
} catch {
// ignore JSON parse errors for non-JSON error responses
}
if (res.status === 404) {
detail += ' (endpoint missing: restart backend to load /audio/detect-silence)';
}
throw new Error(detail);
}
const data = await res.json();
setRanges(data.ranges || []);
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : 'Unknown error';
alert(`Silence detection failed: ${message}`);
} finally {
setIsDetecting(false);
}
};
const applyAsCuts = () => {
const preBufferSeconds = preBufferMs / 1000;
const postBufferSeconds = postBufferMs / 1000;
const maxEnd = duration > 0 ? duration : Number.POSITIVE_INFINITY;
for (const r of ranges) {
// Positive buffers shrink the cut, negative buffers expand it.
const start = Math.max(0, r.start + preBufferSeconds);
const end = Math.min(maxEnd, r.end - postBufferSeconds);
if (end - start >= 0.01) {
addCutRange(start, end);
}
}
};
return (
<div className="p-4 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Silence / Pause Trimmer</h3>
<p className="text-xs text-editor-text-muted">
Detect pauses and convert them into cut ranges.
</p>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Minimum pause length (ms)
</label>
<input
type="number"
min={100}
step={50}
value={minSilenceMs}
onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Silence threshold (dB)
</label>
<input
type="number"
min={-80}
max={0}
step={1}
value={silenceDb}
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Buffer before (ms, +shrink / -expand)
</label>
<input
type="number"
min={-5000}
max={5000}
step={10}
value={preBufferMs}
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Buffer after (ms, +shrink / -expand)
</label>
<input
type="number"
min={-5000}
max={5000}
step={10}
value={postBufferMs}
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
</div>
<button
onClick={detectSilence}
disabled={isDetecting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isDetecting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Detecting pauses...
</>
) : (
'Detect Pauses'
)}
</button>
</div>
{ranges.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Detected {ranges.length} pause ranges</span>
<button
onClick={applyAsCuts}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
>
<Scissors className="w-3 h-3" />
Apply As Cuts
</button>
</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
{ranges.slice(0, 50).map((r, i) => (
<div key={`${r.start}-${r.end}-${i}`} className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs">
{r.start.toFixed(2)}s - {r.end.toFixed(2)}s ({r.duration.toFixed(2)}s)
</div>
))}
</div>
</div>
)}
</div>
);
}