diff --git a/electron/ipc/ffmpeg/filters.test.ts b/electron/ipc/ffmpeg/filters.test.ts index deab8017..d86efbc7 100644 --- a/electron/ipc/ffmpeg/filters.test.ts +++ b/electron/ipc/ffmpeg/filters.test.ts @@ -47,4 +47,47 @@ describe("getAudioSyncAdjustment", () => { "[1:a]atempo=0.975000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]", ]); }); + + it("anchors and pads at end when the duration delta exceeds the delay cap (long recording case)", () => { + // Reporter case in issue #252: 10-min video with audio probed as ~8 min. + // Previously this returned mode "delay" with delayMs 120000, prepending + // 2 minutes of leading silence via `adelay`. That is jarring on long + // recordings. Above the cap we anchor at the start instead. + expect(getAudioSyncAdjustment(600, 480)).toEqual({ + mode: "pad", + delayMs: 0, + tempoRatio: 1, + durationDeltaMs: 120000, + }); + }); + + it("anchors and pads at end when delta is just over the delay cap", () => { + // 35-second short on a 10-min recording — the earlier symptom on issue + // #252 before the regression to ~2 min. Above the 15s cap, anchor. + expect(getAudioSyncAdjustment(600, 565)).toEqual({ + mode: "pad", + delayMs: 0, + tempoRatio: 1, + durationDeltaMs: 35000, + }); + }); + + it("keeps the existing delay branch for moderately short audio at the cap boundary", () => { + // Boundary: 15-second short stays in delay mode (cap is strictly >). + expect(getAudioSyncAdjustment(120, 105)).toEqual({ + mode: "delay", + delayMs: 15000, + tempoRatio: 1, + durationDeltaMs: 15000, + }); + }); + + it("emits apad with trailing silence for the pad mode instead of leading adelay", () => { + const filterParts: string[] = []; + appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(600, 480)); + + expect(filterParts).toEqual([ + "[1:a]apad=pad_dur=120.000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]", + ]); + }); }); diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts index ce973f16..a975b4f3 100644 --- a/electron/ipc/ffmpeg/filters.ts +++ b/electron/ipc/ffmpeg/filters.ts @@ -1,5 +1,12 @@ import type { AudioSyncAdjustment, PauseSegment } from "../types"; +// When the audio file is significantly shorter than the video, prepending +// leading silence equal to the full duration delta (the `delay` branch) is +// reasonable for small deltas but becomes very jarring at tens of seconds: +// the user hears a long silence before audio cuts in mid-clip. Above this +// cap, anchor audio at the start and pad trailing silence instead. +const MAX_AUDIO_SYNC_DELAY_MS = 15000; + export function buildAtempoFilters(tempoRatio: number): string[] { if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) { return []; @@ -58,6 +65,10 @@ export function getAudioSyncAdjustment( return { mode: "tempo", delayMs: 0, tempoRatio, durationDeltaMs }; } + if (durationDeltaMs > MAX_AUDIO_SYNC_DELAY_MS) { + return { mode: "pad", delayMs: 0, tempoRatio: 1, durationDeltaMs }; + } + return { mode: "delay", delayMs: durationDeltaMs, tempoRatio: 1, durationDeltaMs }; } @@ -77,6 +88,10 @@ export function appendSyncedAudioFilter( filters.push(...buildAtempoFilters(adjustment.tempoRatio)); } + if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) { + filters.push(`apad=pad_dur=${(adjustment.durationDeltaMs / 1000).toFixed(3)}`); + } + filters.push("aresample=async=1:first_pts=0", "asetpts=PTS-STARTPTS"); filterParts.push(`${inputLabel}${filters.join(",")}[${outputLabel}]`); } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 5922fb0e..7c7de9c1 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -156,7 +156,7 @@ export type UiohookModuleNamespace = { }; export type AudioSyncAdjustment = { - mode: "none" | "tempo" | "delay"; + mode: "none" | "tempo" | "delay" | "pad"; delayMs: number; tempoRatio: number; durationDeltaMs: number;