feat: increase perceived audio loudness — +3dB master boost, configurable SFX volume

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
This commit is contained in:
Jannis Braun
2026-03-12 23:42:11 +01:00
parent 7166be0507
commit 770e0490dd
4 changed files with 68 additions and 19 deletions
+11 -5
View File
@@ -12,6 +12,7 @@ export class AudioManager {
private silentGain: GainNode | null = null; private silentGain: GainNode | null = null;
private analyser: AnalyserNode | null = null; private analyser: AnalyserNode | null = null;
private masterCompressor: DynamicsCompressorNode | null = null; private masterCompressor: DynamicsCompressorNode | null = null;
private masterBoost: GainNode | null = null;
private currentInputDeviceId: string = 'default'; private currentInputDeviceId: string = 'default';
private desiredOutputDeviceId: string = 'default'; private desiredOutputDeviceId: string = 'default';
@@ -61,6 +62,11 @@ export class AudioManager {
this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking
this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks
this.masterCompressor.release.value = 0.01; // 10ms — recover quickly 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.masterCompressor.connect(this.ctx.destination);
this.inputGain.connect(this.inputDestination); this.inputGain.connect(this.inputDestination);
@@ -168,10 +174,10 @@ export class AudioManager {
source.loop = options.loop || false; source.loop = options.loop || false;
const gainNode = this.ctx.createGain(); const gainNode = this.ctx.createGain();
gainNode.gain.value = options.volume ?? 0.5; gainNode.gain.value = options.volume ?? 0.8;
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(this.masterCompressor!); gainNode.connect(this.masterBoost!);
source.start(0); source.start(0);
return source; 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. * 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. * Output device is controlled via setSinkId on the underlying AudioContext.
*/ */
getMasterOutput(): AudioNode { getMasterOutput(): AudioNode {
if (!this.ctx) this.initContext(); if (!this.ctx) this.initContext();
return this.masterCompressor!; return this.masterBoost!;
} }
getContext(): AudioContext | null { getContext(): AudioContext | null {
@@ -8,9 +8,36 @@ export function VoicePanel() {
const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation); const setEchoCancellation = useVoiceStore((s) => s.setEchoCancellation);
const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl); const setAutoGainControl = useVoiceStore((s) => s.setAutoGainControl);
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
const soundEffectVolume = useVoiceStore((s) => s.soundEffectVolume);
const setSoundEffectVolume = useVoiceStore((s) => s.setSoundEffectVolume);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Volume
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="py-1">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-txt-primary">Sound Effects Volume</div>
<div className="text-xs text-txt-tertiary tabular-nums">{soundEffectVolume}%</div>
</div>
<input
type="range"
min={0}
max={200}
value={soundEffectVolume}
onChange={(e) => 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%)`,
}}
/>
</div>
</div>
</div>
<div> <div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Processing Voice Processing
@@ -4,6 +4,11 @@ import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { AudioManager } from '../../audio/AudioManager'; import { AudioManager } from '../../audio/AudioManager';
/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0200). */
function getSfxVolume(): number {
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100);
}
export function SoundController() { export function SoundController() {
const audioManager = AudioManager.getInstance(); const audioManager = AudioManager.getInstance();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
@@ -31,37 +36,39 @@ export function SoundController() {
const unsubscribeVoice = useVoiceStore.subscribe((state) => { const unsubscribeVoice = useVoiceStore.subscribe((state) => {
if (isInitialMount.current) return; if (isInitialMount.current) return;
const sfxOpts = { volume: getSfxVolume() };
// Mute/Unmute // Mute/Unmute
if (state.isMuted !== prevIsMuted.current) { if (state.isMuted !== prevIsMuted.current) {
audioManager.playSound(state.isMuted ? 'mute' : 'unmute'); audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts);
prevIsMuted.current = state.isMuted; prevIsMuted.current = state.isMuted;
} }
// Deafen/Undeafen // Deafen/Undeafen
if (state.isDeafened !== prevIsDeafened.current) { if (state.isDeafened !== prevIsDeafened.current) {
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen'); audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts);
prevIsDeafened.current = state.isDeafened; prevIsDeafened.current = state.isDeafened;
} }
// Camera Toggle // Camera Toggle
if (state.isCameraOn !== prevIsCameraOn.current) { 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; prevIsCameraOn.current = state.isCameraOn;
} }
// Screen Share Toggle (Self) // Screen Share Toggle (Self)
if (state.isScreenSharing !== prevIsScreenSharing.current) { 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; prevIsScreenSharing.current = state.isScreenSharing;
} }
// Disconnect (Self) // Disconnect (Self)
if (prevIsConnected.current && !state.isLiveKitConnected) { if (prevIsConnected.current && !state.isLiveKitConnected) {
audioManager.playSound('disconnect'); audioManager.playSound('disconnect', sfxOpts);
} }
// Connect (Self) // Connect (Self)
if (!prevIsConnected.current && state.isLiveKitConnected) { if (!prevIsConnected.current && state.isLiveKitConnected) {
audioManager.playSound('user_join'); audioManager.playSound('user_join', sfxOpts);
} }
prevIsConnected.current = state.isLiveKitConnected; prevIsConnected.current = state.isLiveKitConnected;
@@ -73,28 +80,28 @@ export function SoundController() {
// Someone joined voice (Others only) // Someone joined voice (Others only)
state.participants.forEach(p => { state.participants.forEach(p => {
if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) { if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) {
audioManager.playSound('user_join'); audioManager.playSound('user_join', sfxOpts);
} }
}); });
// Someone left voice (Others only) // Someone left voice (Others only)
prevParticipantIds.current.forEach(userId => { prevParticipantIds.current.forEach(userId => {
if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) { if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) {
audioManager.playSound('user_leave'); audioManager.playSound('user_leave', sfxOpts);
} }
}); });
// Someone started screen sharing (Others only) // Someone started screen sharing (Others only)
state.participants.forEach(p => { state.participants.forEach(p => {
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && p.userId !== currentUser?.id) { 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) // Someone stopped screen sharing (Others only)
prevScreenShareUserIds.current.forEach(userId => { prevScreenShareUserIds.current.forEach(userId => {
if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) { 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) // Incoming Call (Ringing)
if (state.incomingCall && !incomingCallLoop.current) { 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; incomingCallLoop.current = source;
}); });
} else if (!state.incomingCall && incomingCallLoop.current) { } else if (!state.incomingCall && incomingCallLoop.current) {
@@ -114,7 +121,7 @@ export function SoundController() {
// Outgoing Call (Calling) // Outgoing Call (Calling)
if (state.outgoingCall && !outgoingCallLoop.current) { 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; outgoingCallLoop.current = source;
}); });
} else if (!state.outgoingCall && outgoingCallLoop.current) { } else if (!state.outgoingCall && outgoingCallLoop.current) {
@@ -132,7 +139,7 @@ export function SoundController() {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length); const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) { for (const { message } of newEvents) {
if (message.userId !== currentUser?.id) { if (message.userId !== currentUser?.id) {
audioManager.playSound('message'); audioManager.playSound('message', { volume: getSfxVolume() });
break; // one sound per batch break; // one sound per batch
} }
} }
+10 -1
View File
@@ -43,6 +43,8 @@ interface VoiceState {
streamMutes: Map<string, boolean>; // userId → muted? streamMutes: Map<string, boolean>; // userId → muted?
watchingStreams: Set<string>; // userIds we're watching watchingStreams: Set<string>; // userIds we're watching
unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of
soundEffectVolume: number; // 0-200 (100 = default)
setSoundEffectVolume: (volume: number) => void;
streamAttenuationEnabled: boolean; // global toggle, default true streamAttenuationEnabled: boolean; // global toggle, default true
streamAttenuationStrength: number; // 0-100, default 50 streamAttenuationStrength: number; // 0-100, default 50
setStreamVolume: (userId: string, volume: number) => void; setStreamVolume: (userId: string, volume: number) => void;
@@ -150,6 +152,9 @@ export const useVoiceStore = create<VoiceState>()(
}); });
}, },
soundEffectVolume: 100,
setSoundEffectVolume: (volume) => set({ soundEffectVolume: volume }),
// Stream widget state // Stream widget state
streamVolumes: new Map(), streamVolumes: new Map(),
streamMutes: new Map(), streamMutes: new Map(),
@@ -509,7 +514,7 @@ export const useVoiceStore = create<VoiceState>()(
}), }),
{ {
name: 'backspace-voice-settings', name: 'backspace-voice-settings',
version: 7, version: 8,
migrate: (persistedState: any, version: number) => { migrate: (persistedState: any, version: number) => {
if (version === 0) { if (version === 0) {
persistedState.streamAttenuationEnabled = false; persistedState.streamAttenuationEnabled = false;
@@ -542,6 +547,9 @@ export const useVoiceStore = create<VoiceState>()(
if (version < 7) { if (version < 7) {
// No data migration needed — Sets will be populated from server on next connect // No data migration needed — Sets will be populated from server on next connect
} }
if (version < 8) {
persistedState.soundEffectVolume = 100;
}
return persistedState; return persistedState;
}, },
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
@@ -560,6 +568,7 @@ export const useVoiceStore = create<VoiceState>()(
echoCancellation: state.echoCancellation, echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl, autoGainControl: state.autoGainControl,
rnnoiseEnabled: state.rnnoiseEnabled, rnnoiseEnabled: state.rnnoiseEnabled,
soundEffectVolume: state.soundEffectVolume,
streamAttenuationEnabled: state.streamAttenuationEnabled, streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength, streamAttenuationStrength: state.streamAttenuationStrength,
}), }),