Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 204 additions & 8 deletions src/lib/exporter/audioEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vitest";

import { AudioProcessor, softLimitOfflineMixPeaksInPlace } from "./audioEncoder";
import { AudioProcessor, isWavAudioPath, softLimitOfflineMixPeaksInPlace } from "./audioEncoder";

type OfflineRenderTestHarness = AudioProcessor & {
bulkDecodeFromUrl(url: string, sampleRate: number): Promise<AudioBuffer | null>;
decodeAudioFromUrl(url: string): Promise<AudioBuffer | null>;
getMediaDurationSec(url: string): Promise<number>;
loadAudioFileDemuxer(audioPath: string): Promise<unknown>;
processTrimOnlyAudio(
demuxer: unknown,
muxer: unknown,
sortedTrims: unknown[],
readEndSec?: number,
): Promise<boolean>;
streamDecodeFromUrl(url: string): Promise<AudioBuffer | null>;
prepareOfflineRender(
videoUrl: string,
trimRegions: never[],
Expand Down Expand Up @@ -55,14 +63,37 @@ function fakeAudioBuffer(channels: Float32Array[]): AudioBuffer {
describe("AudioProcessor offline render preparation", () => {
it("routes a muted full-track clip through offline audio rendering", async () => {
const processor = new AudioProcessor();
const render = vi.spyOn(processor as unknown as OfflineRenderTestHarness,
"renderAndMuxOfflineAudio").mockResolvedValue();
const clips = [{ id: "clip", startMs: 0, endMs: 1000, sourceStartMs: 0, speed: 1, muted: true }];
const render = vi
.spyOn(processor as unknown as OfflineRenderTestHarness, "renderAndMuxOfflineAudio")
.mockResolvedValue();
const clips = [
{ id: "clip", startMs: 0, endMs: 1000, sourceStartMs: 0, speed: 1, muted: true },
];
const muxer = {} as never;
await processor.process(null, muxer, "recording.mp4", [], [], undefined,
[], [], undefined, undefined, clips);
expect(render).toHaveBeenCalledWith("recording.mp4", [], [], [], [],
undefined, undefined, clips, muxer);
await processor.process(
null,
muxer,
"recording.mp4",
[],
[],
undefined,
[],
[],
undefined,
undefined,
clips,
);
expect(render).toHaveBeenCalledWith(
"recording.mp4",
[],
[],
[],
[],
undefined,
undefined,
clips,
muxer,
);
});

it("rejects a cancelled chunked render instead of returning a partial WAV", async () => {
Expand Down Expand Up @@ -208,6 +239,171 @@ describe("AudioProcessor offline render preparation", () => {
expect(renderAndMuxOfflineAudio).toHaveBeenCalled();
});

it("detects WAV audio paths and URLs correctly", () => {
expect(isWavAudioPath("C:\\Recordly\\recording.system.wav")).toBe(true);
expect(isWavAudioPath("/tmp/recording.mic.WAV")).toBe(true);
expect(isWavAudioPath("http://localhost:4321/video?path=audio.wav&sig=123")).toBe(true);
expect(isWavAudioPath("file:///tmp/recording.mp4")).toBe(false);
expect(isWavAudioPath("/tmp/recording.mic.m4a")).toBe(false);
expect(isWavAudioPath("/tmp/recording.mic.webm")).toBe(false);
expect(isWavAudioPath(null)).toBe(false);
expect(isWavAudioPath(undefined)).toBe(false);
});

it("avoids the single-sidecar fast path for WAV companion audio (such as system audio) and routes to offline rendering", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const loadAudioFileDemuxer = vi.spyOn(processor, "loadAudioFileDemuxer");
const renderAndMuxOfflineAudio = vi
.spyOn(processor, "renderAndMuxOfflineAudio")
.mockResolvedValue();

await processor.process(
null,
{} as never,
"file:///tmp/recording.mp4",
[],
[],
undefined,
[],
["C:\\Recordly\\recording.system.wav"],
);

expect(loadAudioFileDemuxer).not.toHaveBeenCalled();
expect(renderAndMuxOfflineAudio).toHaveBeenCalledWith(
"file:///tmp/recording.mp4",
[],
[],
[],
["C:\\Recordly\\recording.system.wav"],
undefined,
undefined,
undefined,
expect.anything(),
);
});

it("falls back to offline rendering when fast sidecar demux fails or outputs no audio", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const mockDemuxer = { destroy: vi.fn() };
vi.spyOn(processor, "loadAudioFileDemuxer").mockResolvedValue(mockDemuxer);
vi.spyOn(processor, "processTrimOnlyAudio").mockResolvedValue(false);
const renderAndMuxOfflineAudio = vi
.spyOn(processor, "renderAndMuxOfflineAudio")
.mockResolvedValue();

await processor.process(
null,
{} as never,
"file:///tmp/recording.mp4",
[],
[],
undefined,
[],
["/tmp/recording.mic.webm"],
);

expect(mockDemuxer.destroy).toHaveBeenCalled();
expect(renderAndMuxOfflineAudio).toHaveBeenCalledWith(
"file:///tmp/recording.mp4",
[],
[],
[],
["/tmp/recording.mic.webm"],
undefined,
undefined,
undefined,
expect.anything(),
);
});

it("does not fall back to offline rendering if audio chunks were already committed to muxer", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const mockDemuxer = { destroy: vi.fn() };
vi.spyOn(processor, "loadAudioFileDemuxer").mockResolvedValue(mockDemuxer);
vi.spyOn(processor, "processTrimOnlyAudio").mockRejectedValue(
new Error("Muxer write failure"),
);
const renderAndMuxOfflineAudio = vi
.spyOn(processor, "renderAndMuxOfflineAudio")
.mockResolvedValue();

const muxer = { hasCommittedAudio: true } as never;

await expect(
processor.process(
null,
muxer,
"file:///tmp/recording.mp4",
[],
[],
undefined,
[],
["/tmp/recording.mic.webm"],
),
).rejects.toThrow("Muxer write failure");

expect(mockDemuxer.destroy).toHaveBeenCalled();
expect(renderAndMuxOfflineAudio).not.toHaveBeenCalled();
});

it("rejects offline audio rendering if muxer already has committed audio", async () => {
const processor = new AudioProcessor() as unknown as {
renderAndMuxOfflineAudio: (...args: unknown[]) => Promise<void>;
};
const muxer = { hasCommittedAudio: true } as never;

await expect(
processor.renderAndMuxOfflineAudio(
"file:///tmp/recording.mp4",
[],
[],
[],
["/tmp/recording.mic.webm"],
undefined,
undefined,
undefined,
muxer,
),
).rejects.toThrow(
"Cannot render offline audio after audio chunks have already been committed to muxer",
);
});

it("uses video duration instead of companion audio duration when video duration is available", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const companionBuffer = { duration: 15, numberOfChannels: 2 } as AudioBuffer;
vi.spyOn(processor, "decodeAudioFromUrl").mockResolvedValue(companionBuffer);
vi.spyOn(processor, "getMediaDurationSec").mockResolvedValue(10);

const prepared = await processor.prepareOfflineRender(
"file:///tmp/recording.mp4",
[],
[],
[],
["/tmp/recording.system.wav"],
);

expect(prepared.outputDurationMs).toBe(10_000);
});

it("bypasses streaming decode and uses bulk decode directly for WAV files", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const fakeBuffer = { duration: 10, numberOfChannels: 2 } as AudioBuffer;
const streamDecodeFromUrl = vi.spyOn(processor, "streamDecodeFromUrl");
const bulkDecodeFromUrl = vi
.spyOn(processor, "bulkDecodeFromUrl")
.mockResolvedValue(fakeBuffer);

const result = await processor.decodeAudioFromUrl("C:\\Recordly\\recording.system.wav");

expect(result).toBe(fakeBuffer);
expect(streamDecodeFromUrl).not.toHaveBeenCalled();
expect(bulkDecodeFromUrl).toHaveBeenCalledWith(
"C:\\Recordly\\recording.system.wav",
expect.any(Number),
);
});

it("soft-limits mixed peaks before encoding or WAV conversion", () => {
const samples = new Float32Array([
-1.6,
Expand Down
79 changes: 64 additions & 15 deletions src/lib/exporter/audioEncoder.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import type { WebDemuxer } from "web-demuxer";
import { requiresClipTimelineRendering } from "./clipTimeline";
import type {
AudioRegion,
ClipRegion,
SourceAudioTrackSettings,
SpeedRegion,
} from "@/components/video-editor/types";
import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy";
import { AudioTranscodeProcessor } from "./audioTranscodeProcessor";
import {
hasNonDefaultSourceTrackSettings,
isWavAudioPath,
MIN_SPEED_REGION_DELTA_MS,
MP4_AUDIO_CODEC,
type TrimLikeRegion,
} from "./audioProcessorShared";
import { AudioTranscodeProcessor } from "./audioTranscodeProcessor";
import { requiresClipTimelineRendering } from "./clipTimeline";
import type { VideoMuxer } from "./muxer";
import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy";

export {
getSourceTrackIdFromPath,
hasNonDefaultSourceTrackSettings,
isAacAudioEncodingSupported,
isWavAudioPath,
softLimitOfflineMixPeaksInPlace,
} from "./audioProcessorShared";

Expand Down Expand Up @@ -142,11 +144,15 @@ export class AudioProcessor extends AudioTranscodeProcessor {
const hasTimedCompanionAudio = routingPolicy.playbackPaths.some(
(audioPath) => (sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0,
);
const hasWavCompanionAudio = routingPolicy.playbackPaths.some((audioPath) =>
isWavAudioPath(audioPath),
);
const needsSourceAudioMixing =
routingPolicy.playbackPaths.length > 1 ||
(routingPolicy.hasEmbeddedSourceAudio && routingPolicy.playbackPaths.length > 0) ||
requiresLegacyMacMicSidecarMix ||
hasTimedCompanionAudio;
hasTimedCompanionAudio ||
hasWavCompanionAudio;

// When speed edits, audio regions, or multiple audio sources need mixing, use offline AudioContext pipeline.
if (
Expand All @@ -173,22 +179,40 @@ export class AudioProcessor extends AudioTranscodeProcessor {

// Single sidecar audio with no speed/audio edits: demux directly (skips slow real-time rendering).
if (!routingPolicy.hasEmbeddedSourceAudio && routingPolicy.playbackPaths.length === 1) {
const sidecarDemuxer = await this.loadAudioFileDemuxer(routingPolicy.playbackPaths[0]);
if (sidecarDemuxer) {
try {
await this.processTrimOnlyAudio(sidecarDemuxer, muxer, sortedTrims);
} finally {
const sidecarPath = routingPolicy.playbackPaths[0];
if (!isWavAudioPath(sidecarPath)) {
const sidecarDemuxer = await this.loadAudioFileDemuxer(sidecarPath);
if (sidecarDemuxer) {
let wroteAudio = false;
try {
sidecarDemuxer.destroy();
} catch {
/* cleanup */
wroteAudio = await this.processTrimOnlyAudio(
sidecarDemuxer,
muxer,
sortedTrims,
);
} catch (error) {
console.warn("[AudioProcessor] Fast sidecar demux failed:", error);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (muxer?.hasCommittedAudio) {
throw error;
}
} finally {
try {
sidecarDemuxer.destroy();
} catch {
/* cleanup */
}
}
if (wroteAudio || muxer?.hasCommittedAudio) {
return;
}
}
}
if (muxer?.hasCommittedAudio) {
return;
}
// Fallback to offline rendering if demuxer creation failed
// Fallback to offline rendering if demuxer creation failed, unsupported codec, or no audio was written
console.warn(
"[AudioProcessor] Fast sidecar demux failed, falling back to offline rendering",
"[AudioProcessor] Fast sidecar demux unavailable or failed, falling back to offline rendering",
);
await this.renderAndMuxOfflineAudio(
videoUrl,
Expand Down Expand Up @@ -235,7 +259,32 @@ export class AudioProcessor extends AudioTranscodeProcessor {
}
}

await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec);
const wroteTrimOnlyAudio = await this.processTrimOnlyAudio(
demuxer,
muxer,
sortedTrims,
readEndSec,
);
if (
!wroteTrimOnlyAudio &&
routingPolicy.playbackPaths.length > 0 &&
!muxer?.hasCommittedAudio
) {
console.warn(
"[AudioProcessor] Main demuxer audio trim failed, falling back to offline rendering for playback paths",
);
await this.renderAndMuxOfflineAudio(
videoUrl,
sortedTrims,
[],
[],
routingPolicy.playbackPaths,
sourceAudioFallbackStartDelayMsByPath,
sourceAudioTrackSettings,
clipRegions,
muxer,
);
}
}

async renderEditedAudioTrack(
Expand Down
Loading