Non-linear volume controls - #66
Open
AntonioSanFer wants to merge 2 commits into
Open
Conversation
audio element volume is a linear amplitude multiplier, but loudness perception is logarithmic, meaning the most notable volume adjustments always happened on the 0-10 scale. settings.volume now holds the slider position and is mapped through a decibel-linear taper before reaching the audio elements. ramp crossfades over slider positions instead of raw amplitude normalise mouse wheel deltas across devices and carry sub-step movement use the unused full volume icon above 80%
it looked horrible above 80%. back to the original mute/low/mid icons, the threshold and markup are identical to master again.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: make volume controls non-linear
The problem
HTMLMediaElement.volumeis a linear amplitude multiplier, but loudness perceptionis logarithmic. The slider fed its position straight into it, so the same amount of
travel did wildly different things depending on where you grabbed it:
In practice the top half of the slider did almost nothing and every meaningful
adjustment was crammed into the bottom tenth.
While listening music, I spent most of the time tweaking the volume at the 1-7 range,
having anything above 10 just being really loud and without much perceptible difference.
The approach
Split the one value into two:
settings.volume— the slider position (0–1). What the user sees and adjusts,and what gets persisted.
settings.volume_gain(new getter) — that position run through a decibel-lineartaper, and the only thing that ever reaches an audio element.
The taper is
dB = -50 × (1 - position), with a linear fade to true silence below 5%so the bottom of the slider still means off rather than -50 dB.
This results in constant 5 dB per 10% of travel, anywhere on the slider.
50 dB was picked to keep ~0.5 dB of resolution per 1% of travel — fine enough that small
nudges near the top are audible, wide enough that the low end stays usable for quiet
listening.
Background: https://www.dr-lex.be/info-stuff/volumecontrols.html
Because the stored value is still a slider position, no migration is needed — existing
persisted settings keep working as-is.
Changes
utils/audio/volume.ts(new) —volumeToGain,volumeRampGain,clamp.stores/settings/index.ts—volume_gaingetter;setVolumeclamps and pushes the gain.stores/player.ts— the three places that assignedsettings.volumeto an audioelement now use
volume_gain.utils/audio/crossFade.ts— ramps over slider positions, so fades are even by ear(a linear-amplitude ramp spends most of its time inaudible, then lurches). Also switched
from drift-prone step accumulation to elapsed-time progress, 25 ms ticks instead of 100 ms
(10 steps over a 1 s fade was a 5 dB zipper), a clearer
fade_out: booleanin place ofstart_volume, and aWeakMapso a new fade cancels a stale one on the same elementduring rapid track switching.
components/BottomBar/Volume.vue— wheel deltas normalised perdeltaModeand cappedat 3 notches (raw
deltaYis wildly device-dependent; one flick of a high-res wheel couldslam silent → full), with sub-step movement carried between events so slow trackpad
scrolling still registers. Added
aria-label/aria-valuetextto the range input.Testing
Verified end-to-end against
ghcr.io/swingmx/swingmusic:latestin Docker, serving thisbranch's own build.
Also:
tsc --noEmitclean on all touched files (6 pre-existing errors elsewhere),eslintclean,
vite buildsucceeds, and the curve logic was exercised offline for monotonicity andrange across 10k points, endpoints, NaN / out-of-range inputs, ramp clamping, and wheel
behaviour.