fix: preserve Windows system audio on export

This commit is contained in:
EtienneLescot
2026-05-06 15:31:33 +02:00
parent 4d3bce0f20
commit ac2e34e58c
2 changed files with 167 additions and 18 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AudioProcessor } from "./audioEncoder";
import { AudioProcessor, downmixPlanarChannelsForExport } from "./audioEncoder";
describe("AudioProcessor.selectSupportedExportCodec", () => {
afterEach(() => {
@@ -38,3 +38,35 @@ describe("AudioProcessor.selectSupportedExportCodec", () => {
});
});
});
describe("downmixPlanarChannelsForExport", () => {
it("preserves non-front Windows system audio channels when exporting stereo", () => {
const sourcePlanes = Array.from({ length: 8 }, (_, channel) => {
const plane = new Float32Array(2);
if (channel === 2) {
plane[0] = 0.8;
plane[1] = 0.4;
}
if (channel === 6) {
plane[0] = 0.2;
plane[1] = 0.1;
}
return plane;
});
const stereo = downmixPlanarChannelsForExport(sourcePlanes, 2);
expect(stereo[0]).toBeGreaterThan(0);
expect(stereo[1]).toBeGreaterThan(0);
expect(stereo[2]).toBeGreaterThan(0);
expect(stereo[3]).toBeGreaterThan(0);
});
it("duplicates mono microphone audio when exporting stereo", () => {
const mono = new Float32Array([0.25, -0.5]);
const stereo = downmixPlanarChannelsForExport([mono], 2);
expect(Array.from(stereo)).toEqual([0.25, -0.5, 0.25, -0.5]);
});
});
+134 -17
View File
@@ -22,6 +22,138 @@ const EXPORT_AUDIO_CODECS: ExportAudioCodecCandidate[] = [
{ encoderCodec: "opus", muxerCodec: "opus", label: "Opus" },
];
function averageChannels(sourcePlanes: Float32Array[], frame: number) {
let mixed = 0;
for (const plane of sourcePlanes) {
mixed += plane[frame] ?? 0;
}
return mixed / Math.max(1, sourcePlanes.length);
}
function weightedSample(
sourcePlanes: Float32Array[],
frame: number,
weights: Array<[channel: number, weight: number]>,
) {
let mixed = 0;
let weightSum = 0;
for (const [channel, weight] of weights) {
const sample = sourcePlanes[channel]?.[frame];
if (typeof sample !== "number") {
continue;
}
mixed += sample * weight;
weightSum += weight;
}
return weightSum > 0 ? mixed / weightSum : averageChannels(sourcePlanes, frame);
}
function getStereoDownmixWeights(sourceChannels: number) {
const centerWeight = Math.SQRT1_2;
const surroundWeight = Math.SQRT1_2;
const lfeWeight = 0.5;
if (sourceChannels >= 8) {
// Windows 7.1 order: FL, FR, FC, LFE, BL, BR, SL, SR.
return {
left: [
[0, 1],
[2, centerWeight],
[3, lfeWeight],
[4, surroundWeight],
[6, surroundWeight],
] satisfies Array<[number, number]>,
right: [
[1, 1],
[2, centerWeight],
[3, lfeWeight],
[5, surroundWeight],
[7, surroundWeight],
] satisfies Array<[number, number]>,
};
}
if (sourceChannels >= 6) {
// Windows 5.1 order: FL, FR, FC, LFE, BL, BR.
return {
left: [
[0, 1],
[2, centerWeight],
[3, lfeWeight],
[4, surroundWeight],
] satisfies Array<[number, number]>,
right: [
[1, 1],
[2, centerWeight],
[3, lfeWeight],
[5, surroundWeight],
] satisfies Array<[number, number]>,
};
}
if (sourceChannels >= 4) {
return {
left: [
[0, 1],
[2, surroundWeight],
] satisfies Array<[number, number]>,
right: [
[1, 1],
[3, surroundWeight],
] satisfies Array<[number, number]>,
};
}
return {
left: [
[0, 1],
[2, centerWeight],
] satisfies Array<[number, number]>,
right: [
[1, 1],
[2, centerWeight],
] satisfies Array<[number, number]>,
};
}
export function downmixPlanarChannelsForExport(
sourcePlanes: Float32Array[],
targetChannels: number,
): Float32Array {
const frameCount = sourcePlanes[0]?.length ?? 0;
const output = new Float32Array(frameCount * targetChannels);
if (targetChannels === 1) {
for (let frame = 0; frame < frameCount; frame++) {
output[frame] = averageChannels(sourcePlanes, frame);
}
return output;
}
if (targetChannels !== 2) {
throw new Error(`Unsupported target channel count: ${targetChannels}`);
}
if (sourcePlanes.length === 1) {
output.set(sourcePlanes[0], 0);
output.set(sourcePlanes[0], frameCount);
return output;
}
if (sourcePlanes.length === 2) {
output.set(sourcePlanes[0], 0);
output.set(sourcePlanes[1], frameCount);
return output;
}
const weights = getStereoDownmixWeights(sourcePlanes.length);
for (let frame = 0; frame < frameCount; frame++) {
output[frame] = weightedSample(sourcePlanes, frame, weights.left);
output[frameCount + frame] = weightedSample(sourcePlanes, frame, weights.right);
}
return output;
}
export class AudioProcessor {
private cancelled = false;
@@ -665,22 +797,7 @@ export class AudioProcessor {
});
}
const output = new Float32Array(frameCount * targetChannels);
if (targetChannels === 1) {
for (let frame = 0; frame < frameCount; frame++) {
let mixed = 0;
for (let channel = 0; channel < sourceChannels; channel++) {
mixed += sourcePlanes[channel][frame];
}
output[frame] = mixed / sourceChannels;
}
} else if (sourceChannels === 1) {
output.set(sourcePlanes[0], 0);
output.set(sourcePlanes[0], frameCount);
} else {
output.set(sourcePlanes[0], 0);
output.set(sourcePlanes[1], frameCount);
}
const output = downmixPlanarChannelsForExport(sourcePlanes, targetChannels);
return new AudioData({
format: "f32-planar",
@@ -688,7 +805,7 @@ export class AudioProcessor {
numberOfFrames: frameCount,
numberOfChannels: targetChannels,
timestamp: newTimestamp,
data: output,
data: output.buffer instanceof ArrayBuffer ? output.buffer : output.slice().buffer,
});
}