mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
|
import {createVoiceSoftClipNode} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
||||||
import {buildDeepFilterAudioChain, type DeepFilterAudioChain} from '@app/features/voice/utils/DeepFilterNoiseProcessor';
|
import {buildDeepFilterAudioChain, type DeepFilterAudioChain} from '@app/features/voice/utils/DeepFilterNoiseProcessor';
|
||||||
|
|
||||||
const logger = new Logger('MicTestAudioGraph');
|
const logger = new Logger('MicTestAudioGraph');
|
||||||
@@ -11,6 +12,8 @@ export interface MicTestAudioGraph {
|
|||||||
inputGain: GainNode;
|
inputGain: GainNode;
|
||||||
delay: DelayNode;
|
delay: DelayNode;
|
||||||
outputGain: GainNode;
|
outputGain: GainNode;
|
||||||
|
softClipInput: GainNode;
|
||||||
|
softClipOutput: AudioNode;
|
||||||
playbackTarget: AudioNode;
|
playbackTarget: AudioNode;
|
||||||
dispose: () => Promise<void>;
|
dispose: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -80,10 +83,14 @@ export async function createMicTestAudioGraph({
|
|||||||
analyser.smoothingTimeConstant = 0.2;
|
analyser.smoothingTimeConstant = 0.2;
|
||||||
delay.delayTime.value = playbackDelaySeconds;
|
delay.delayTime.value = playbackDelaySeconds;
|
||||||
outputGainNode.gain.value = outputGain;
|
outputGainNode.gain.value = outputGain;
|
||||||
|
const softClip = createVoiceSoftClipNode(audioContext);
|
||||||
|
const softClipInput = softClip?.input ?? outputGainNode;
|
||||||
|
const softClipOutput: AudioNode = softClip?.output ?? outputGainNode;
|
||||||
inputGainNode.connect(analyser);
|
inputGainNode.connect(analyser);
|
||||||
analyser.connect(delay);
|
analyser.connect(delay);
|
||||||
delay.connect(outputGainNode);
|
delay.connect(outputGainNode);
|
||||||
outputGainNode.connect(playbackTarget);
|
if (softClip) outputGainNode.connect(softClip.input);
|
||||||
|
softClipOutput.connect(playbackTarget);
|
||||||
|
|
||||||
const dispose = async () => {
|
const dispose = async () => {
|
||||||
source.disconnect();
|
source.disconnect();
|
||||||
@@ -91,6 +98,8 @@ export async function createMicTestAudioGraph({
|
|||||||
inputGainNode.disconnect();
|
inputGainNode.disconnect();
|
||||||
delay.disconnect();
|
delay.disconnect();
|
||||||
outputGainNode.disconnect();
|
outputGainNode.disconnect();
|
||||||
|
softClipInput.disconnect();
|
||||||
|
softClipOutput.disconnect();
|
||||||
passthroughDestination?.disconnect();
|
passthroughDestination?.disconnect();
|
||||||
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
|
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
|
||||||
if (deepFilterChain) {
|
if (deepFilterChain) {
|
||||||
@@ -106,6 +115,8 @@ export async function createMicTestAudioGraph({
|
|||||||
inputGain: inputGainNode,
|
inputGain: inputGainNode,
|
||||||
delay,
|
delay,
|
||||||
outputGain: outputGainNode,
|
outputGain: outputGainNode,
|
||||||
|
softClipInput,
|
||||||
|
softClipOutput,
|
||||||
playbackTarget,
|
playbackTarget,
|
||||||
dispose,
|
dispose,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export const useMicTest = (settings: MicTestSettings) => {
|
|||||||
playbackDestination.disconnect();
|
playbackDestination.disconnect();
|
||||||
playbackDestination.stream.getTracks().forEach((track) => track.stop());
|
playbackDestination.stream.getTracks().forEach((track) => track.stop());
|
||||||
playbackDestinationRef.current = null;
|
playbackDestinationRef.current = null;
|
||||||
graphRef.current.outputGain.disconnect();
|
graphRef.current.softClipOutput.disconnect();
|
||||||
playbackTarget = audioContext.destination;
|
playbackTarget = audioContext.destination;
|
||||||
const sinkableAudioContext = audioContext as SinkableAudioContext;
|
const sinkableAudioContext = audioContext as SinkableAudioContext;
|
||||||
if (settings.outputDeviceId !== 'default' && sinkableAudioContext.setSinkId) {
|
if (settings.outputDeviceId !== 'default' && sinkableAudioContext.setSinkId) {
|
||||||
@@ -254,7 +254,7 @@ export const useMicTest = (settings: MicTestSettings) => {
|
|||||||
logger.warn('Failed to set mic test AudioContext output device', sinkError);
|
logger.warn('Failed to set mic test AudioContext output device', sinkError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
graphRef.current.outputGain.connect(playbackTarget);
|
graphRef.current.softClipOutput.connect(playbackTarget);
|
||||||
graphRef.current.playbackTarget = playbackTarget;
|
graphRef.current.playbackTarget = playbackTarget;
|
||||||
}
|
}
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
interface FakeAudioContext {
|
interface FakeAudioContext {
|
||||||
@@ -8,6 +9,34 @@ interface FakeAudioContext {
|
|||||||
resumeCount: number;
|
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 {
|
interface FakeWindowHarness {
|
||||||
requestedOptions: Array<AudioContextOptions | undefined>;
|
requestedOptions: Array<AudioContextOptions | undefined>;
|
||||||
contexts: Array<FakeAudioContext>;
|
contexts: Array<FakeAudioContext>;
|
||||||
@@ -64,6 +93,108 @@ function installFakeWindow(
|
|||||||
return harness;
|
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() {
|
async function loadModule() {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
return await import('@app/features/voice/engine/VoiceSharedAudioContext');
|
return await import('@app/features/voice/engine/VoiceSharedAudioContext');
|
||||||
@@ -141,4 +272,146 @@ describe('VoiceSharedAudioContext', () => {
|
|||||||
|
|
||||||
expect(createVoiceAudioContext({latencyHint: 'interactive', sampleRate: 48000})).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,11 +2,59 @@
|
|||||||
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
|
import {VOICE_TRACK_MAX_TOTAL_GAIN} from '@app/features/voice/utils/VoiceVolumeUtils';
|
||||||
|
|
||||||
const logger = new Logger('VoiceSharedAudioContext');
|
const logger = new Logger('VoiceSharedAudioContext');
|
||||||
|
|
||||||
type AudioContextConstructor = typeof AudioContext;
|
type AudioContextConstructor = typeof AudioContext;
|
||||||
|
|
||||||
|
const MASTER_SOFT_CLIP_KNEE = 0.8;
|
||||||
|
const MASTER_SOFT_CLIP_CURVE_POINTS = 8192;
|
||||||
|
|
||||||
|
function softClipSample(x: number): number {
|
||||||
|
const magnitude = Math.abs(x);
|
||||||
|
if (magnitude <= MASTER_SOFT_CLIP_KNEE) return x;
|
||||||
|
const over = (magnitude - MASTER_SOFT_CLIP_KNEE) / (1 - MASTER_SOFT_CLIP_KNEE);
|
||||||
|
const shaped = MASTER_SOFT_CLIP_KNEE + (1 - MASTER_SOFT_CLIP_KNEE) * Math.tanh(over);
|
||||||
|
return x < 0 ? -shaped : shaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVoiceSoftClipCurve(): Float32Array<ArrayBuffer> {
|
||||||
|
const curve = new Float32Array(new ArrayBuffer(MASTER_SOFT_CLIP_CURVE_POINTS * Float32Array.BYTES_PER_ELEMENT));
|
||||||
|
for (let i = 0; i < MASTER_SOFT_CLIP_CURVE_POINTS; i++) {
|
||||||
|
const normalized = (i / (MASTER_SOFT_CLIP_CURVE_POINTS - 1)) * 2 - 1;
|
||||||
|
curve[i] = softClipSample(normalized * VOICE_TRACK_MAX_TOTAL_GAIN);
|
||||||
|
}
|
||||||
|
return curve;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVoiceSoftClipNode(context: BaseAudioContext): {input: GainNode; output: WaveShaperNode} | null {
|
||||||
|
if (typeof context.createGain !== 'function' || typeof context.createWaveShaper !== 'function') return null;
|
||||||
|
const input = context.createGain();
|
||||||
|
input.gain.value = 1 / VOICE_TRACK_MAX_TOTAL_GAIN;
|
||||||
|
const output = context.createWaveShaper();
|
||||||
|
output.curve = createVoiceSoftClipCurve();
|
||||||
|
output.oversample = 'none';
|
||||||
|
input.connect(output);
|
||||||
|
return {input, output};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMasterSoftClip(context: AudioContext): void {
|
||||||
|
const hardwareDestination = context.destination;
|
||||||
|
if (!hardwareDestination) return;
|
||||||
|
const softClip = createVoiceSoftClipNode(context);
|
||||||
|
if (!softClip) return;
|
||||||
|
try {
|
||||||
|
softClip.output.connect(hardwareDestination);
|
||||||
|
Object.defineProperty(context, 'destination', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => softClip.input,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to install master soft clip on shared voice AudioContext', {error});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sharedAudioContext: AudioContext | null = null;
|
let sharedAudioContext: AudioContext | null = null;
|
||||||
|
|
||||||
function resolveAudioContextConstructor(): AudioContextConstructor | null {
|
function resolveAudioContextConstructor(): AudioContextConstructor | null {
|
||||||
@@ -55,6 +103,7 @@ export function getSharedVoiceAudioContext(): AudioContext | null {
|
|||||||
sharedAudioContext = createVoiceAudioContext({latencyHint: 'interactive'});
|
sharedAudioContext = createVoiceAudioContext({latencyHint: 'interactive'});
|
||||||
if (!sharedAudioContext) return null;
|
if (!sharedAudioContext) return null;
|
||||||
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
|
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
|
||||||
|
installMasterSoftClip(sharedAudioContext);
|
||||||
if (sharedAudioContext.state === 'suspended') {
|
if (sharedAudioContext.state === 'suspended') {
|
||||||
void sharedAudioContext.resume().catch((error) => {
|
void sharedAudioContext.resume().catch((error) => {
|
||||||
logger.debug('Initial resume of shared voice AudioContext rejected', {error});
|
logger.debug('Initial resume of shared voice AudioContext rejected', {error});
|
||||||
|
|||||||
@@ -234,6 +234,17 @@ function applyStreamingModeDefaultMigrationV1(parsed: Record<string, unknown>):
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyOutputVolumeRecalibrationMigrationV1(parsed: Record<string, unknown>): boolean {
|
||||||
|
if (parsed.outputVolumeRecalibratedV1 === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof parsed.outputVolume === 'number' && parsed.outputVolume > 100) {
|
||||||
|
parsed.outputVolume = 100;
|
||||||
|
}
|
||||||
|
parsed.outputVolumeRecalibratedV1 = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function validateBackgroundImages(images: unknown): Array<BackgroundImage> {
|
function validateBackgroundImages(images: unknown): Array<BackgroundImage> {
|
||||||
if (!Array.isArray(images)) return [];
|
if (!Array.isArray(images)) return [];
|
||||||
const validated: Array<BackgroundImage> = [];
|
const validated: Array<BackgroundImage> = [];
|
||||||
@@ -279,6 +290,7 @@ class VoiceSettings {
|
|||||||
shareDeviceAudio = false;
|
shareDeviceAudio = false;
|
||||||
screenShareAudioDeviceId = 'default';
|
screenShareAudioDeviceId = 'default';
|
||||||
screenShareAudioConsentMigratedV1 = false;
|
screenShareAudioConsentMigratedV1 = false;
|
||||||
|
outputVolumeRecalibratedV1 = false;
|
||||||
backgroundImageId = NONE_BACKGROUND_ID;
|
backgroundImageId = NONE_BACKGROUND_ID;
|
||||||
backgroundImages: Array<BackgroundImage> = [];
|
backgroundImages: Array<BackgroundImage> = [];
|
||||||
backgroundBlurStrength = CAMERA_EFFECT_STRENGTH_DEFAULT;
|
backgroundBlurStrength = CAMERA_EFFECT_STRENGTH_DEFAULT;
|
||||||
@@ -413,9 +425,11 @@ class VoiceSettings {
|
|||||||
changed = applyAdaptiveScreenShareQualityMigrationV2(parsed) || changed;
|
changed = applyAdaptiveScreenShareQualityMigrationV2(parsed) || changed;
|
||||||
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
|
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
|
||||||
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
|
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
|
||||||
|
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
AppStorage.setItem('VoiceSettings', JSON.stringify(parsed));
|
AppStorage.setItem('VoiceSettings', JSON.stringify(parsed));
|
||||||
}
|
}
|
||||||
|
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to migrate persisted voice settings:', error);
|
logger.warn('Failed to migrate persisted voice settings:', error);
|
||||||
}
|
}
|
||||||
@@ -449,6 +463,7 @@ class VoiceSettings {
|
|||||||
'shareDeviceAudio',
|
'shareDeviceAudio',
|
||||||
'screenShareAudioDeviceId',
|
'screenShareAudioDeviceId',
|
||||||
'screenShareAudioConsentMigratedV1',
|
'screenShareAudioConsentMigratedV1',
|
||||||
|
'outputVolumeRecalibratedV1',
|
||||||
'backgroundImageId',
|
'backgroundImageId',
|
||||||
'backgroundImages',
|
'backgroundImages',
|
||||||
'backgroundBlurStrength',
|
'backgroundBlurStrength',
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {beforeAll, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
|
||||||
|
areVoiceBackgroundsAvailable: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
|
||||||
|
LimitResolver: {resolve: () => 0},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/app/utils/LimitUtils', () => ({
|
||||||
|
isLimitToggleEnabled: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'VoiceSettings';
|
||||||
|
|
||||||
|
async function loadVoiceSettingsWith(stored: Record<string, unknown> | null) {
|
||||||
|
vi.resetModules();
|
||||||
|
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
|
||||||
|
AppStorage.removeItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
AppStorage.setItem(STORAGE_KEY, JSON.stringify({...stored, __mps__: {version: 1}}));
|
||||||
|
}
|
||||||
|
const {default: VoiceSettings} = await import('./VoiceSettings');
|
||||||
|
const {awaitHydration} = await import('@app/features/platform/utils/MobXPersistence');
|
||||||
|
await awaitHydration(STORAGE_KEY);
|
||||||
|
const readStored = (): Record<string, unknown> => {
|
||||||
|
const raw = AppStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
};
|
||||||
|
return {VoiceSettings, readStored};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VoiceSettings output volume recalibration migration', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import('./VoiceSettings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets a boosted output volume once and records that it ran', async () => {
|
||||||
|
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 200});
|
||||||
|
expect(VoiceSettings.outputVolume).toBe(100);
|
||||||
|
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches an output volume the user set after the migration ran', async () => {
|
||||||
|
const {VoiceSettings} = await loadVoiceSettingsWith({
|
||||||
|
outputVolume: 200,
|
||||||
|
outputVolumeRecalibratedV1: true,
|
||||||
|
});
|
||||||
|
expect(VoiceSettings.outputVolume).toBe(200);
|
||||||
|
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an output volume at or below unity alone', async () => {
|
||||||
|
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 60});
|
||||||
|
expect(VoiceSettings.outputVolume).toBe(60);
|
||||||
|
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an empty stored record as recalibrated', async () => {
|
||||||
|
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({});
|
||||||
|
expect(VoiceSettings.outputVolume).toBe(100);
|
||||||
|
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a fresh install as recalibrated so a later launch cannot reset a volume set today', async () => {
|
||||||
|
const {VoiceSettings} = await loadVoiceSettingsWith(null);
|
||||||
|
expect(VoiceSettings.outputVolume).toBe(100);
|
||||||
|
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
boostedVoiceVolumePercentToTrackVolume,
|
||||||
|
clampVoiceTrackTotalGain,
|
||||||
|
composeVoiceVolumeGain,
|
||||||
|
VOICE_TRACK_MAX_TOTAL_GAIN,
|
||||||
|
VOICE_VOLUME_MAX_GAIN,
|
||||||
|
VOICE_VOLUME_MAX_PERCENT,
|
||||||
|
VOICE_VOLUME_MAX_SLIDER_VOLUME,
|
||||||
|
} from '@app/features/voice/utils/VoiceVolumeUtils';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
describe('boostedVoiceVolumePercentToTrackVolume', () => {
|
||||||
|
it('leaves every setting at or below unity untouched', () => {
|
||||||
|
const cases: Array<[number, number]> = [
|
||||||
|
[0, 0],
|
||||||
|
[1, 10 ** -1.188],
|
||||||
|
[10, 10 ** -1.08],
|
||||||
|
[25, 10 ** -0.9],
|
||||||
|
[50, 10 ** -0.6],
|
||||||
|
[75, 10 ** -0.3],
|
||||||
|
[99, 10 ** -0.012],
|
||||||
|
[100, 1],
|
||||||
|
];
|
||||||
|
for (const [percent, expected] of cases) {
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(percent)).toBeCloseTo(expected, 15);
|
||||||
|
}
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(0)).toBe(0);
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(100)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widens the boost leg to +12.04 dB at 200%', () => {
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(200)).toBeCloseTo(4, 12);
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(150)).toBe(2);
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(125)).toBe(Math.SQRT2);
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(100)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('voice volume ceilings', () => {
|
||||||
|
it('keeps the slider ceiling and the gain ceiling as separate quantities', () => {
|
||||||
|
expect(VOICE_VOLUME_MAX_SLIDER_VOLUME).toBe(2);
|
||||||
|
expect(VOICE_VOLUME_MAX_SLIDER_VOLUME).toBe(VOICE_VOLUME_MAX_PERCENT / 100);
|
||||||
|
expect(VOICE_VOLUME_MAX_GAIN).toBe(4);
|
||||||
|
expect(boostedVoiceVolumePercentToTrackVolume(VOICE_VOLUME_MAX_PERCENT)).toBeCloseTo(VOICE_VOLUME_MAX_GAIN, 12);
|
||||||
|
expect(VOICE_TRACK_MAX_TOTAL_GAIN).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composeVoiceVolumeGain', () => {
|
||||||
|
it('composes faders in linear gain instead of collapsing them', () => {
|
||||||
|
expect(composeVoiceVolumeGain(200, 100, 200)).toBeCloseTo(16, 12);
|
||||||
|
expect(composeVoiceVolumeGain(200, 100, 200)).toBeGreaterThan(composeVoiceVolumeGain(200, 100, 100));
|
||||||
|
expect(composeVoiceVolumeGain(50, 100, 100)).toBe(10 ** -0.6);
|
||||||
|
expect(composeVoiceVolumeGain()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps each part, not the product', () => {
|
||||||
|
expect(composeVoiceVolumeGain(400)).toBe(composeVoiceVolumeGain(200));
|
||||||
|
expect(composeVoiceVolumeGain(-50)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampVoiceTrackTotalGain', () => {
|
||||||
|
it('fails to unity, not silence', () => {
|
||||||
|
expect(clampVoiceTrackTotalGain(Number.NaN)).toBe(1);
|
||||||
|
expect(clampVoiceTrackTotalGain(Number.POSITIVE_INFINITY)).toBe(1);
|
||||||
|
expect(clampVoiceTrackTotalGain(48)).toBe(12);
|
||||||
|
expect(clampVoiceTrackTotalGain(-1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
export const VOICE_VOLUME_MAX_PERCENT = 200;
|
export const VOICE_VOLUME_MAX_PERCENT = 200;
|
||||||
|
export const VOICE_VOLUME_MAX_GAIN = 4;
|
||||||
|
export const VOICE_TRACK_MAX_TOTAL_GAIN = 12;
|
||||||
|
export const VOICE_VOLUME_MAX_SLIDER_VOLUME = VOICE_VOLUME_MAX_PERCENT / 100;
|
||||||
const UNITY_GAIN_PERCENT = 100;
|
const UNITY_GAIN_PERCENT = 100;
|
||||||
const QUIET_RANGE_EXPONENT = 1.2;
|
const QUIET_RANGE_EXPONENT = 1.2;
|
||||||
|
|
||||||
@@ -27,5 +30,16 @@ export function boostedVoiceVolumePercentToTrackVolume(value: number): number {
|
|||||||
if (clamped <= UNITY_GAIN_PERCENT) {
|
if (clamped <= UNITY_GAIN_PERCENT) {
|
||||||
return 10 ** (((clamped - UNITY_GAIN_PERCENT) / UNITY_GAIN_PERCENT) * QUIET_RANGE_EXPONENT);
|
return 10 ** (((clamped - UNITY_GAIN_PERCENT) / UNITY_GAIN_PERCENT) * QUIET_RANGE_EXPONENT);
|
||||||
}
|
}
|
||||||
return 2 ** ((clamped - UNITY_GAIN_PERCENT) / (VOICE_VOLUME_MAX_PERCENT - UNITY_GAIN_PERCENT));
|
return VOICE_VOLUME_MAX_GAIN ** ((clamped - UNITY_GAIN_PERCENT) / (VOICE_VOLUME_MAX_PERCENT - UNITY_GAIN_PERCENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeVoiceVolumeGain(...volumePercents: Array<number>): number {
|
||||||
|
return volumePercents.reduce((gain, percent) => gain * boostedVoiceVolumePercentToTrackVolume(percent), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampVoiceTrackTotalGain(gain: number): number {
|
||||||
|
if (!Number.isFinite(gain)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(VOICE_TRACK_MAX_TOTAL_GAIN, gain));
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user