From 770e0490dd6fe658022fc2a23f9333b7d7f14cce Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:42:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20increase=20perceived=20audio=20loudness?= =?UTF-8?q?=20=E2=80=94=20+3dB=20master=20boost,=20configurable=20SFX=20vo?= =?UTF-8?q?lume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to bring output volume closer to native apps: - Insert masterBoost GainNode (+3dB) before the compressor/limiter - Raise default system sound volume from 0.5 to 0.8 - Add configurable Sound Effects Volume slider (0–200%) in Voice settings --- packages/web/src/audio/AudioManager.ts | 16 ++++++--- .../modals/settingsPanels/VoicePanel.tsx | 27 +++++++++++++++ .../src/components/voice/SoundController.tsx | 33 +++++++++++-------- packages/web/src/stores/voiceStore.ts | 11 ++++++- 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index a07a7b81..dc610068 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -12,6 +12,7 @@ export class AudioManager { private silentGain: GainNode | null = null; private analyser: AnalyserNode | null = null; private masterCompressor: DynamicsCompressorNode | null = null; + private masterBoost: GainNode | null = null; private currentInputDeviceId: string = 'default'; private desiredOutputDeviceId: string = 'default'; @@ -61,6 +62,11 @@ export class AudioManager { this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks this.masterCompressor.release.value = 0.01; // 10ms — recover quickly + // +3dB boost before the limiter — drives a hotter signal into the + // compressor, raising perceived loudness while peaks are still caught. + this.masterBoost = this.ctx.createGain(); + this.masterBoost.gain.value = 1.41; // +3dB + this.masterBoost.connect(this.masterCompressor); this.masterCompressor.connect(this.ctx.destination); this.inputGain.connect(this.inputDestination); @@ -168,10 +174,10 @@ export class AudioManager { source.loop = options.loop || false; const gainNode = this.ctx.createGain(); - gainNode.gain.value = options.volume ?? 0.5; + gainNode.gain.value = options.volume ?? 0.8; source.connect(gainNode); - gainNode.connect(this.masterCompressor!); + gainNode.connect(this.masterBoost!); source.start(0); return source; @@ -373,14 +379,14 @@ export class AudioManager { } /** - * Returns the master output bus (DynamicsCompressorNode → ctx.destination). + * Returns the master output bus (masterBoost → masterCompressor → ctx.destination). * All audio (remote voice, streams, effects) routes through this node. - * The compressor prevents clipping when multiple sources sum together. + * The +3dB boost raises perceived loudness; the compressor catches peaks. * Output device is controlled via setSinkId on the underlying AudioContext. */ getMasterOutput(): AudioNode { if (!this.ctx) this.initContext(); - return this.masterCompressor!; + return this.masterBoost!; } getContext(): AudioContext | null { diff --git a/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx index 61f12b9c..17103d40 100644 --- a/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/VoicePanel.tsx @@ -8,9 +8,36 @@ export function VoicePanel() { const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation); const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl); const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); + const soundEffectVolume = useVoiceStore((s) => s.soundEffectVolume); + const setSoundEffectVolume = useVoiceStore((s) => s.setSoundEffectVolume); return (
+
+
+ Volume +
+
+
+
+
Sound Effects Volume
+
{soundEffectVolume}%
+
+ setSoundEffectVolume(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${soundEffectVolume / 2}%, rgb(var(--interactive-muted)) ${soundEffectVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, + }} + /> +
+
+
+
Voice Processing diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx index d41414a1..78611dfa 100644 --- a/packages/web/src/components/voice/SoundController.tsx +++ b/packages/web/src/components/voice/SoundController.tsx @@ -4,6 +4,11 @@ import { useChatStore } from '../../stores/chatStore'; import { useAuthStore } from '../../stores/authStore'; import { AudioManager } from '../../audio/AudioManager'; +/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0–200). */ +function getSfxVolume(): number { + return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100); +} + export function SoundController() { const audioManager = AudioManager.getInstance(); const currentUser = useAuthStore((s) => s.user); @@ -31,37 +36,39 @@ export function SoundController() { const unsubscribeVoice = useVoiceStore.subscribe((state) => { if (isInitialMount.current) return; + const sfxOpts = { volume: getSfxVolume() }; + // Mute/Unmute if (state.isMuted !== prevIsMuted.current) { - audioManager.playSound(state.isMuted ? 'mute' : 'unmute'); + audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts); prevIsMuted.current = state.isMuted; } // Deafen/Undeafen if (state.isDeafened !== prevIsDeafened.current) { - audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen'); + audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts); prevIsDeafened.current = state.isDeafened; } // Camera Toggle if (state.isCameraOn !== prevIsCameraOn.current) { - audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off'); + audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts); prevIsCameraOn.current = state.isCameraOn; } // Screen Share Toggle (Self) if (state.isScreenSharing !== prevIsScreenSharing.current) { - audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended'); + audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended', sfxOpts); prevIsScreenSharing.current = state.isScreenSharing; } // Disconnect (Self) if (prevIsConnected.current && !state.isLiveKitConnected) { - audioManager.playSound('disconnect'); + audioManager.playSound('disconnect', sfxOpts); } // Connect (Self) if (!prevIsConnected.current && state.isLiveKitConnected) { - audioManager.playSound('user_join'); + audioManager.playSound('user_join', sfxOpts); } prevIsConnected.current = state.isLiveKitConnected; @@ -73,28 +80,28 @@ export function SoundController() { // Someone joined voice (Others only) state.participants.forEach(p => { if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) { - audioManager.playSound('user_join'); + audioManager.playSound('user_join', sfxOpts); } }); // Someone left voice (Others only) prevParticipantIds.current.forEach(userId => { if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) { - audioManager.playSound('user_leave'); + audioManager.playSound('user_leave', sfxOpts); } }); // Someone started screen sharing (Others only) state.participants.forEach(p => { if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && p.userId !== currentUser?.id) { - audioManager.playSound('stream_user_joined'); + audioManager.playSound('stream_user_joined', sfxOpts); } }); // Someone stopped screen sharing (Others only) prevScreenShareUserIds.current.forEach(userId => { if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) { - audioManager.playSound('stream_user_left'); + audioManager.playSound('stream_user_left', sfxOpts); } }); } @@ -104,7 +111,7 @@ export function SoundController() { // Incoming Call (Ringing) if (state.incomingCall && !incomingCallLoop.current) { - audioManager.playSound('call_ringing', { loop: true }).then(source => { + audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => { incomingCallLoop.current = source; }); } else if (!state.incomingCall && incomingCallLoop.current) { @@ -114,7 +121,7 @@ export function SoundController() { // Outgoing Call (Calling) if (state.outgoingCall && !outgoingCallLoop.current) { - audioManager.playSound('call_calling', { loop: true }).then(source => { + audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => { outgoingCallLoop.current = source; }); } else if (!state.outgoingCall && outgoingCallLoop.current) { @@ -132,7 +139,7 @@ export function SoundController() { const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length); for (const { message } of newEvents) { if (message.userId !== currentUser?.id) { - audioManager.playSound('message'); + audioManager.playSound('message', { volume: getSfxVolume() }); break; // one sound per batch } } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index bc69dabe..c15ab156 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -43,6 +43,8 @@ interface VoiceState { streamMutes: Map; // userId → muted? watchingStreams: Set; // userIds we're watching unwatchedCameras: Set; // userIds whose cameras we've opted out of + soundEffectVolume: number; // 0-200 (100 = default) + setSoundEffectVolume: (volume: number) => void; streamAttenuationEnabled: boolean; // global toggle, default true streamAttenuationStrength: number; // 0-100, default 50 setStreamVolume: (userId: string, volume: number) => void; @@ -150,6 +152,9 @@ export const useVoiceStore = create()( }); }, + soundEffectVolume: 100, + setSoundEffectVolume: (volume) => set({ soundEffectVolume: volume }), + // Stream widget state streamVolumes: new Map(), streamMutes: new Map(), @@ -509,7 +514,7 @@ export const useVoiceStore = create()( }), { name: 'backspace-voice-settings', - version: 7, + version: 8, migrate: (persistedState: any, version: number) => { if (version === 0) { persistedState.streamAttenuationEnabled = false; @@ -542,6 +547,9 @@ export const useVoiceStore = create()( if (version < 7) { // No data migration needed — Sets will be populated from server on next connect } + if (version < 8) { + persistedState.soundEffectVolume = 100; + } return persistedState; }, storage: createJSONStorage(() => localStorage), @@ -560,6 +568,7 @@ export const useVoiceStore = create()( echoCancellation: state.echoCancellation, autoGainControl: state.autoGainControl, rnnoiseEnabled: state.rnnoiseEnabled, + soundEffectVolume: state.soundEffectVolume, streamAttenuationEnabled: state.streamAttenuationEnabled, streamAttenuationStrength: state.streamAttenuationStrength, }),