forked from Keelrleek/SoulStream
fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with the position advancing and no error, but the sink was at volume 0. The open/close crossfade ramped the main player's USER VOLUME as its fade channel and left it at 0 on an ack timeout (or when a track change suppressed the close resume). User volume persists across tracks and a new Play doesn't reset it, so every later click played at 0 while the slider still read 0.7. Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink = volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close fades ramp fade_gain instead of set_volume (their volume arg is gone, JS callers too), so the user volume is never touched -- store and backend can't desync -- and a clicked track always resets fade_gain to 1, so it can never be stranded silent. fade_resume lands exactly at 1. Player::fade_gain() mirrors it into a report atomic; debug overlay shows main fade + this proved the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7). 39 app + 12 player + 85 JS tests pass.
This commit is contained in:
1 parent
28aba3a3b3
commit
86e0d86604
7 files changed
+98
-45
No files matched your search
+51
-10
@@ -113,6 +113,10 @@ enum Cmd {
|
||||
/// exactly where loudness may step. Cleared by Play/Stop; an unknown
|
||||
/// next track falls back to unity at the boundary.
|
||||
SetNextNormGainDb(f32),
|
||||
/// Transient fade multiplier (0..1) for the preview open/close crossfade,
|
||||
/// SEPARATE from the user volume so a fade can never strand the volume
|
||||
/// setting. Reset to 1 by every Play/Stop.
|
||||
SetFadeGain(f32),
|
||||
SetOutputDevice {
|
||||
device: Option<String>,
|
||||
reply: mpsc::Sender<Result<(), String>>,
|
||||
@@ -227,6 +231,9 @@ where
|
||||
#[derive(Clone)]
|
||||
pub struct Player {
|
||||
cmd_tx: mpsc::Sender<Cmd>,
|
||||
/// f32 bits; the audio thread mirrors its live fade-gain here for
|
||||
/// observability (the debug overlay / crossfade diagnostics). 1.0 = full.
|
||||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -242,13 +249,20 @@ impl Player {
|
||||
) -> (Player, mpsc::Receiver<PlayerEvent>) {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||||
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
|
||||
let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
|
||||
|
||||
let thread_report = fade_report.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("audio".into())
|
||||
.spawn(move || audio_thread(cmd_rx, event_tx, output_device))
|
||||
.spawn(move || audio_thread(cmd_rx, event_tx, output_device, thread_report))
|
||||
.expect("spawn audio thread");
|
||||
|
||||
(Player { cmd_tx }, event_rx)
|
||||
(Player { cmd_tx, fade_report }, event_rx)
|
||||
}
|
||||
|
||||
/// The audio thread's current fade-gain (0..1). Observability only.
|
||||
pub fn fade_gain(&self) -> f32 {
|
||||
f32::from_bits(self.fade_report.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
|
||||
@@ -352,6 +366,13 @@ impl Player {
|
||||
.send(Cmd::SetNextNormGainDb(db.clamp(-18.0, 9.0)));
|
||||
}
|
||||
|
||||
/// Transient fade multiplier (0..1) for the preview open/close crossfade.
|
||||
/// A separate channel from the user volume — reset to 1 by every Play/Stop
|
||||
/// — so a fade can never leave a later track silent. See [`Cmd::SetFadeGain`].
|
||||
pub fn set_fade_gain(&self, gain: f32) {
|
||||
let _ = self.cmd_tx.send(Cmd::SetFadeGain(gain.clamp(0.0, 1.0)));
|
||||
}
|
||||
|
||||
/// Change devices immediately. The audio thread acknowledges only after
|
||||
/// the endpoint has opened successfully, so callers can safely persist it.
|
||||
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
|
||||
@@ -476,6 +497,7 @@ fn audio_thread(
|
||||
cmd_rx: mpsc::Receiver<Cmd>,
|
||||
event_tx: mpsc::Sender<PlayerEvent>,
|
||||
initial_device: Option<String>,
|
||||
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||||
) {
|
||||
// Open the output device lazily and keep it across tracks. If the device
|
||||
// is momentarily busy (e.g. another instance still releasing it), we
|
||||
@@ -505,7 +527,12 @@ fn audio_thread(
|
||||
let mut norm_target_db: f32 = 0.0;
|
||||
// Gain waiting for the next gapless boundary (None → unity there).
|
||||
let mut pending_norm_db: Option<f32> = None;
|
||||
let eff = |user: f32, ndb: f32| user * 10f32.powf(ndb / 20.0);
|
||||
// Transient fade multiplier (0..1) for the preview session's open/close
|
||||
// crossfade — a SEPARATE channel from the user volume, so a fade can
|
||||
// never corrupt the volume setting (leaving a track silent after the
|
||||
// fade or across the next Play). Reset to 1 by every Play/Stop.
|
||||
let mut fade_gain: f32 = 1.0;
|
||||
let eff = |user: f32, ndb: f32, fg: f32| user * 10f32.powf(ndb / 20.0) * fg;
|
||||
let mut playing = false;
|
||||
// The active stream source, so we can abort its blocking reads.
|
||||
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
||||
@@ -568,6 +595,11 @@ fn audio_thread(
|
||||
norm_db = 0.0;
|
||||
norm_target_db = 0.0;
|
||||
pending_norm_db = None;
|
||||
// A clicked/queued track always plays at full: never inherit a
|
||||
// preview crossfade's residual fade level (that was the
|
||||
// "audio broken after a track click" bug).
|
||||
fade_gain = 1.0;
|
||||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||||
let track = TrackSource {
|
||||
path,
|
||||
stream,
|
||||
@@ -672,7 +704,7 @@ fn audio_thread(
|
||||
match decode_scratch_window(path, secs, rate) {
|
||||
Ok((samples, speed)) => {
|
||||
let s = Sink::connect_new(out.mixer());
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
s.set_speed(speed);
|
||||
s.append(samples);
|
||||
s.play();
|
||||
@@ -700,7 +732,7 @@ fn audio_thread(
|
||||
match local_decoder(path) {
|
||||
Ok(decoder) => {
|
||||
let s = Sink::connect_new(out.mixer());
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
queued_sources.store(1, Ordering::SeqCst);
|
||||
s.append(EndNotifier {
|
||||
inner: decoder,
|
||||
@@ -731,7 +763,7 @@ fn audio_thread(
|
||||
Ok(Cmd::Volume(v)) => {
|
||||
volume = v;
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
Ok(Cmd::SetNormGainDb { db, immediate }) => {
|
||||
@@ -739,7 +771,7 @@ fn audio_thread(
|
||||
if immediate {
|
||||
norm_db = db;
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
// Non-immediate: the tick arm slews norm_db there.
|
||||
@@ -747,6 +779,13 @@ fn audio_thread(
|
||||
Ok(Cmd::SetNextNormGainDb(db)) => {
|
||||
pending_norm_db = Some(db);
|
||||
}
|
||||
Ok(Cmd::SetFadeGain(fg)) => {
|
||||
fade_gain = fg.clamp(0.0, 1.0);
|
||||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
Ok(Cmd::SetOutputDevice { device, reply }) => {
|
||||
output_change = Some((device, Some(reply)));
|
||||
}
|
||||
@@ -762,6 +801,8 @@ fn audio_thread(
|
||||
waiting_preload = None;
|
||||
pending_tracks.clear();
|
||||
pending_norm_db = None;
|
||||
fade_gain = 1.0;
|
||||
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||||
playing = false;
|
||||
scratching = false;
|
||||
local_path = None;
|
||||
@@ -776,7 +817,7 @@ fn audio_thread(
|
||||
if diff.abs() > 0.001 {
|
||||
norm_db += diff.clamp(-0.15, 0.15);
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -882,7 +923,7 @@ fn audio_thread(
|
||||
let track_duration = ready.track_duration();
|
||||
if !is_preload {
|
||||
let s = Sink::connect_new(out.mixer());
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
sink = Some(s);
|
||||
active_track = Some(ready.track.clone());
|
||||
active_stream = ready.track.stream.clone();
|
||||
@@ -969,7 +1010,7 @@ fn audio_thread(
|
||||
norm_db = pending_norm_db.take().unwrap_or(0.0);
|
||||
norm_target_db = norm_db;
|
||||
if let Some(s) = &sink {
|
||||
s.set_volume(eff(volume, norm_db));
|
||||
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||
}
|
||||
}
|
||||
last_queued = q;
|
||||
|
||||
Reference in new issue
Block a user