fix(volume): make the volume control fully fluid (de-quantize)

Volume snapped between 1% steps from two integer quantizers. UI: the volume sliders now use a 0..1000 scale so the shared int slider's round() lands on 0.1% (imperceptible). Runtime: the player volume atomic widened from integer-percent AtomicU64 to f32-bits AtomicU32 (same idiom as normalization_gain) so a continuous value no longer re-snaps on track restart / device switch / gapless. Bit-perfect untouched: 1.0f32 round-trips its bits, no gain multiply or ramp added to the protected ALSA-direct / DoP / JACK paths.

cc
This commit is contained in:
vicrodh committed 2026-07-12 20:17:12 -06:00
1 parent 2103bbd3eb
commit 42105eaf46
4 files changed
+33 -22

No files matched your search

+11 -9
View File
@@ -968,8 +968,10 @@ pub struct SharedState {
dsd_direct: Arc<std::sync::atomic::AtomicU8>,
/// True when audio data/source is available for playback or resume
has_loaded_audio: Arc<AtomicBool>,
/// Volume (0.0 - 1.0 stored as 0-100)
volume: Arc<AtomicU64>,
/// Volume (0.0 - 1.0, f32 stored as u32 bits — same idiom as
/// `normalization_gain`; integer-percent storage quantized the volume
/// to 1% on every re-apply)
volume: Arc<AtomicU32>,
/// Playback start time (Unix timestamp millis when started/resumed)
playback_start_millis: Arc<AtomicU64>,
/// Position when playback was started/resumed (in seconds)
@@ -1015,7 +1017,7 @@ impl SharedState {
current_track_id: Arc::new(AtomicU64::new(0)),
dsd_direct: Arc::new(std::sync::atomic::AtomicU8::new(0)),
has_loaded_audio: Arc::new(AtomicBool::new(false)),
volume: Arc::new(AtomicU64::new(75)),
volume: Arc::new(AtomicU32::new(0.75f32.to_bits())),
playback_start_millis: Arc::new(AtomicU64::new(0)),
position_at_start: Arc::new(AtomicU64::new(0)),
current_device: Arc::new(std::sync::RwLock::new(None)),
@@ -1287,7 +1289,7 @@ impl SharedState {
}
pub fn volume(&self) -> f32 {
self.volume.load(Ordering::SeqCst) as f32 / 100.0
f32::from_bits(self.volume.load(Ordering::SeqCst))
}
}
@@ -1982,7 +1984,7 @@ impl Player {
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
let volume = f32::from_bits(thread_state.volume.load(Ordering::SeqCst));
apply_engine_volume(&stream_opt, &engine, volume);
let source = match decode_with_fallback(&data) {
@@ -2314,7 +2316,7 @@ impl Player {
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
let volume = f32::from_bits(thread_state.volume.load(Ordering::SeqCst));
apply_engine_volume(&stream_opt, &engine, volume);
// Wait for minimum buffer before starting playback.
@@ -2937,7 +2939,7 @@ impl Player {
};
let volume =
thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
f32::from_bits(thread_state.volume.load(Ordering::SeqCst));
apply_engine_volume(&stream_opt, &engine, volume);
let source = match decode_with_fallback(&audio_data) {
@@ -3030,7 +3032,7 @@ impl Player {
AudioCommand::SetVolume(volume) => {
thread_state
.volume
.store((volume * 100.0) as u64, Ordering::SeqCst);
.store(volume.to_bits(), Ordering::SeqCst);
if let Some(ref engine) = *current_engine {
apply_engine_volume(&stream_opt, &engine, volume);
}
@@ -3159,7 +3161,7 @@ impl Player {
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
let volume = f32::from_bits(thread_state.volume.load(Ordering::SeqCst));
apply_engine_volume(&stream_opt, &engine, volume);
// Build the decoded source for the seek. Both
+5 -3
View File
@@ -143,10 +143,12 @@ component MiniVolume inherits Rectangle {
}
QbzSlider {
width: 96px;
// 0..1000 scale: the int slider rounds to whole steps;
// 0.1% = fluid volume.
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
changed(v) => { NowPlayingState.set-volume(v / 100.0); }
maximum: 1000;
value: NowPlayingState.volume * 1000;
changed(v) => { NowPlayingState.set-volume(v / 1000.0); }
}
Text {
text: Math.round(NowPlayingState.volume * 100) + "";
+7 -4
View File
@@ -846,19 +846,22 @@ export component PlayerBar inherits Rectangle {
// Large (this slider is outside any npb-mode conditional).
// 90 -> 81.
width: 81px;
// 0..1000 scale: the shared int slider rounds to whole
// steps, so a 0..100 range quantized volume to 1%.
// 0.1% steps are imperceptible -> fluid drag.
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
maximum: 1000;
value: NowPlayingState.volume * 1000;
changed(v) => {
if (!root.vol-locked) {
NowPlayingState.set-volume(v / 100.0);
NowPlayingState.set-volume(v / 1000.0);
}
}
// Persist only the final value on drag-end (avoids a
// ui_prefs write per drag tick).
released(v) => {
if (!root.vol-locked) {
NowPlayingState.persist-volume(v / 100.0);
NowPlayingState.persist-volume(v / 1000.0);
}
}
}
+10 -6
View File
@@ -903,12 +903,14 @@ export component PlayerBarSmall inherits Rectangle {
// homologated 244px envelope instead of
// leaving dead space above it.
height: 190px;
// 0..1000 scale: the int slider rounds
// to whole steps; 0.1% = fluid volume.
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
maximum: 1000;
value: NowPlayingState.volume * 1000;
changed(v) => {
if (!root.vol-locked) {
NowPlayingState.set-volume(v / 100.0);
NowPlayingState.set-volume(v / 1000.0);
}
}
}
@@ -942,12 +944,14 @@ export component PlayerBarSmall inherits Rectangle {
// -10% width (owner: tighten the horizontal volume in
// all NPB modes to free a little room). 80 -> 72.
width: 72px;
// 0..1000 scale: the int slider rounds to whole
// steps; 0.1% = fluid volume.
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
maximum: 1000;
value: NowPlayingState.volume * 1000;
changed(v) => {
if (!root.vol-locked) {
NowPlayingState.set-volume(v / 100.0);
NowPlayingState.set-volume(v / 1000.0);
}
}
}