Apply perceptual curve to volume sliders (#4272)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-14 09:12:26 -07:00
committed by GitHub
parent 1c5122e2d2
commit bb5e7dc954
2 changed files with 28 additions and 6 deletions
+18 -4
View File
@@ -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];