diff --git a/electron/ipc/ffmpeg/filters.ts b/electron/ipc/ffmpeg/filters.ts index e6fa12533..dfc59fbbe 100644 --- a/electron/ipc/ffmpeg/filters.ts +++ b/electron/ipc/ffmpeg/filters.ts @@ -1,6 +1,7 @@ import type { AudioSyncAdjustment, PauseSegment } from "../types"; const MAX_AUDIO_SYNC_DELAY_MS = 15000; +export const ATEMPO_FILTER_EPSILON = 0.0005; export function buildAtempoFilters(tempoRatio: number): string[] { if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) { @@ -20,7 +21,7 @@ export function buildAtempoFilters(tempoRatio: number): string[] { remaining /= 2.0; } - if (Math.abs(remaining - 1) > 0.0005) { + if (Math.abs(remaining - 1) > ATEMPO_FILTER_EPSILON) { filters.push(`atempo=${remaining.toFixed(6)}`); } diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index 855e3c07f..6f7fbb4a7 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ATEMPO_FILTER_EPSILON } from "./ffmpeg/filters"; import { buildEditedTrackSourceAudioFilter, buildTrimmedSourceAudioFilter, @@ -20,7 +21,7 @@ describe("buildTrimmedSourceAudioFilter", () => { }); describe("buildEditedTrackSourceAudioFilter", () => { - it("builds a concat filtergraph that pitch-shifts via asetrate for speed changes", () => { + it("builds a concat filtergraph that applies tempo filters for speed changes", () => { const filter = buildEditedTrackSourceAudioFilter( [ { startMs: 0, endMs: 2_000, speed: 1 }, @@ -31,23 +32,49 @@ describe("buildEditedTrackSourceAudioFilter", () => { expect(filter).toBe( "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" + - "[1:a]atrim=start=2.000:end=6.000,asetpts=PTS-STARTPTS,asetrate=66150,aresample=44100[edited_audio_1];" + + "[1:a]atrim=start=2.000:end=6.000,asetpts=PTS-STARTPTS,atempo=1.500000[edited_audio_1];" + "[edited_audio_0][edited_audio_1]concat=n=2:v=0:a=1[aout]", ); }); - it("builds a filtergraph for slowdown segments by lowering then resampling the source rate", () => { + it("builds a filtergraph for slowdown segments with a tempo filter", () => { const filter = buildEditedTrackSourceAudioFilter( [{ startMs: 0, endMs: 2_000, speed: 0.5 }], 44_100, ); expect(filter).toBe( - "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS,asetrate=22050,aresample=44100[edited_audio_0];" + + "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS,atempo=0.500000[edited_audio_0];" + "[edited_audio_0]anull[aout]", ); }); + it("treats near-unity speed changes as unchanged audio", () => { + const filter = buildEditedTrackSourceAudioFilter( + [{ startMs: 0, endMs: 2_000, speed: 1.0002 }], + 44_100, + ); + + expect(filter).toBe( + "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" + + "[edited_audio_0]anull[aout]", + ); + }); + + it("treats exact epsilon speed changes as unchanged audio", () => { + for (const speed of [1 - ATEMPO_FILTER_EPSILON, 1 + ATEMPO_FILTER_EPSILON]) { + const filter = buildEditedTrackSourceAudioFilter( + [{ startMs: 0, endMs: 2_000, speed }], + 44_100, + ); + + expect(filter).toBe( + "[1:a]atrim=start=0.000:end=2.000,asetpts=PTS-STARTPTS[edited_audio_0];" + + "[edited_audio_0]anull[aout]", + ); + } + }); + it("returns null when the edited-track filtergraph inputs are incomplete", () => { expect(buildEditedTrackSourceAudioFilter([], 44_100)).toBeNull(); expect( diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index 5ca8fa081..3a8d812bf 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -1,4 +1,8 @@ +import { ATEMPO_FILTER_EPSILON, buildAtempoFilters } from "./ffmpeg/filters"; + const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4; +const MIN_EDITED_TRACK_TEMPO_SPEED = 0.5; +const MAX_EDITED_TRACK_TEMPO_SPEED = 2; export type NativeExportEncodingMode = "fast" | "balanced" | "quality"; @@ -207,7 +211,11 @@ export function buildEditedTrackSourceAudioFilter( const label = `edited_audio_${index}`; const speed = segment.speed; - if (!Number.isFinite(speed) || speed <= 0) { + if ( + !Number.isFinite(speed) || + speed < MIN_EDITED_TRACK_TEMPO_SPEED || + speed > MAX_EDITED_TRACK_TEMPO_SPEED + ) { hasInvalidSegment = true; return; } @@ -217,17 +225,12 @@ export function buildEditedTrackSourceAudioFilter( "asetpts=PTS-STARTPTS", ]; - if (Math.abs(speed - 1) > 0.0001) { - const adjustedSampleRate = Math.round(normalizedSourceSampleRate * speed); - if (!Number.isSafeInteger(adjustedSampleRate) || adjustedSampleRate < 1) { - hasInvalidSegment = true; - return; - } - - segmentFilter.push( - `asetrate=${adjustedSampleRate}`, - `aresample=${normalizedSourceSampleRate}`, - ); + const tempoFilters = buildAtempoFilters(speed); + if (tempoFilters.length > 0) { + segmentFilter.push(...tempoFilters); + } else if (Math.abs(speed - 1) > ATEMPO_FILTER_EPSILON) { + hasInvalidSegment = true; + return; } filterParts.push(`${segmentFilter.join(",")}[${label}]`);