Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ playwright-report/

# Vitest browser mode screenshots
__screenshots__/
.vitest-attachments/

# shell files
/shell.sh
Expand Down
109 changes: 108 additions & 1 deletion src/lib/exporter/audioEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AudioProcessor, downmixPlanarChannelsForExport } from "./audioEncoder";
import { AudioProcessor, downmixPlanarChannelsForExport, mixPlanarSources } from "./audioEncoder";

describe("AudioProcessor.selectSupportedExportCodec", () => {
afterEach(() => {
Expand Down Expand Up @@ -70,3 +70,110 @@ describe("downmixPlanarChannelsForExport", () => {
expect(Array.from(stereo)).toEqual([0.25, -0.5, 0.25, -0.5]);
});
});

describe("mixPlanarSources", () => {
it("sums overlapping stereo sources sample-aligned", () => {
const system = {
planes: [new Float32Array([0.1, 0.2, 0.3]), new Float32Array([0.1, 0.2, 0.3])],
startFrame: 0,
};
const mic = {
planes: [new Float32Array([0.2, 0.2, 0.2]), new Float32Array([0.4, 0.4, 0.4])],
startFrame: 0,
};

const mixed = mixPlanarSources([system, mic], 2, 3);

expect(mixed).toHaveLength(2);
expect(Array.from(mixed[0])).toEqual([
expect.closeTo(0.3, 5),
expect.closeTo(0.4, 5),
expect.closeTo(0.5, 5),
]);
expect(Array.from(mixed[1])).toEqual([
expect.closeTo(0.5, 5),
expect.closeTo(0.6, 5),
expect.closeTo(0.7, 5),
]);
});

it("recovers a track when the other is silent (the #108 case)", () => {
// System audio captured silence; the microphone track holds the real signal.
const silentSystem = {
planes: [new Float32Array([0, 0, 0]), new Float32Array([0, 0, 0])],
startFrame: 0,
};
const mic = {
planes: [new Float32Array([0.5, -0.5, 0.25]), new Float32Array([0.5, -0.5, 0.25])],
startFrame: 0,
};

const mixed = mixPlanarSources([silentSystem, mic], 2, 3);

expect(Array.from(mixed[0])).toEqual([
expect.closeTo(0.5, 5),
expect.closeTo(-0.5, 5),
expect.closeTo(0.25, 5),
]);
});

it("aligns each source at its startFrame offset", () => {
const early = { planes: [new Float32Array([1, 1])], startFrame: 0 };
// Mic packets often start slightly after the video (source start_time > 0).
const late = { planes: [new Float32Array([0.5, 0.5])], startFrame: 2 };

const mixed = mixPlanarSources([early, late], 1, 4);

expect(Array.from(mixed[0])).toEqual([
expect.closeTo(1, 5),
expect.closeTo(1, 5),
expect.closeTo(0.5, 5),
expect.closeTo(0.5, 5),
]);
});

it("discards samples before frame zero from a negative startFrame (AAC preroll)", () => {
// AAC preroll gives the first decoded frame a negative timestamp, so the source's
// startFrame is signed. The preroll samples (timeline < 0) must be dropped while
// the real timestamp-zero sample keeps its true offset.
const withPreroll = {
planes: [new Float32Array([0.9, 0.5, 0.5])],
startFrame: -2,
};

const mixed = mixPlanarSources([withPreroll], 1, 2);

// Frames -2 and -1 (0.9, 0.5) are discarded; frame 0 keeps the real sample.
expect(Array.from(mixed[0])).toEqual([expect.closeTo(0.5, 5), expect.closeTo(0, 5)]);
});

it("upmixes a mono source to stereo before mixing", () => {
const mono = { planes: [new Float32Array([0.3, 0.3])], startFrame: 0 };
const stereo = {
planes: [new Float32Array([0.1, 0.1]), new Float32Array([0.2, 0.2])],
startFrame: 0,
};

const mixed = mixPlanarSources([mono, stereo], 2, 2);

expect(Array.from(mixed[0])).toEqual([expect.closeTo(0.4, 5), expect.closeTo(0.4, 5)]);
expect(Array.from(mixed[1])).toEqual([expect.closeTo(0.5, 5), expect.closeTo(0.5, 5)]);
});

it("clamps the summed signal to [-1, 1] to avoid overflow", () => {
const a = { planes: [new Float32Array([0.8, -0.8])], startFrame: 0 };
const b = { planes: [new Float32Array([0.8, -0.8])], startFrame: 0 };

const mixed = mixPlanarSources([a, b], 1, 2);

expect(Array.from(mixed[0])).toEqual([1, -1]);
});

it("ignores samples that fall beyond the mixed length", () => {
const source = { planes: [new Float32Array([0.5, 0.5, 0.5])], startFrame: 1 };

const mixed = mixPlanarSources([source], 1, 2);

expect(Array.from(mixed[0])).toEqual([expect.closeTo(0, 5), expect.closeTo(0.5, 5)]);
});
});
Loading
Loading