fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-20 17:42:02 +02:00
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
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';
const logger = new Logger('MicTestAudioGraph');
@@ -11,6 +12,8 @@ export interface MicTestAudioGraph {
inputGain: GainNode;
delay: DelayNode;
outputGain: GainNode;
softClipInput: GainNode;
softClipOutput: AudioNode;
playbackTarget: AudioNode;
dispose: () => Promise<void>;
}
@@ -80,10 +83,14 @@ export async function createMicTestAudioGraph({
analyser.smoothingTimeConstant = 0.2;
delay.delayTime.value = playbackDelaySeconds;
outputGainNode.gain.value = outputGain;
const softClip = createVoiceSoftClipNode(audioContext);
const softClipInput = softClip?.input ?? outputGainNode;
const softClipOutput: AudioNode = softClip?.output ?? outputGainNode;
inputGainNode.connect(analyser);
analyser.connect(delay);
delay.connect(outputGainNode);
outputGainNode.connect(playbackTarget);
if (softClip) outputGainNode.connect(softClip.input);
softClipOutput.connect(playbackTarget);
const dispose = async () => {
source.disconnect();
@@ -91,6 +98,8 @@ export async function createMicTestAudioGraph({
inputGainNode.disconnect();
delay.disconnect();
outputGainNode.disconnect();
softClipInput.disconnect();
softClipOutput.disconnect();
passthroughDestination?.disconnect();
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
if (deepFilterChain) {
@@ -106,6 +115,8 @@ export async function createMicTestAudioGraph({
inputGain: inputGainNode,
delay,
outputGain: outputGainNode,
softClipInput,
softClipOutput,
playbackTarget,
dispose,
};
@@ -244,7 +244,7 @@ export const useMicTest = (settings: MicTestSettings) => {
playbackDestination.disconnect();
playbackDestination.stream.getTracks().forEach((track) => track.stop());
playbackDestinationRef.current = null;
graphRef.current.outputGain.disconnect();
graphRef.current.softClipOutput.disconnect();
playbackTarget = audioContext.destination;
const sinkableAudioContext = audioContext as SinkableAudioContext;
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);
}
}
graphRef.current.outputGain.connect(playbackTarget);
graphRef.current.softClipOutput.connect(playbackTarget);
graphRef.current.playbackTarget = playbackTarget;
}
setIsTesting(true);
@@ -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);
});
});
@@ -2,11 +2,59 @@
import assert from 'node:assert/strict';
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');
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;
function resolveAudioContextConstructor(): AudioContextConstructor | null {
@@ -55,6 +103,7 @@ export function getSharedVoiceAudioContext(): AudioContext | null {
sharedAudioContext = createVoiceAudioContext({latencyHint: 'interactive'});
if (!sharedAudioContext) return null;
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
installMasterSoftClip(sharedAudioContext);
if (sharedAudioContext.state === 'suspended') {
void sharedAudioContext.resume().catch((error) => {
logger.debug('Initial resume of shared voice AudioContext rejected', {error});
@@ -234,6 +234,17 @@ function applyStreamingModeDefaultMigrationV1(parsed: Record<string, unknown>):
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> {
if (!Array.isArray(images)) return [];
const validated: Array<BackgroundImage> = [];
@@ -279,6 +290,7 @@ class VoiceSettings {
shareDeviceAudio = false;
screenShareAudioDeviceId = 'default';
screenShareAudioConsentMigratedV1 = false;
outputVolumeRecalibratedV1 = false;
backgroundImageId = NONE_BACKGROUND_ID;
backgroundImages: Array<BackgroundImage> = [];
backgroundBlurStrength = CAMERA_EFFECT_STRENGTH_DEFAULT;
@@ -413,9 +425,11 @@ class VoiceSettings {
changed = applyAdaptiveScreenShareQualityMigrationV2(parsed) || changed;
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
if (changed) {
AppStorage.setItem('VoiceSettings', JSON.stringify(parsed));
}
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
} catch (error) {
logger.warn('Failed to migrate persisted voice settings:', error);
}
@@ -449,6 +463,7 @@ class VoiceSettings {
'shareDeviceAudio',
'screenShareAudioDeviceId',
'screenShareAudioConsentMigratedV1',
'outputVolumeRecalibratedV1',
'backgroundImageId',
'backgroundImages',
'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
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 QUIET_RANGE_EXPONENT = 1.2;
@@ -27,5 +30,16 @@ export function boostedVoiceVolumePercentToTrackVolume(value: number): number {
if (clamped <= UNITY_GAIN_PERCENT) {
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));
}