diff --git a/fluxer_app/src/features/user/components/modals/tabs/hooks/MicTestAudioGraph.ts b/fluxer_app/src/features/user/components/modals/tabs/hooks/MicTestAudioGraph.ts index d89a5f0fd..a05d40878 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/hooks/MicTestAudioGraph.ts +++ b/fluxer_app/src/features/user/components/modals/tabs/hooks/MicTestAudioGraph.ts @@ -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; } @@ -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, }; diff --git a/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx b/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx index 2de5ab329..37f32ef37 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx @@ -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); diff --git a/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.test.ts b/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.test.ts index b2c92614a..cc29af425 100644 --- a/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.test.ts +++ b/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.test.ts @@ -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; + 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; + gains: Array; + waveShapers: Array; +} + interface FakeWindowHarness { requestedOptions: Array; contexts: Array; @@ -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 { + 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); + }); }); diff --git a/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.ts b/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.ts index 95c76eecf..76aa18567 100644 --- a/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.ts +++ b/fluxer_app/src/features/voice/engine/VoiceSharedAudioContext.ts @@ -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 { + 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}); diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index 570f902fd..59fe3649c 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -234,6 +234,17 @@ function applyStreamingModeDefaultMigrationV1(parsed: Record): return true; } +function applyOutputVolumeRecalibrationMigrationV1(parsed: Record): 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 { if (!Array.isArray(images)) return []; const validated: Array = []; @@ -279,6 +290,7 @@ class VoiceSettings { shareDeviceAudio = false; screenShareAudioDeviceId = 'default'; screenShareAudioConsentMigratedV1 = false; + outputVolumeRecalibratedV1 = false; backgroundImageId = NONE_BACKGROUND_ID; backgroundImages: Array = []; 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', diff --git a/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts b/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts new file mode 100644 index 000000000..f49389dac --- /dev/null +++ b/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts @@ -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 | 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 => { + 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); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts new file mode 100644 index 000000000..0c2205c11 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts @@ -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); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts index ff3619828..ba84196d9 100644 --- a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts +++ b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts @@ -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 { + 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)); }