From bb5e7dc95401022824ff18fbdbc71f5705b61162 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 14 Jun 2026 09:12:26 -0700 Subject: [PATCH] Apply perceptual curve to volume sliders (#4272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Players reported having to turn the volume slider down to ~20% before noticing any change in loudness. The sliders fed their linear 0–1 position straight to Howler's `volume()`, which is linear amplitude gain. Human loudness perception is roughly logarithmic, so the top ~80% of the slider all sounds nearly identical — the classic linear-fader problem. ## Fix Square the slider position into a perceptual (audio-taper) gain inside `SoundManager`. The stored setting and the displayed `%` remain the intuitive linear slider position; only the gain handed to Howler is curved. | Slider | Old gain (linear) | New gain (x²) | |--------|-------------------|---------------| | 100% | 1.00 | 1.00 | | 90% | 0.90 | 0.81 | | 80% | 0.80 | 0.64 | | 50% | 0.50 | 0.25 | | 20% | 0.20 | 0.04 | Lowering the slider from 100→80 now produces an audible drop instead of nothing until ~20%. ## Notes - Quadratic (x²) was chosen as a balanced, conservative taper. Cubic (x³) would make the top-end drop-off even more immediate if preferred. - Existing saved settings are unaffected; the same slider position will simply sound slightly quieter, which is the intended correction. ## Tests Updated `SoundManager.test.ts` to assert the curved gain and added a dedicated test locking in the top-of-range behavior. All 18 tests pass. Co-authored-by: Claude Opus 4.8 --- src/client/sound/SoundManager.ts | 12 ++++++++++-- tests/client/sound/SoundManager.test.ts | 22 ++++++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/client/sound/SoundManager.ts b/src/client/sound/SoundManager.ts index a30f4c5e3..05526def5 100644 --- a/src/client/sound/SoundManager.ts +++ b/src/client/sound/SoundManager.ts @@ -107,8 +107,16 @@ export class SoundManager { }); } + // Slider positions are linear (0–1) but perceived loudness is roughly + // logarithmic, so feeding the position straight to Howler makes the top of + // the range sound identical. Square the position for an audio-taper curve. + private perceptualGain(position: number): number { + const clamped = Math.max(0, Math.min(1, position)); + return clamped * clamped; + } + public setBackgroundMusicVolume(volume: number): void { - this.backgroundMusicVolume = Math.max(0, Math.min(1, volume)); + this.backgroundMusicVolume = this.perceptualGain(volume); this.safely("set background music volume", () => { this.backgroundMusic.forEach((track) => { track.volume(this.backgroundMusicVolume); @@ -159,7 +167,7 @@ export class SoundManager { } public setSoundEffectsVolume(volume: number): void { - this.soundEffectsVolume = Math.max(0, Math.min(1, volume)); + this.soundEffectsVolume = this.perceptualGain(volume); this.safely("set sound effects volume", () => { this.soundEffects.forEach((sound) => { sound.volume(this.soundEffectsVolume); diff --git a/tests/client/sound/SoundManager.test.ts b/tests/client/sound/SoundManager.test.ts index a8827ebf1..bf19d0aed 100644 --- a/tests/client/sound/SoundManager.test.ts +++ b/tests/client/sound/SoundManager.test.ts @@ -113,7 +113,8 @@ describe("SoundManager", () => { new SoundManager(bus, settings); const bgHowls = howlInstances.slice(0, 3); bgHowls.forEach((h) => { - expect(h.volume).toHaveBeenCalledWith(0.5); + // Slider position is curved (squared) into perceptual gain: 0.5² = 0.25. + expect(h.volume).toHaveBeenCalledWith(0.25); }); }); @@ -124,8 +125,9 @@ describe("SoundManager", () => { howlInstances.length = 0; new SoundManager(bus, settings); bus.emit(new PlaySoundEffectEvent("click")); + // Slider position 0.3 is curved (squared) into perceptual gain: 0.3² = 0.09. expect(howlCtor).toHaveBeenLastCalledWith( - expect.objectContaining({ volume: 0.3 }), + expect.objectContaining({ volume: 0.09 }), ); }); @@ -133,7 +135,8 @@ describe("SoundManager", () => { eventBus.emit(new SetBackgroundMusicVolumeEvent(0.7)); const bgHowls = howlInstances.slice(0, 3); bgHowls.forEach((h) => { - expect(h.volume).toHaveBeenCalledWith(0.7); + // 0.7² = 0.49 perceptual gain. + expect(h.volume).toHaveBeenCalledWith(0.7 * 0.7); }); }); @@ -142,7 +145,8 @@ describe("SoundManager", () => { const clickHowl = howlInstances[howlInstances.length - 1]; clickHowl.volume.mockClear(); eventBus.emit(new SetSoundEffectsVolumeEvent(0.4)); - expect(clickHowl.volume).toHaveBeenCalledWith(0.4); + // 0.4² = 0.16 perceptual gain. + expect(clickHowl.volume).toHaveBeenCalledWith(0.4 * 0.4); }); it("clamps volume values between 0 and 1", () => { @@ -159,6 +163,16 @@ describe("SoundManager", () => { }); }); + it("curves the slider position into perceptual gain so the top of the range is audibly distinct", () => { + const bgHowls = howlInstances.slice(0, 3); + // Linear gain would make 0.9 and 1.0 nearly indistinguishable; squaring + // spreads the top end (0.9 → 0.81) so reductions are noticeable sooner. + eventBus.emit(new SetBackgroundMusicVolumeEvent(0.9)); + bgHowls.forEach((h) => { + expect(h.volume).toHaveBeenLastCalledWith(0.81); + }); + }); + it("dispose() unsubscribes from EventBus so events no longer play sounds", () => { eventBus.emit(new PlaySoundEffectEvent("click")); const clickHowl = howlInstances[howlInstances.length - 1];