Claude 6ba9cef03f
Add remove_silence step to edit_video
Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-05 09:52:53 +00:00

212 lines
7.6 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { EditVideoStep } from "@rakazo/contracts";
import { runFfmpeg } from "./ffmpeg.js";
import type { VideoEditPlanRequest } from "./video-edit-plan.js";
import { planVideoEdit } from "./video-edit-plan.js";
/**
* Silence detection for `remove_silence` and `analyze_video`. ffmpeg's silencedetect marks
* silences in frame metadata, which `ametadata` prints to a file in the job's directory; the
* file holds nothing but frame times and numbers, unlike stderr with its interleaved logs.
*/
export interface TimeRange {
start: number;
end: number;
}
/** Shortest piece of speech kept on its own; shorter blips between two pauses are noise. */
const MIN_SPEECH_SECONDS = 0.1;
/** Most pieces one remove_silence step keeps, so the filter graph stays a sensible size. */
export const MAX_SPEECH_SEGMENTS = 200;
/** Closer to the end of the timeline than this counts as reaching it. */
const EDGE_SECONDS = 0.01;
/** One frame of `metadata=mode=print` output: its time and the values set on it. */
export interface FrameMetadata {
time: number;
values: Record<string, string>;
}
/**
* Parses the output of the `metadata`/`ametadata` filters in print mode:
* `frame:N pts:P pts_time:T` lines, each followed by `key=value` lines.
*/
export function parseFrameMetadata(text: string): FrameMetadata[] {
const frames: FrameMetadata[] = [];
let current: FrameMetadata | undefined;
for (const raw of text.split("\n")) {
const line = raw.trim();
const frame = /^frame:\s*\d+\s+pts:\s*\S+\s+pts_time:\s*(\S+)/.exec(line);
if (frame) {
const time = Number(frame[1]);
current = Number.isFinite(time) ? { time, values: {} } : undefined;
if (current) frames.push(current);
continue;
}
const separator = line.indexOf("=");
if (current && separator > 0) {
current.values[line.slice(0, separator)] = line.slice(separator + 1);
}
}
return frames;
}
/** Filters that detect silence and print it to `file`, a generated name in ffmpeg's cwd. */
export function silenceDetectFilters(
options: { thresholdDb: number; minSilence: number },
file: string,
): string[] {
return [
`silencedetect=noise=${number(options.thresholdDb)}dB:duration=${number(options.minSilence)}`,
`ametadata=mode=print:file=${file}`,
];
}
/**
* Silences from silencedetect's printed metadata, sorted and clamped to `[0, duration]`. A
* silence still running when the audio ends has no end mark and lasts to the end.
*/
export function parseSilences(text: string, duration: number): TimeRange[] {
const silences: TimeRange[] = [];
let start: number | undefined;
const add = (from: number, to: number) => {
const range = { start: clamp(from, 0, duration), end: clamp(to, 0, duration) };
if (range.end > range.start) silences.push(range);
};
for (const frame of parseFrameMetadata(text)) {
const begins = Number(frame.values["lavfi.silence_start"]);
const ends = Number(frame.values["lavfi.silence_end"]);
// One frame can end a silence and start the next one; the end belongs to the earlier one.
if (frame.values["lavfi.silence_end"] !== undefined && Number.isFinite(ends)) {
add(start ?? 0, ends);
start = undefined;
}
if (frame.values["lavfi.silence_start"] !== undefined && Number.isFinite(begins)) {
start = begins;
}
}
if (start !== undefined) add(start, duration);
return mergeRanges(silences);
}
/**
* The parts of `[0, duration]` that are not silent, each widened by `padding` on both sides,
* clamped to the timeline and merged where the padding closes a gap. Null when nothing would
* be cut; throws when nothing would be left.
*/
export function speechSegments(
silences: readonly TimeRange[],
duration: number,
options: { padding: number; thresholdDb: number; maxSegments?: number },
): TimeRange[] | null {
const speech: TimeRange[] = [];
let cursor = 0;
for (const silence of mergeRanges(silences)) {
if (silence.start > cursor) speech.push({ start: cursor, end: silence.start });
cursor = Math.max(cursor, silence.end);
}
if (cursor < duration) speech.push({ start: cursor, end: duration });
const padded = mergeRanges(
speech.map((range) => ({
start: clamp(range.start - options.padding, 0, duration),
end: clamp(range.end + options.padding, 0, duration),
})),
).filter((range) => range.end - range.start >= MIN_SPEECH_SECONDS);
if (padded.length === 0) {
throw new Error(
`remove_silence found nothing louder than ${number(options.thresholdDb)} dB, so nothing would be left.`,
);
}
const kept = bridgeShortestGaps(padded, options.maxSegments ?? MAX_SPEECH_SEGMENTS);
const [only] = kept;
if (
kept.length === 1 &&
only &&
only.start <= EDGE_SECONDS &&
only.end >= duration - EDGE_SECONDS
) {
return null;
}
return kept;
}
/**
* Detects the silence each remove_silence step sees, in step order: one audio-only ffmpeg pass
* over the timeline the steps before it leave, so trims, appends and speed changes before it
* are taken into account. The result is the `speech` map `planVideoEdit` cuts with.
*/
export async function detectSpeech(
request: Omit<VideoEditPlanRequest, "speech" | "analyzeAudio" | "outputPath">,
options: { workDir: string; signal: AbortSignal },
): Promise<Map<EditVideoStep, TimeRange[] | null>> {
const speech = new Map<EditVideoStep, TimeRange[] | null>();
for (const [index, step] of request.steps.entries()) {
if (step.op !== "remove_silence") continue;
const file = `silence-${index}.txt`;
const plan = planVideoEdit({
...request,
steps: request.steps.slice(0, index),
speech,
analyzeAudio: silenceDetectFilters(
{ thresholdDb: step.threshold_db, minSilence: step.min_silence },
file,
),
outputPath: "",
});
await runFfmpeg(plan.args, {
signal: options.signal,
cwd: options.workDir,
durationMs: plan.durationMs,
});
const duration = plan.durationMs / 1000;
const silences = parseSilences(await readFile(join(options.workDir, file), "utf8"), duration);
speech.set(
step,
speechSegments(silences, duration, {
padding: step.padding,
thresholdDb: step.threshold_db,
}),
);
}
return speech;
}
/** Sorted, with ranges that overlap or touch merged. */
function mergeRanges(ranges: readonly TimeRange[]): TimeRange[] {
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const merged: TimeRange[] = [];
for (const range of sorted) {
const last = merged.at(-1);
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
else merged.push({ ...range });
}
return merged;
}
/** At most `max` segments: the shortest gaps between them stay in until few enough remain. */
function bridgeShortestGaps(segments: readonly TimeRange[], max: number): TimeRange[] {
if (segments.length <= max) return [...segments];
const gaps = segments
.slice(1)
.map((segment, index) => ({ index, length: segment.start - segments[index]!.end }))
.sort((a, b) => a.length - b.length || a.index - b.index);
const bridged = new Set(gaps.slice(0, segments.length - max).map((gap) => gap.index));
const kept: TimeRange[] = [];
for (const [index, segment] of segments.entries()) {
const last = kept.at(-1);
if (last && bridged.has(index - 1)) last.end = segment.end;
else kept.push({ ...segment });
}
return kept;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function number(value: number): string {
return String(Number(value.toFixed(6)));
}