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
|
/// exactly where loudness may step. Cleared by Play/Stop; an unknown
|
||||||
/// next track falls back to unity at the boundary.
|
/// next track falls back to unity at the boundary.
|
||||||
SetNextNormGainDb(f32),
|
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 {
|
SetOutputDevice {
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
reply: mpsc::Sender<Result<(), String>>,
|
reply: mpsc::Sender<Result<(), String>>,
|
||||||
@@ -227,6 +231,9 @@ where
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
cmd_tx: mpsc::Sender<Cmd>,
|
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 {
|
impl Player {
|
||||||
@@ -242,13 +249,20 @@ impl Player {
|
|||||||
) -> (Player, mpsc::Receiver<PlayerEvent>) {
|
) -> (Player, mpsc::Receiver<PlayerEvent>) {
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
|
||||||
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
|
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()
|
std::thread::Builder::new()
|
||||||
.name("audio".into())
|
.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");
|
.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>) {
|
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)));
|
.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
|
/// Change devices immediately. The audio thread acknowledges only after
|
||||||
/// the endpoint has opened successfully, so callers can safely persist it.
|
/// the endpoint has opened successfully, so callers can safely persist it.
|
||||||
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
|
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
|
||||||
@@ -476,6 +497,7 @@ fn audio_thread(
|
|||||||
cmd_rx: mpsc::Receiver<Cmd>,
|
cmd_rx: mpsc::Receiver<Cmd>,
|
||||||
event_tx: mpsc::Sender<PlayerEvent>,
|
event_tx: mpsc::Sender<PlayerEvent>,
|
||||||
initial_device: Option<String>,
|
initial_device: Option<String>,
|
||||||
|
fade_report: Arc<std::sync::atomic::AtomicU32>,
|
||||||
) {
|
) {
|
||||||
// Open the output device lazily and keep it across tracks. If the device
|
// Open the output device lazily and keep it across tracks. If the device
|
||||||
// is momentarily busy (e.g. another instance still releasing it), we
|
// 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;
|
let mut norm_target_db: f32 = 0.0;
|
||||||
// Gain waiting for the next gapless boundary (None → unity there).
|
// Gain waiting for the next gapless boundary (None → unity there).
|
||||||
let mut pending_norm_db: Option<f32> = None;
|
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;
|
let mut playing = false;
|
||||||
// The active stream source, so we can abort its blocking reads.
|
// The active stream source, so we can abort its blocking reads.
|
||||||
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
let mut active_stream: Option<Arc<StreamProgress>> = None;
|
||||||
@@ -568,6 +595,11 @@ fn audio_thread(
|
|||||||
norm_db = 0.0;
|
norm_db = 0.0;
|
||||||
norm_target_db = 0.0;
|
norm_target_db = 0.0;
|
||||||
pending_norm_db = None;
|
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 {
|
let track = TrackSource {
|
||||||
path,
|
path,
|
||||||
stream,
|
stream,
|
||||||
@@ -672,7 +704,7 @@ fn audio_thread(
|
|||||||
match decode_scratch_window(path, secs, rate) {
|
match decode_scratch_window(path, secs, rate) {
|
||||||
Ok((samples, speed)) => {
|
Ok((samples, speed)) => {
|
||||||
let s = Sink::connect_new(out.mixer());
|
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.set_speed(speed);
|
||||||
s.append(samples);
|
s.append(samples);
|
||||||
s.play();
|
s.play();
|
||||||
@@ -700,7 +732,7 @@ fn audio_thread(
|
|||||||
match local_decoder(path) {
|
match local_decoder(path) {
|
||||||
Ok(decoder) => {
|
Ok(decoder) => {
|
||||||
let s = Sink::connect_new(out.mixer());
|
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);
|
queued_sources.store(1, Ordering::SeqCst);
|
||||||
s.append(EndNotifier {
|
s.append(EndNotifier {
|
||||||
inner: decoder,
|
inner: decoder,
|
||||||
@@ -731,7 +763,7 @@ fn audio_thread(
|
|||||||
Ok(Cmd::Volume(v)) => {
|
Ok(Cmd::Volume(v)) => {
|
||||||
volume = v;
|
volume = v;
|
||||||
if let Some(s) = &sink {
|
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 }) => {
|
Ok(Cmd::SetNormGainDb { db, immediate }) => {
|
||||||
@@ -739,7 +771,7 @@ fn audio_thread(
|
|||||||
if immediate {
|
if immediate {
|
||||||
norm_db = db;
|
norm_db = db;
|
||||||
if let Some(s) = &sink {
|
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.
|
// Non-immediate: the tick arm slews norm_db there.
|
||||||
@@ -747,6 +779,13 @@ fn audio_thread(
|
|||||||
Ok(Cmd::SetNextNormGainDb(db)) => {
|
Ok(Cmd::SetNextNormGainDb(db)) => {
|
||||||
pending_norm_db = Some(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 }) => {
|
Ok(Cmd::SetOutputDevice { device, reply }) => {
|
||||||
output_change = Some((device, Some(reply)));
|
output_change = Some((device, Some(reply)));
|
||||||
}
|
}
|
||||||
@@ -762,6 +801,8 @@ fn audio_thread(
|
|||||||
waiting_preload = None;
|
waiting_preload = None;
|
||||||
pending_tracks.clear();
|
pending_tracks.clear();
|
||||||
pending_norm_db = None;
|
pending_norm_db = None;
|
||||||
|
fade_gain = 1.0;
|
||||||
|
fade_report.store(fade_gain.to_bits(), Ordering::Relaxed);
|
||||||
playing = false;
|
playing = false;
|
||||||
scratching = false;
|
scratching = false;
|
||||||
local_path = None;
|
local_path = None;
|
||||||
@@ -776,7 +817,7 @@ fn audio_thread(
|
|||||||
if diff.abs() > 0.001 {
|
if diff.abs() > 0.001 {
|
||||||
norm_db += diff.clamp(-0.15, 0.15);
|
norm_db += diff.clamp(-0.15, 0.15);
|
||||||
if let Some(s) = &sink {
|
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();
|
let track_duration = ready.track_duration();
|
||||||
if !is_preload {
|
if !is_preload {
|
||||||
let s = Sink::connect_new(out.mixer());
|
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);
|
sink = Some(s);
|
||||||
active_track = Some(ready.track.clone());
|
active_track = Some(ready.track.clone());
|
||||||
active_stream = ready.track.stream.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_db = pending_norm_db.take().unwrap_or(0.0);
|
||||||
norm_target_db = norm_db;
|
norm_target_db = norm_db;
|
||||||
if let Some(s) = &sink {
|
if let Some(s) = &sink {
|
||||||
s.set_volume(eff(volume, norm_db));
|
s.set_volume(eff(volume, norm_db, fade_gain));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last_queued = q;
|
last_queued = q;
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ pub struct DebugStats {
|
|||||||
pub normalization_enabled: bool,
|
pub normalization_enabled: bool,
|
||||||
pub target_lufs: f64,
|
pub target_lufs: f64,
|
||||||
pub loudness: Option<crate::loudness::CurrentTrack>,
|
pub loudness: Option<crate::loudness::CurrentTrack>,
|
||||||
|
/// Main player's live fade-gain (1.0 = full; <1 only during a preview
|
||||||
|
/// open/close crossfade). Proves a fade never strands the volume.
|
||||||
|
pub main_fade_gain: Option<f32>,
|
||||||
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
|
||||||
pub preview_volume: Option<f32>,
|
pub preview_volume: Option<f32>,
|
||||||
/// The active preview clip's loudness-normalization gain in dB.
|
/// The active preview clip's loudness-normalization gain in dB.
|
||||||
@@ -173,6 +176,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
|
|||||||
preview_norm_db: app
|
preview_norm_db: app
|
||||||
.try_state::<crate::preview::PreviewState>()
|
.try_state::<crate::preview::PreviewState>()
|
||||||
.map(|s| crate::preview::active_norm_db(&s)),
|
.map(|s| crate::preview::active_norm_db(&s)),
|
||||||
|
main_fade_gain: app
|
||||||
|
.try_state::<crate::playback::PlayerState>()
|
||||||
|
.map(|p| p.player.fade_gain()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-27
@@ -688,27 +688,26 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> {
|
|||||||
/// the preview voices' `fade_gen`.
|
/// the preview voices' `fade_gen`.
|
||||||
static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0);
|
static MAIN_FADE_GEN: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
/// Session-open crossfade, main side: ramp the main player to silence over
|
/// Session-open crossfade, main side: ramp the main player's FADE-GAIN (a
|
||||||
/// `fade_ms` (equal-power cos, so the preview's sin ramp-in sums level),
|
/// channel separate from the user volume) 1→0 over `fade_ms` (equal-power
|
||||||
/// then pause it WITH the desired-paused latch, then restore `volume` on the
|
/// cos, so the preview's sin ramp-in sums level), then pause with the
|
||||||
/// now-silent sink so the later resume has its level back. Returns as soon
|
/// desired-paused latch. Returns as soon as the fade thread is RUNNING — the
|
||||||
/// as the fade thread is RUNNING — the caller starts the preview's ramp-in
|
/// caller starts the preview's ramp-in over it; the overlap is the point
|
||||||
/// over it; the overlap is the point (deliberate relaxation of the Phase
|
/// (deliberate relaxation of the Phase 0.4 "acknowledged silence before any
|
||||||
/// 0.4 "acknowledged silence before any preview audio" rule, user decision
|
/// preview audio" rule, user decision 2026-07-23).
|
||||||
/// 2026-07-23). If the ack never lands, the volume stays at 0 — silent is
|
///
|
||||||
/// the safe failure — and the close-side fade-resume self-heals it.
|
/// Fade-gain is used INSTEAD of the user volume precisely so a fade can never
|
||||||
|
/// strand the volume: fade-gain resets to 1 on the next Play, and the
|
||||||
|
/// close-side fade-resume ramps it back — so even if the ack times out or a
|
||||||
|
/// track change suppresses the resume, a later clicked track still plays at
|
||||||
|
/// full (the "audio broken after a track click" bug this replaces).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn player_fade_pause_acked(
|
pub fn player_fade_pause_acked(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> {
|
||||||
app: tauri::AppHandle,
|
|
||||||
fade_ms: u64,
|
|
||||||
volume: f64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("main-fade-out".into())
|
.name("main-fade-out".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let players = app.state::<PlayerState>();
|
let players = app.state::<PlayerState>();
|
||||||
let v = volume as f32;
|
|
||||||
let dur = fade_ms.max(1);
|
let dur = fade_ms.max(1);
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
@@ -718,7 +717,7 @@ pub fn player_fade_pause_acked(
|
|||||||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||||||
players
|
players
|
||||||
.player
|
.player
|
||||||
.set_volume(v * (t * std::f32::consts::FRAC_PI_2).cos());
|
.set_fade_gain((t * std::f32::consts::FRAC_PI_2).cos());
|
||||||
if t >= 1.0 {
|
if t >= 1.0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -727,28 +726,27 @@ pub fn player_fade_pause_acked(
|
|||||||
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
if MAIN_FADE_GEN.load(Ordering::Relaxed) != gen {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pause with the latch. Fade-gain stays 0 while paused (silent),
|
||||||
|
// but the user volume is untouched, so nothing is stranded.
|
||||||
let rx = players.player.pause_acked();
|
let rx = players.player.pause_acked();
|
||||||
match rx.recv_timeout(Duration::from_millis(1_500)) {
|
let _ = rx.recv_timeout(Duration::from_millis(1_500));
|
||||||
Ok(_) => players.player.set_volume(v),
|
|
||||||
Err(_) => eprintln!("preview: main fade-pause ack timed out; volume left silent"),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session-close crossfade, main side: come back from the paused snapshot at
|
/// Session-close crossfade, main side: resume the paused main and ramp its
|
||||||
/// volume 0 and ramp to `volume` while the preview's fade-out rings down.
|
/// FADE-GAIN 0→1 while the preview's fade-out rings down. Resume clears the
|
||||||
/// Resume also clears the desired-paused latch (player-crate contract).
|
/// desired-paused latch (player-crate contract); the user volume is never
|
||||||
|
/// touched, so the track returns at exactly the level it left.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> Result<(), String> {
|
pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64) -> Result<(), String> {
|
||||||
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
let gen = MAIN_FADE_GEN.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("main-fade-in".into())
|
.name("main-fade-in".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let players = app.state::<PlayerState>();
|
let players = app.state::<PlayerState>();
|
||||||
let v = volume as f32;
|
players.player.set_fade_gain(0.0);
|
||||||
players.player.set_volume(0.0);
|
|
||||||
players.player.resume();
|
players.player.resume();
|
||||||
let dur = fade_ms.max(1);
|
let dur = fade_ms.max(1);
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
@@ -759,12 +757,16 @@ pub fn player_fade_resume(app: tauri::AppHandle, fade_ms: u64, volume: f64) -> R
|
|||||||
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
let t = (t0.elapsed().as_millis() as f32 / dur as f32).min(1.0);
|
||||||
players
|
players
|
||||||
.player
|
.player
|
||||||
.set_volume(v * (t * std::f32::consts::FRAC_PI_2).sin());
|
.set_fade_gain((t * std::f32::consts::FRAC_PI_2).sin());
|
||||||
if t >= 1.0 {
|
if t >= 1.0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
std::thread::sleep(FADE_TICK);
|
std::thread::sleep(FADE_TICK);
|
||||||
}
|
}
|
||||||
|
// Land exactly at unity so a stale tick can't leave it short.
|
||||||
|
if MAIN_FADE_GEN.load(Ordering::Relaxed) == gen {
|
||||||
|
players.player.set_fade_gain(1.0);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -92,7 +92,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sec">
|
<div class="sec">
|
||||||
<div><b>FPS</b> {fps}</div>
|
<div><b>FPS</b> {fps}</div>
|
||||||
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
|
<div>
|
||||||
|
<b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}
|
||||||
|
{#if stats.mainFadeGain != null && stats.mainFadeGain < 0.999}· fade {stats.mainFadeGain.toFixed(2)}{/if}
|
||||||
|
</div>
|
||||||
{#if dlTotal > 0}
|
{#if dlTotal > 0}
|
||||||
<div>
|
<div>
|
||||||
<b>DL</b>
|
<b>DL</b>
|
||||||
|
|||||||
+4
-4
@@ -138,10 +138,10 @@ export const streamWaveform = (username, path, size, buckets) =>
|
|||||||
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
||||||
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
||||||
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
|
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
|
||||||
export const playerFadePauseAcked = (fadeMs, volume) =>
|
export const playerFadePauseAcked = (fadeMs) =>
|
||||||
invoke("player_fade_pause_acked", { fadeMs, volume });
|
invoke("player_fade_pause_acked", { fadeMs });
|
||||||
export const playerFadeResume = (fadeMs, volume) =>
|
export const playerFadeResume = (fadeMs) =>
|
||||||
invoke("player_fade_resume", { fadeMs, volume });
|
invoke("player_fade_resume", { fadeMs });
|
||||||
export const debugStats = () => invoke("debug_stats");
|
export const debugStats = () => invoke("debug_stats");
|
||||||
export const previewControl = (action, value = null) =>
|
export const previewControl = (action, value = null) =>
|
||||||
invoke("preview_control", { action, value });
|
invoke("preview_control", { action, value });
|
||||||
|
|||||||
@@ -105,14 +105,15 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
|||||||
// Deferred open crossfade: fade the main track out NOW, synchronised with
|
// Deferred open crossfade: fade the main track out NOW, synchronised with
|
||||||
// the preview's fade-in. Idempotent, and only fades if the main is
|
// the preview's fade-in. Idempotent, and only fades if the main is
|
||||||
// actually sounding (guards a commitment that leaked past a handoff or an
|
// actually sounding (guards a commitment that leaked past a handoff or an
|
||||||
// idle-main open). Fresh volume read at fire time.
|
// idle-main open). The fade rides a separate fade-gain channel — the user
|
||||||
|
// volume is never touched.
|
||||||
coordinator.beginMainSilence = () => {
|
coordinator.beginMainSilence = () => {
|
||||||
if (!mainSilenceArmed) return;
|
if (!mainSilenceArmed) return;
|
||||||
mainSilenceArmed = false;
|
mainSilenceArmed = false;
|
||||||
const p = readPlayer();
|
const p = readPlayer();
|
||||||
const audible = p.status === "playing" || p.status === "buffering";
|
const audible = p.status === "playing" || p.status === "buffering";
|
||||||
if (!audible) return;
|
if (!audible) return;
|
||||||
void api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7));
|
void api.playerFadePauseAcked(CROSSFADE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { coordinator, gateway };
|
return { coordinator, gateway };
|
||||||
|
|||||||
@@ -3570,7 +3570,7 @@ export function initEvents() {
|
|||||||
resumeMain: () => {
|
resumeMain: () => {
|
||||||
// Close crossfade: come back from the preview at volume 0 and
|
// Close crossfade: come back from the preview at volume 0 and
|
||||||
// ramp to the user's level while the preview rings down.
|
// ramp to the user's level while the preview rings down.
|
||||||
api.playerFadeResume(PREVIEW_CROSSFADE_MS, player.muted ? 0 : player.volume);
|
api.playerFadeResume(PREVIEW_CROSSFADE_MS);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user