mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782)
This commit is contained in:
1 parent
c020eded31
commit
25af7516a4
8 files changed
+514
-4
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {VOICE_TRACK_MAX_TOTAL_GAIN} from '@app/features/voice/utils/VoiceVolumeUtils';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface FakeAudioContext {
|
||||
@@ -8,6 +9,34 @@ interface FakeAudioContext {
|
||||
resumeCount: number;
|
||||
}
|
||||
|
||||
interface FakeAudioNode {
|
||||
kind: string;
|
||||
connections: Array<FakeAudioNode>;
|
||||
connect: (target: FakeAudioNode) => FakeAudioNode;
|
||||
}
|
||||
|
||||
interface FakeGainNode extends FakeAudioNode {
|
||||
gain: {value: number};
|
||||
}
|
||||
|
||||
interface FakeWaveShaperNode extends FakeAudioNode {
|
||||
curve: Float32Array | null;
|
||||
oversample: OverSampleType;
|
||||
}
|
||||
|
||||
interface FakeGraphContext {
|
||||
state: AudioContextState;
|
||||
sampleRate: number;
|
||||
hardwareDestination: FakeAudioNode;
|
||||
readonly destination: FakeAudioNode;
|
||||
}
|
||||
|
||||
interface FakeGraphHarness {
|
||||
contexts: Array<FakeGraphContext>;
|
||||
gains: Array<FakeGainNode>;
|
||||
waveShapers: Array<FakeWaveShaperNode>;
|
||||
}
|
||||
|
||||
interface FakeWindowHarness {
|
||||
requestedOptions: Array<AudioContextOptions | undefined>;
|
||||
contexts: Array<FakeAudioContext>;
|
||||
@@ -64,6 +93,108 @@ function installFakeWindow(
|
||||
return harness;
|
||||
}
|
||||
|
||||
function createFakeAudioNode(kind: string): FakeAudioNode {
|
||||
const node: FakeAudioNode = {
|
||||
kind,
|
||||
connections: [],
|
||||
connect: (target: FakeAudioNode): FakeAudioNode => {
|
||||
node.connections.push(target);
|
||||
return target;
|
||||
},
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function installFakeGraphWindow(config: {withWaveShaper?: boolean} = {}): FakeGraphHarness {
|
||||
const harness: FakeGraphHarness = {contexts: [], gains: [], waveShapers: []};
|
||||
const withWaveShaper = config.withWaveShaper ?? true;
|
||||
|
||||
class FakeGraphAudioContextImpl {
|
||||
state: AudioContextState = 'running';
|
||||
sampleRate = HARDWARE_SAMPLE_RATE;
|
||||
hardwareDestination: FakeAudioNode;
|
||||
|
||||
constructor() {
|
||||
this.hardwareDestination = createFakeAudioNode('destination');
|
||||
harness.contexts.push(this as unknown as FakeGraphContext);
|
||||
}
|
||||
|
||||
get destination(): FakeAudioNode {
|
||||
return this.hardwareDestination;
|
||||
}
|
||||
|
||||
createGain(): FakeGainNode {
|
||||
const node = createFakeAudioNode('gain') as FakeGainNode;
|
||||
node.gain = {value: 1};
|
||||
harness.gains.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
this.state = 'running';
|
||||
}
|
||||
|
||||
addEventListener(): void {}
|
||||
removeEventListener(): void {}
|
||||
}
|
||||
|
||||
class FakeGraphAudioContextWithWaveShaperImpl extends FakeGraphAudioContextImpl {
|
||||
createWaveShaper(): FakeWaveShaperNode {
|
||||
const node = createFakeAudioNode('waveShaper') as FakeWaveShaperNode;
|
||||
node.curve = null;
|
||||
node.oversample = '4x';
|
||||
harness.waveShapers.push(node);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
AudioContext: withWaveShaper ? FakeGraphAudioContextWithWaveShaperImpl : FakeGraphAudioContextImpl,
|
||||
document: {
|
||||
body: {
|
||||
addEventListener: (): void => {},
|
||||
removeEventListener: (): void => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
return harness;
|
||||
}
|
||||
|
||||
function evaluateSoftClipCurve(curve: Float32Array, input: number): number {
|
||||
const scaled = ((curve.length - 1) / 2) * (input + 1);
|
||||
if (scaled <= 0) return curve[0]!;
|
||||
if (scaled >= curve.length - 1) return curve[curve.length - 1]!;
|
||||
const index = Math.floor(scaled);
|
||||
const fraction = scaled - index;
|
||||
return (1 - fraction) * curve[index]! + fraction * curve[index + 1]!;
|
||||
}
|
||||
|
||||
function expectCurveIsMonotoneOddAndBounded(curve: Float32Array): void {
|
||||
let maxAbsolute = 0;
|
||||
let maxOddError = 0;
|
||||
let maxBackwardsStep = 0;
|
||||
for (let i = 0; i < curve.length; i++) {
|
||||
maxAbsolute = Math.max(maxAbsolute, Math.abs(curve[i]!));
|
||||
maxOddError = Math.max(maxOddError, Math.abs(curve[i]! + curve[curve.length - 1 - i]!));
|
||||
if (i < curve.length - 1) {
|
||||
maxBackwardsStep = Math.max(maxBackwardsStep, curve[i]! - curve[i + 1]!);
|
||||
}
|
||||
}
|
||||
expect(maxBackwardsStep).toBe(0);
|
||||
expect(maxOddError).toBeLessThanOrEqual(1e-6);
|
||||
expect(maxAbsolute).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
function measureWorstTransparencyError(curve: Float32Array, maxAmplitude: number): number {
|
||||
let worst = 0;
|
||||
for (let step = -2000; step <= 2000; step++) {
|
||||
const amplitude = (step / 2000) * maxAmplitude;
|
||||
const shaped = evaluateSoftClipCurve(curve, amplitude / VOICE_TRACK_MAX_TOTAL_GAIN);
|
||||
worst = Math.max(worst, Math.abs(shaped - amplitude));
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
vi.resetModules();
|
||||
return await import('@app/features/voice/engine/VoiceSharedAudioContext');
|
||||
@@ -141,4 +272,146 @@ describe('VoiceSharedAudioContext', () => {
|
||||
|
||||
expect(createVoiceAudioContext({latencyHint: 'interactive', sampleRate: 48000})).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the context untouched when the browser has no WaveShaper', async () => {
|
||||
installFakeWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
const context = getSharedVoiceAudioContext();
|
||||
|
||||
expect(context).not.toBeNull();
|
||||
expect(Object.getOwnPropertyDescriptor(context as object, 'destination')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the hardware destination in place when the context cannot build a WaveShaper', async () => {
|
||||
const harness = installFakeGraphWindow({withWaveShaper: false});
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
const context = getSharedVoiceAudioContext() as unknown as FakeGraphContext;
|
||||
|
||||
expect(Object.getOwnPropertyDescriptor(context as object, 'destination')).toBeUndefined();
|
||||
expect(context.destination).toBe(harness.contexts[0]!.hardwareDestination);
|
||||
expect(harness.gains).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VoiceSharedAudioContext master soft clip', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('routes LiveKit track gain through the master soft clipper', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
const context = getSharedVoiceAudioContext() as unknown as FakeGraphContext;
|
||||
const masterInput = harness.gains[0]!;
|
||||
const waveShaper = harness.waveShapers[0]!;
|
||||
|
||||
expect(context.destination).toBe(masterInput);
|
||||
expect(context.destination).not.toBe(context.hardwareDestination);
|
||||
expect(masterInput.connections).toEqual([waveShaper]);
|
||||
expect(waveShaper.connections).toEqual([context.hardwareDestination]);
|
||||
});
|
||||
|
||||
it('pre-gain opens a 12x window', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
getSharedVoiceAudioContext();
|
||||
|
||||
expect(harness.gains).toHaveLength(1);
|
||||
expect(harness.gains[0]!.gain.value).toBe(1 / VOICE_TRACK_MAX_TOTAL_GAIN);
|
||||
});
|
||||
|
||||
it('oversample stays none so the limiter adds zero latency', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
getSharedVoiceAudioContext();
|
||||
|
||||
expect(harness.waveShapers[0]!.oversample).toBe('none');
|
||||
});
|
||||
|
||||
it('installs the shared curve, which is monotone, odd and never exceeds full scale', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext, createVoiceSoftClipCurve} = await loadModule();
|
||||
|
||||
getSharedVoiceAudioContext();
|
||||
const curve = harness.waveShapers[0]!.curve;
|
||||
|
||||
expect(curve).toEqual(createVoiceSoftClipCurve());
|
||||
expectCurveIsMonotoneOddAndBounded(curve as Float32Array);
|
||||
});
|
||||
|
||||
it('is transparent below the knee so normal playback is unchanged', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
getSharedVoiceAudioContext();
|
||||
const curve = harness.waveShapers[0]!.curve as Float32Array;
|
||||
|
||||
for (const amplitude of [0, 0.4, 0.79]) {
|
||||
expect(evaluateSoftClipCurve(curve, amplitude / VOICE_TRACK_MAX_TOTAL_GAIN)).toBeCloseTo(amplitude, 6);
|
||||
}
|
||||
expect(measureWorstTransparencyError(curve, 0.8)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('reuses the shared context without stacking a second limiter', async () => {
|
||||
const harness = installFakeGraphWindow();
|
||||
const {getSharedVoiceAudioContext} = await loadModule();
|
||||
|
||||
const first = getSharedVoiceAudioContext();
|
||||
|
||||
expect(getSharedVoiceAudioContext()).toBe(first);
|
||||
expect(harness.gains).toHaveLength(1);
|
||||
expect(harness.waveShapers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVoiceSoftClipCurve', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('spans the full 12x window and lands exactly on full scale at the edges', async () => {
|
||||
installFakeWindow();
|
||||
const {createVoiceSoftClipCurve} = await loadModule();
|
||||
|
||||
const curve = createVoiceSoftClipCurve();
|
||||
|
||||
expect(curve).toHaveLength(8192);
|
||||
expect(curve[0]).toBe(-1);
|
||||
expect(curve[curve.length - 1]).toBe(1);
|
||||
});
|
||||
|
||||
it('is monotone, odd-symmetric and bounded by full scale', async () => {
|
||||
installFakeWindow();
|
||||
const {createVoiceSoftClipCurve} = await loadModule();
|
||||
|
||||
expectCurveIsMonotoneOddAndBounded(createVoiceSoftClipCurve());
|
||||
});
|
||||
|
||||
it('passes everything below the knee through untouched', async () => {
|
||||
installFakeWindow();
|
||||
const {createVoiceSoftClipCurve} = await loadModule();
|
||||
|
||||
const curve = createVoiceSoftClipCurve();
|
||||
|
||||
expect(measureWorstTransparencyError(curve, 0.8)).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
it('shaves full scale by 0.42 dB and hard limits far above it', async () => {
|
||||
installFakeWindow();
|
||||
const {createVoiceSoftClipCurve} = await loadModule();
|
||||
|
||||
const curve = createVoiceSoftClipCurve();
|
||||
const atFullScale = evaluateSoftClipCurve(curve, 1 / VOICE_TRACK_MAX_TOTAL_GAIN);
|
||||
|
||||
expect(atFullScale).toBeCloseTo(0.95232, 5);
|
||||
expect(20 * Math.log10(atFullScale)).toBeCloseTo(-0.42, 2);
|
||||
expect(evaluateSoftClipCurve(curve, 3 / VOICE_TRACK_MAX_TOTAL_GAIN)).toBeCloseTo(1, 6);
|
||||
expect(evaluateSoftClipCurve(curve, 5)).toBe(1);
|
||||
expect(evaluateSoftClipCurve(curve, -5)).toBe(-1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user