mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05: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
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user