fix(audio): release idle output stream after 60s (#1071) (#1073)

* fix(audio): release idle output stream after 60s (#1071)

Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.

* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)

* fix(audio): satisfy clippy if-same-then-else in idle watcher

* fix(audio): silence rodio DeviceSink drop unless logging is debug

Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.

* feat(audio): cold-start paused restore and silent engine prepare

After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.

* fix(audio): satisfy clippy too_many_arguments on stream arm helper

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

* fix(audio): release output stream immediately on stop (#1071)

Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.

* fix(audio): keep waveform mounted after stop (#1071)

Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.

* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)

- Add unit tests for the idle-keepalive decision: empty/playing/paused main
  sink, preview and fading-out sinks, and radio playing/paused. Players are
  built device-less via rodio's Player::new + a Zero source, so empty()/state
  are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
  new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
  resolves the engine from the AppHandle each poll.

* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)

Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
This commit is contained in:
cucadmuh
2026-06-12 17:13:51 +03:00
committed by GitHub
parent 80822fd742
commit 184e87a469
29 changed files with 1120 additions and 336 deletions
+10
View File
@@ -191,6 +191,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed ## Fixed
### Windows — idle app no longer blocks system sleep
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@Thraka](https://github.com/Thraka), PR [#1073](https://github.com/Psychotoxical/psysonic/pull/1073)**
* Psysonic no longer keeps the audio output device open while the app is idle — the CPAL stream opens on first playback and closes after **60 seconds** without active audio (pause), or **immediately** on Stop / natural queue end, so Windows `powercfg` no longer reports an in-use audio stream when music is not playing ([#1071](https://github.com/Psychotoxical/psysonic/issues/1071)).
* Resume after a long pause uses the existing cold path (`audio:output-released` resets the warm-pause flag); post-sleep recovery skips reopening the stream when nothing is playing.
* **Cold start while paused:** after `getPlayQueue` restores position, the seekbar shows the saved time immediately; the current track is hot-cache prefetched and the engine loads silently (`audio_play` with `startPaused`) at that position so the next Play is a warm `audio_resume` without an audible blip at the start of the track.
* Rodio `Dropping DeviceSink` warnings on stream release are suppressed unless logging is in debug mode.
* **Stop keeps the waveform:** stopping no longer wipes the seekbar waveform for the still-shown track — the cached analysis bins are kept and re-hydrated from the database, so the real waveform stays mounted instead of falling back to flat bars.
### Internet radio — no more duplicate now-playing on Linux ### Internet radio — no more duplicate now-playing on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)** **By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)**
+40 -28
View File
@@ -15,9 +15,12 @@ use super::engine::{audio_http_client, AudioEngine};
use super::helpers::*; use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{ use super::play_input::{
build_playback_source_with_probe_fallback, select_play_input, build_playback_source_with_probe_fallback, select_play_input, url_format_hint, BuildSourceArgs,
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs, PlayInputContext,
PlayInputContext, SinkSwapInputs, };
use super::sink_swap::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
SinkSwapInputs,
}; };
use super::playback_rate::preserve_pitch_will_run; use super::playback_rate::preserve_pitch_will_run;
use super::preview::preview_clear_for_new_main_playback; use super::preview::preview_clear_for_new_main_playback;
@@ -53,9 +56,13 @@ pub async fn audio_play(
analysis_track_id: Option<String>, analysis_track_id: Option<String>,
server_id: Option<String>, server_id: Option<String>,
stream_format_suffix: Option<String>, stream_format_suffix: Option<String>,
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
// `false` so older/external `audio_play` callers that omit it still work.
start_paused: Option<bool>,
app: AppHandle, app: AppHandle,
state: State<'_, AudioEngine>, state: State<'_, AudioEngine>,
) -> Result<(), String> { ) -> Result<(), String> {
let start_paused = start_paused.unwrap_or(false);
let gapless = state.gapless_enabled.load(Ordering::Relaxed); let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ─────────────────────────────────────────────────── // ── Ghost-command guard ───────────────────────────────────────────────────
@@ -311,23 +318,25 @@ pub async fn audio_play(
}; };
let needs_switch = target_rate > 0 && target_rate != current_stream_rate; let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch { if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
let dev = state.selected_device.lock().unwrap().clone(); let dev = state.selected_device.lock().unwrap().clone();
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() { match super::engine::open_output_stream_blocking(
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) { &state,
Ok(new_handle) => { target_rate,
*state.stream_handle.lock().unwrap() = new_handle; hi_res_enabled,
state.stream_sample_rate.store(target_rate, Ordering::Relaxed); dev,
// Give PipeWire time to reconfigure at the new rate before ) {
// we open a Sink — only needed for large hi-res quanta. Ok(_) => {
if hi_res_enabled && target_rate > 48_000 { // Give PipeWire time to reconfigure at the new rate before
tokio::time::sleep(Duration::from_millis(150)).await; // we open a Sink — only needed for large hi-res quanta.
} if hi_res_enabled && target_rate > 48_000 {
} tokio::time::sleep(Duration::from_millis(150)).await;
Err(_) => {
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
} }
} }
Err(_) => {
crate::app_eprintln!(
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
);
}
} }
} }
@@ -337,7 +346,8 @@ pub async fn audio_play(
} }
} }
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume(effective_volume); sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ────────────────────────────────────── // ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
@@ -401,7 +411,7 @@ pub async fn audio_play(
if state.generation.load(Ordering::SeqCst) != gen { if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently return Ok(()); // skipped during pre-fill — abort silently
} }
if !defer_playback_start { if !defer_playback_start && !start_paused {
sink.play(); sink.play();
} }
} }
@@ -415,24 +425,26 @@ pub async fn audio_play(
fadeout_samples: built.fadeout_samples, fadeout_samples: built.fadeout_samples,
crossfade_enabled, crossfade_enabled,
actual_fade_secs, actual_fade_secs,
start_paused,
}); });
if defer_playback_start { if defer_playback_start {
{ if !start_paused {
let mut cur = state.current.lock().unwrap(); let mut cur = state.current.lock().unwrap();
cur.play_started = None; cur.play_started = None;
cur.paused_at = Some(0.0); cur.paused_at = Some(0.0);
} }
spawn_legacy_stream_start_when_armed( spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
gen, gen,
state.generation.clone(), gen_arc: state.generation.clone(),
state.stream_playback_armed.clone(), playback_armed: state.stream_playback_armed.clone(),
state.samples_played.clone(), samples_played: state.samples_played.clone(),
state.current.clone(), current: state.current.clone(),
app.clone(), app: app.clone(),
duration_secs, duration_secs,
); hold_paused: start_paused,
} else { });
} else if !start_paused {
app.emit("audio:playing", duration_secs).ok(); app.emit("audio:playing", duration_secs).ok();
} }
@@ -2,9 +2,7 @@
//! `commands.rs` so playback / radio / EQ aren't entangled with the device //! `commands.rs` so playback / radio / EQ aren't entangled with the device
//! enumeration + reopen path. //! enumeration + reopen path.
use std::sync::Arc;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::Duration;
use tauri::{Emitter, State}; use tauri::{Emitter, State};
@@ -78,16 +76,13 @@ pub async fn audio_set_device(
*state.selected_device.lock().unwrap() = device_name.clone(); *state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed); let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0); let open_rate = if rate > 0 {
state.stream_reopen_tx rate
.send((rate, false, device_name, reply_tx)) } else {
.map_err(|e| e.to_string())?; state.device_default_rate
};
let new_handle = tauri::async_runtime::spawn_blocking(move || { super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
reply_rx.recv_timeout(Duration::from_secs(5)).ok() .map_err(|_| "device open timed out".to_string())?;
}).await.unwrap_or(None).ok_or("device open timed out")?;
*state.stream_handle.lock().unwrap() = new_handle;
// Capture position and drop the active sink atomically so the position // Capture position and drop the active sink atomically so the position
// reading is still valid (play_started / paused_at intact before take). // reading is still valid (play_started / paused_at intact before take).
@@ -24,9 +24,10 @@ use tauri::Manager;
use super::engine::AudioEngine; use super::engine::AudioEngine;
use super::play_input::{ use super::play_input::{
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint, build_playback_source_with_probe_fallback, url_format_hint, BuildSourceArgs, PlayInput,
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs, PlaybackSource,
}; };
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
use super::progress_task::spawn_progress_task; use super::progress_task::spawn_progress_task;
use super::stream::LocalFileSource; use super::stream::LocalFileSource;
@@ -187,9 +188,14 @@ pub(crate) async fn try_resume_after_device_change(
.current_channels .current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed); .store(ps.built.output_channels as u32, Ordering::Relaxed);
let sink = Arc::new(Player::connect_new( let stream = match super::engine::ensure_output_stream_open(&engine) {
engine.stream_handle.lock().unwrap().mixer(), Ok(s) => s,
)); Err(e) => {
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
return false;
}
};
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0); let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume); sink.set_volume(effective_volume);
sink.append(ps.built.source); sink.append(ps.built.source);
@@ -205,6 +211,7 @@ pub(crate) async fn try_resume_after_device_change(
fadeout_samples: ps.built.fadeout_samples, fadeout_samples: ps.built.fadeout_samples,
crossfade_enabled: false, crossfade_enabled: false,
actual_fade_secs: 0.0, actual_fade_secs: 0.0,
start_paused: false,
}, },
); );
@@ -1,6 +1,5 @@
//! Poll default output device and pinned-device presence; reopen stream when needed. //! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::Emitter; use tauri::Emitter;
@@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream(
}; };
let rate = engine.stream_sample_rate.load(Ordering::Relaxed); let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
let reopen_tx = engine.stream_reopen_tx.clone(); let open_rate = if rate > 0 {
let stream_handle = engine.stream_handle.clone(); rate
} else {
engine.device_default_rate
};
let current = engine.current.clone(); let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone(); let fading_out = engine.fading_out_sink.clone();
@@ -62,25 +64,24 @@ pub(crate) async fn reopen_output_stream(
} }
}; };
let new_handle = tauri::async_runtime::spawn_blocking(move || { let app_for_open = app.clone();
let (reply_tx, reply_rx) = let device_name_for_open = device_name.clone();
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0); let opened = tauri::async_runtime::spawn_blocking(move || {
if reopen_tx let engine = app_for_open.state::<AudioEngine>();
.send((rate, false, device_name, reply_tx)) super::engine::open_output_stream_blocking(
.is_err() &engine,
{ open_rate,
return None; false,
} device_name_for_open,
reply_rx.recv_timeout(Duration::from_secs(5)).ok() )
.is_ok()
}) })
.await .await
.unwrap_or(None); .unwrap_or(false);
let Some(handle) = new_handle else { if !opened {
return false; return false;
}; }
*stream_handle.lock().unwrap() = handle;
if let Some(s) = current.lock().unwrap().sink.take() { if let Some(s) = current.lock().unwrap().sink.take() {
s.stop(); s.stop();
} }
+158 -57
View File
@@ -7,21 +7,32 @@ use rodio::Player;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill}; use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
/// Reply channel handed back to the audio-stream thread once a re-open finishes. /// Reply channel handed back to the audio-stream thread once an open finishes.
pub type StreamReopenReply = std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>; pub type StreamOpenReply =
/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`. std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
pub type StreamReopenRequest = (u32, bool, Option<String>, StreamReopenReply);
/// Requests handled on the dedicated audio-stream thread (open / idle release).
pub enum StreamThreadMsg {
Open {
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
reply: StreamOpenReply,
},
Release {
reply: std::sync::mpsc::SyncSender<()>,
},
}
pub struct AudioEngine { pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>, pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
/// Sample rate the output stream was last opened at (updated on every re-open). /// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>, pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the /// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active. /// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32, pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream /// Open or release the CPAL output stream on the audio-stream thread.
/// thread to re-open the output device. `device_name = None` → system default. pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
pub stream_reopen_tx: std::sync::mpsc::SyncSender<StreamReopenRequest>,
/// User-selected output device name (None = follow system default). /// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>, pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>, pub current: Arc<Mutex<AudioCurrent>>,
@@ -147,6 +158,15 @@ impl AudioCurrent {
/// 3. Device default. /// 3. Device default.
/// 4. System default (last resort). /// 4. System default (last resort).
/// ///
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
/// when runtime logging is in **debug** mode; normal/off silence the noise.
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
if !crate::logging::should_log_debug() {
handle.log_on_drop(false);
}
Arc::new(handle)
}
/// Returns `(stream_handle, actual_sample_rate)`. /// Returns `(stream_handle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) { fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait}; use rodio::cpal::traits::{DeviceTrait, HostTrait};
@@ -214,7 +234,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream()) .and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{ {
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate); crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
return (Arc::new(handle), desired_rate); return (finalize_mixer_device_sink(handle), desired_rate);
} }
} }
@@ -231,7 +251,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
"[psysonic] audio stream opened at {} Hz (highest, wanted {})", "[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate rate, desired_rate
); );
return (Arc::new(handle), rate); return (finalize_mixer_device_sink(handle), rate);
} }
} }
} }
@@ -244,7 +264,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.map(|c| c.sample_rate()) .map(|c| c.sample_rate())
.unwrap_or(44100); .unwrap_or(44100);
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate); crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (Arc::new(handle), rate); return (finalize_mixer_device_sink(handle), rate);
} }
} }
@@ -257,7 +277,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|d| d.default_output_config().ok()) .and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate()) .map(|c| c.sample_rate())
.unwrap_or(44100); .unwrap_or(44100);
(Arc::new(handle), rate) (finalize_mixer_device_sink(handle), rate)
}
fn probe_device_default_rate() -> u32 {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44_100)
}
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
pub(crate) fn open_output_stream_blocking(
engine: &AudioEngine,
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
let rate = if desired_rate > 0 {
desired_rate
} else {
engine.device_default_rate
};
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Open {
desired_rate: rate,
is_hi_res,
device_name,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
let (handle, actual_rate) = reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream open timed out".to_string())?;
engine
.stream_sample_rate
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
Ok(handle)
}
/// Ensure a live output stream exists; lazy-opens on first playback.
pub(crate) fn ensure_output_stream_open(
engine: &AudioEngine,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
return Ok(handle);
}
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let device = engine.selected_device.lock().unwrap().clone();
open_output_stream_blocking(engine, open_rate, false, device)
}
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Release { reply: reply_tx })
.map_err(|e| e.to_string())?;
reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream release timed out".to_string())?;
Ok(())
} }
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
@@ -269,13 +360,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
} }
} }
// Channels: main thread ←→ audio-stream thread. // Channel: main thread ←→ audio-stream thread (lazy open + idle release).
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup. let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open. let (stream_thread_tx, stream_thread_rx) =
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
let (reopen_tx, reopen_rx) = let device_default_rate = probe_device_default_rate();
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
let thread = std::thread::Builder::new() let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into()) .name("psysonic-audio-stream".into())
@@ -296,52 +386,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Thread priority is kept at default during standard-mode playback. // Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested, // It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames). // to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0); let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
let handle = _stream.clone(); ready_tx.send(()).ok();
init_tx.send((handle, rate)).ok();
// Keep the stream alive and handle sample-rate / device-switch requests. while let Ok(msg) = stream_thread_rx.recv() {
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() { match msg {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need StreamThreadMsg::Release { reply } => {
// real-time scheduling to avoid underruns). No escalation for _stream = None;
// standard mode — the thread blocks on recv() between reopens so let _ = reply.send(());
// elevated priority would only waste scheduler budget. }
if is_hi_res { StreamThreadMsg::Open {
thread_priority::set_current_thread_priority( desired_rate,
thread_priority::ThreadPriority::Max is_hi_res,
).ok(); device_name,
reply,
} => {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max,
)
.ok();
}
_stream = None;
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
#[cfg(target_os = "linux")]
if desired_rate > 0 {
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
let latency_ms =
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, actual_rate) =
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = Some(new_stream);
let _ = reply.send((new_handle, actual_rate));
}
} }
drop(_stream); // close old stream before opening new one
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
#[cfg(target_os = "linux")]
{
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
// get the same wall-clock quantum as PipeWire.
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = new_stream;
reply_tx.send(new_handle).ok();
} }
}) })
.expect("spawn audio stream thread"); .expect("spawn audio stream thread");
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle"); ready_rx.recv().expect("audio stream thread ready");
let engine = AudioEngine { let engine = AudioEngine {
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)), stream_handle: Arc::new(std::sync::Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)), stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate: initial_rate, device_default_rate,
stream_reopen_tx: reopen_tx, stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)), selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent { current: Arc::new(Mutex::new(AudioCurrent {
sink: None, sink: None,
@@ -16,6 +16,7 @@ pub mod device_commands;
pub mod mix_commands; pub mod mix_commands;
mod play_input; mod play_input;
pub mod playback_rate; pub mod playback_rate;
mod sink_swap;
mod preserve_worker; mod preserve_worker;
pub mod preload_commands; pub mod preload_commands;
pub(crate) mod progress_task; pub(crate) mod progress_task;
@@ -24,6 +25,7 @@ pub mod transport_commands;
mod device_resume; mod device_resume;
mod device_watcher; mod device_watcher;
mod engine; mod engine;
mod stream_idle;
#[cfg(any(target_os = "windows", target_os = "linux"))] #[cfg(any(target_os = "windows", target_os = "linux"))]
mod power_resume; mod power_resume;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -39,6 +41,7 @@ mod stream;
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine}; pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
pub use device_watcher::start_device_watcher; pub use device_watcher::start_device_watcher;
pub use stream_idle::start_stream_idle_watcher;
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine}; pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
pub use helpers::{ pub use helpers::{
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url, cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
@@ -401,47 +401,6 @@ async fn open_ranged_or_streaming_input(
})) }))
} }
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(
gen: u64,
gen_arc: Arc<AtomicU64>,
playback_armed: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
current: Arc<Mutex<super::engine::AudioCurrent>>,
app: AppHandle,
duration_secs: f64,
) {
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(std::time::Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
});
}
/// Pulled out of the format_hint extraction block in `audio_play` — strip the /// Pulled out of the format_hint extraction block in `audio_play` — strip the
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) /// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
/// don't latch onto random query-param substrings; only accept short /// don't latch onto random query-param substrings; only accept short
@@ -486,84 +445,6 @@ pub(crate) struct PlaybackSource {
pub(crate) is_seekable: bool, pub(crate) is_seekable: bool,
} }
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
use std::time::Instant;
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
}
fn play_media_format_hint(input: &PlayInput) -> Option<String> { fn play_media_format_hint(input: &PlayInput) -> Option<String> {
match input { match input {
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => { PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
@@ -4,11 +4,11 @@
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::AppHandle; use tauri::{AppHandle, Emitter, Manager};
use tauri::Manager;
use super::device_watcher::{reopen_output_stream, ReopenNotify}; use super::device_watcher::{reopen_output_stream, ReopenNotify};
use super::engine::AudioEngine; use super::engine::{request_stream_release, AudioEngine};
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None); static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
const DEBOUNCE: Duration = Duration::from_millis(900); const DEBOUNCE: Duration = Duration::from_millis(900);
@@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool {
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) { pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
tokio::time::sleep(Duration::from_millis(400)).await; tokio::time::sleep(Duration::from_millis(400)).await;
let device_name = match app.try_state::<AudioEngine>() { let Some(state) = app.try_state::<AudioEngine>() else {
Some(e) => e.selected_device.lock().unwrap().clone(), return;
None => return,
}; };
let engine = state.inner();
if !output_stream_is_needed(engine) {
if engine.stream_handle.lock().unwrap().is_some() {
teardown_playback_sinks_for_idle_release(engine);
let _ = request_stream_release(engine);
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
}
return;
}
let device_name = engine.selected_device.lock().unwrap().clone();
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await { if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] audio output reopened after system resume"); crate::app_eprintln!("[psysonic] audio output reopened after system resume");
@@ -399,7 +399,8 @@ pub async fn audio_preview_play(
let source = PriorityBoostSource::new(source); let source = PriorityBoostSource::new(source);
// ── Build secondary sink on the existing OutputStream ──────────────────── // ── Build secondary sink on the existing OutputStream ────────────────────
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(source); sink.append(source);
@@ -150,7 +150,8 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); } if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(boosted); sink.append(boosted);
@@ -0,0 +1,165 @@
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
//! into `state.current` (handing off the old one to a crossfade tail or a hard
//! stop), and the legacy non-seekable path that holds a sink paused until the
//! download task arms playback. Split out of `play_input.rs` so source
//! selection and source building stay focused on their own concerns.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use super::engine::{AudioCurrent, AudioEngine};
/// Args for [`spawn_legacy_stream_start_when_armed`].
pub(super) struct LegacyStreamStartWhenArmed {
pub gen: u64,
pub gen_arc: Arc<AtomicU64>,
pub playback_armed: Arc<AtomicBool>,
pub samples_played: Arc<AtomicU64>,
pub current: Arc<Mutex<AudioCurrent>>,
pub app: AppHandle,
pub duration_secs: f64,
pub hold_paused: bool,
}
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
let LegacyStreamStartWhenArmed {
gen,
gen_arc,
playback_armed,
samples_played,
current,
app,
duration_secs,
hold_paused,
} = args;
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
if hold_paused {
sink.pause();
let mut cur = current.lock().unwrap();
cur.play_started = None;
if cur.paused_at.is_none() {
cur.paused_at = Some(0.0);
}
cur.seek_offset = 0.0;
crate::app_deprintln!(
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
);
} else {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
}
});
}
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
pub(crate) start_paused: bool,
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
start_paused,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink.clone());
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
if start_paused {
sink.pause();
cur.play_started = None;
cur.paused_at = Some(0.0);
} else {
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
}
@@ -0,0 +1,306 @@
//! Release the CPAL/rodio output stream after playback has been idle so the OS
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use super::engine::AudioEngine;
/// Wall-clock idle period before closing the output device handle.
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
const IDLE_POLL_SECS: u64 = 5;
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
if engine.preview_sink.lock().unwrap().is_some() {
return true;
}
if engine.fading_out_sink.lock().unwrap().is_some() {
return true;
}
let cur = engine.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if sink.empty() {
return false;
}
if cur.play_started.is_some() && cur.paused_at.is_none() {
return true;
}
}
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
if !rs.flags.is_paused.load(Ordering::Relaxed) {
return true;
}
}
false
}
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
s.stop();
}
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
s.stop();
}
let mut cur = engine.current.lock().unwrap();
if let Some(s) = cur.sink.take() {
s.stop();
}
cur.play_started = None;
}
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
super::engine::request_stream_release(engine)?;
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
Ok(())
}
/// Release the output device after the idle timer (pause with no other active audio).
pub(crate) fn release_output_stream(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
teardown_playback_sinks_for_idle_release(engine);
close_output_device_handle(engine, app)?;
crate::app_eprintln!(
"[psysonic] audio output stream released after {}s idle",
OUTPUT_STREAM_IDLE_RELEASE_SECS
);
Ok(())
}
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
pub(crate) fn release_output_stream_on_stop(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
// still tied to the open device before closing the handle.
if engine.preview_sink.lock().unwrap().is_some()
|| engine.fading_out_sink.lock().unwrap().is_some()
{
teardown_playback_sinks_for_idle_release(engine);
}
close_output_device_handle(engine, app)?;
crate::app_eprintln!("[psysonic] audio output stream released on stop");
Ok(())
}
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
/// so it takes only the `AppHandle` — no engine reference is needed here.
pub fn start_stream_idle_watcher(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut idle_since: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
let Some(state) = app.try_state::<AudioEngine>() else {
idle_since = None;
continue;
};
let engine = state.inner();
let stream_open = engine.stream_handle.lock().unwrap().is_some();
if !stream_open {
idle_since = None;
continue;
}
if output_stream_is_needed(engine) {
idle_since = None;
continue;
}
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
continue;
}
let _ = release_output_stream(engine, &app);
idle_since = None;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use ringbuf::HeapCons;
use rodio::source::Zero;
use rodio::{ChannelCount, Player, SampleRate};
use super::super::engine::AudioCurrent;
use super::super::playback_rate::PlaybackRateAtomics;
use super::super::stream::{RadioLiveState, RadioSharedFlags};
/// A device-less rodio `Player` with one infinite source appended, so
/// `empty()` reports `false` without ever opening an output device.
/// Returns the queue output too — keep it alive for the test's duration.
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
let (player, out) = Player::new();
player.append(Zero::new(
ChannelCount::new(2).unwrap(),
SampleRate::new(44_100).unwrap(),
));
(Arc::new(player), out)
}
fn radio_session(is_paused: bool) -> RadioLiveState {
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
RadioLiveState {
url: "http://example.test/stream".to_string(),
gen: 0,
// Detached no-op task; never polled. Drop just aborts it.
task: tokio::spawn(async {}),
flags: Arc::new(RadioSharedFlags {
is_paused: AtomicBool::new(is_paused),
is_hard_paused: AtomicBool::new(false),
new_cons_tx: Mutex::new(tx),
}),
}
}
fn minimal_engine() -> AudioEngine {
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
AudioEngine {
stream_handle: Arc::new(Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate: 48_000,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
seek_offset: 0.0,
play_started: None,
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0)),
playback_rate: PlaybackRateAtomics::new(),
preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)),
stream_completed_spill: Arc::new(Mutex::new(None)),
current_is_seekable: Arc::new(AtomicBool::new(true)),
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
current_analysis_track_id: Arc::new(Mutex::new(None)),
current_playback_server_id: Arc::new(Mutex::new(None)),
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
preview_sink: Arc::new(Mutex::new(None)),
preview_gen: Arc::new(AtomicU64::new(0)),
preview_main_resume: Arc::new(AtomicBool::new(false)),
preview_song_id: Arc::new(Mutex::new(None)),
}
}
#[test]
fn idle_when_no_sink_and_no_preview() {
let engine = minimal_engine();
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_empty() {
// A live but drained main sink (track finished) must not pin the device.
let (player, _out) = Player::new(); // no source appended → empty()
let player = Arc::new(player);
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(player);
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_sink_playing() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = None; // actively playing
}
assert!(output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_paused() {
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = Some(12.0);
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_preview_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.preview_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[test]
fn needed_when_fading_out_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.fading_out_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn needed_when_radio_playing() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn idle_when_radio_paused() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
assert!(!output_stream_is_needed(&engine));
}
}
@@ -130,6 +130,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
cur.seek_offset = 0.0; cur.seek_offset = 0.0;
cur.play_started = None; cur.play_started = None;
cur.paused_at = None; cur.paused_at = None;
drop(cur);
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
} }
#[tauri::command] #[tauri::command]
+1
View File
@@ -562,6 +562,7 @@ pub fn run() {
use tauri::Manager; use tauri::Manager;
let engine = app.state::<audio::AudioEngine>(); let engine = app.state::<audio::AudioEngine>();
audio::start_device_watcher(&engine, app.handle().clone()); audio::start_device_watcher(&engine, app.handle().clone());
audio::start_stream_idle_watcher(app.handle().clone());
} }
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.) // ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
+1
View File
@@ -162,6 +162,7 @@ const CONTRIBUTOR_ENTRIES = [
'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)', 'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)',
'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)', 'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)',
'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)', 'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)',
'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)',
], ],
}, },
{ {
@@ -96,6 +96,20 @@ describe('audio:device-changed', () => {
// ─── audio:device-reset ───────────────────────────────────────────────────── // ─── audio:device-reset ─────────────────────────────────────────────────────
describe('audio:output-released', () => {
it('calls resetAudioPause so the next resume uses the cold path', () => {
const resetAudioPause = vi.fn();
usePlayerStore.setState({ resetAudioPause } as never);
mountBridge();
emitTauriEvent('audio:output-released', null);
expect(resetAudioPause).toHaveBeenCalled();
});
});
// ─── audio:device-reset ─────────────────────────────────────────────────────
describe('audio:device-reset', () => { describe('audio:device-reset', () => {
it('always clears the stored output device', () => { it('always clears the stored output device', () => {
useAuthStore.setState({ audioOutputDevice: 'My DAC' } as never); useAuthStore.setState({ audioOutputDevice: 'My DAC' } as never);
@@ -70,4 +70,13 @@ export function useAudioDeviceBridge() {
}).then(u => { unlisten = u; }); }).then(u => { unlisten = u; });
return () => { unlisten?.(); }; return () => { unlisten?.(); };
}, []); }, []);
// Output stream was released after idle — next resume must use the cold path.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:output-released', () => {
usePlayerStore.getState().resetAudioPause();
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
} }
+10
View File
@@ -55,6 +55,16 @@ function scheduleEvictAfterPreviousGrace(): void {
}, ms); }, ms);
} }
/** Prefetch the current (paused) track so cold resume can hit disk instead of HTTP. */
export function scheduleHotCachePrefetchForTrack(track: { id: string; suffix?: string }, serverId: string | null): void {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !serverId) return;
if (hasLocalPersistentPlaybackBytes(track.id, serverId)) return;
const hotIndex = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
if (hotIndex[entryKey(serverId, track.id)]) return;
enqueueJobs([{ trackId: track.id, serverId, suffix: track.suffix || 'mp3' }]);
}
function enqueueJobs(jobs: PrefetchJob[]) { function enqueueJobs(jobs: PrefetchJob[]) {
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`)); const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
let merged = 0; let merged = 0;
+100
View File
@@ -0,0 +1,100 @@
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
import { getPlayGeneration, setIsAudioPaused } from './engineState';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache';
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting';
import { usePlayerStore } from './playerStore';
/**
* Load a track into the Rust engine at `atSeconds`, optionally leaving transport
* playing or paused. Shared by queue-undo restore and cold-start paused prepare.
*/
export function engineLoadTrackAtPosition(opts: {
generation: number;
track: Track;
queue: Track[];
queueIndex: number;
atSeconds: number;
wantPlaying: boolean;
}): void {
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
const authState = useAuthStore.getState();
const vol = usePlayerStore.getState().volume;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, coldPrev, coldNext,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const playbackCacheSid = playbackCacheKeyForTrack(track);
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
const startPaused = !wantPlaying;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
url,
volume: vol,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
serverId: playbackIndexKey || null,
streamFormatSuffix: track.suffix ?? null,
startPaused,
})
.then(() => {
if (getPlayGeneration() !== generation) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const dur = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => {
if (getPlayGeneration() !== generation) return;
if (!wantPlaying) {
if (!startPaused) {
invoke('audio_pause').catch(console.error);
}
setIsAudioPaused(true);
usePlayerStore.setState({ isPlaying: false });
} else {
setIsAudioPaused(false);
}
};
if (canSeek) {
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
} else {
afterSeek();
}
})
.catch((err: unknown) => {
if (getPlayGeneration() !== generation) return;
console.error('[psysonic] engineLoadTrackAtPosition failed:', err);
usePlayerStore.setState({ isPlaying: false });
})
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, playbackCacheSid);
}
+6 -2
View File
@@ -30,6 +30,7 @@ import {
setSeekFallbackVisualTarget, setSeekFallbackVisualTarget,
} from './seekFallbackState'; } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState'; import { clearSeekTarget } from './seekTargetState';
import { preparePausedRestoreOnStartup } from './pausedRestorePrepare';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = ( type SetState = (
@@ -167,13 +168,16 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
// Seed the resolver with the restored tracks so the queue UI / hot // Seed the resolver with the restored tracks so the queue UI / hot
// paths resolve them without a network round-trip. // paths resolve them without a network round-trip.
if (sid) seedQueueResolver(sid, mappedTracks); if (sid) seedQueueResolver(sid, mappedTracks);
const atSeconds = serverTime > 0 ? serverTime : localTime;
const queueItems = toQueueItemRefs(sid, mappedTracks);
set({ set({
queueItems: toQueueItemRefs(sid, mappedTracks), queueItems,
queueIndex, queueIndex,
currentTrack, currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime, currentTime: atSeconds,
}); });
void refreshWaveformForTrack(currentTrack.id); void refreshWaveformForTrack(currentTrack.id);
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
} }
} catch (e) { } catch (e) {
console.error('Failed to initialize queue from server', e); console.error('Failed to initialize queue from server', e);
+124
View File
@@ -0,0 +1,124 @@
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
invokeMock: vi.fn(async () => undefined),
emitMock: vi.fn(),
setSeekMock: vi.fn(),
prefetchMock: vi.fn(),
promoteMock: vi.fn(async () => undefined),
engineLoadMock: vi.fn(),
bumpGenMock: vi.fn(() => 7),
getGenMock: vi.fn(() => 7),
playerState: {
isPlaying: false,
currentRadio: null as { streamUrl: string } | null,
volume: 0.8,
},
authState: {
hotCacheEnabled: true,
hotCacheDownloadDir: '/tmp/hot',
},
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('./playbackProgress', () => ({ emitPlaybackProgress: hoisted.emitMock }));
vi.mock('./seekFallbackState', () => ({ setSeekFallbackVisualTarget: hoisted.setSeekMock }));
vi.mock('../hotCachePrefetch', () => ({ scheduleHotCachePrefetchForTrack: hoisted.prefetchMock }));
vi.mock('./promoteStreamCache', () => ({ promoteCompletedStreamToHotCache: hoisted.promoteMock }));
vi.mock('./engineLoadTrackAtPosition', () => ({ engineLoadTrackAtPosition: hoisted.engineLoadMock }));
vi.mock('./engineState', () => ({
bumpPlayGeneration: hoisted.bumpGenMock,
getPlayGeneration: hoisted.getGenMock,
}));
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
vi.mock('./playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerState,
setState: vi.fn((partial: Record<string, unknown>) => {
Object.assign(hoisted.playerState, partial);
}),
},
}));
vi.mock('../utils/library/queueTrackView', () => ({
getQueueTracksView: vi.fn((refs: { trackId: string }[]) =>
refs.map(r => ({
id: r.trackId,
title: r.trackId,
artist: 'A',
album: 'B',
albumId: 'B',
duration: 200,
})),
),
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackCacheServerKey: () => 'srv-key',
}));
import {
applyRestoredPlaybackVisual,
preparePausedRestoreOnStartup,
} from './pausedRestorePrepare';
function track(id: string, duration = 200): Track {
return { id, title: id, artist: 'A', album: 'B', albumId: 'B', duration };
}
beforeEach(() => {
hoisted.invokeMock.mockClear();
hoisted.emitMock.mockClear();
hoisted.setSeekMock.mockClear();
hoisted.prefetchMock.mockClear();
hoisted.promoteMock.mockClear();
hoisted.engineLoadMock.mockClear();
hoisted.bumpGenMock.mockClear();
hoisted.getGenMock.mockReturnValue(7);
hoisted.playerState.isPlaying = false;
hoisted.playerState.currentRadio = null;
});
describe('applyRestoredPlaybackVisual', () => {
it('updates store progress and emits playback progress', () => {
applyRestoredPlaybackVisual(track('t1'), 50);
expect(hoisted.emitMock).toHaveBeenCalledWith({
currentTime: 50,
progress: 0.25,
buffered: 0,
buffering: false,
});
expect(hoisted.setSeekMock).toHaveBeenCalledWith({
trackId: 't1',
seconds: 50,
setAtMs: expect.any(Number),
});
});
});
describe('preparePausedRestoreOnStartup', () => {
it('prefetches, promotes stream cache, and loads the engine paused', async () => {
preparePausedRestoreOnStartup(
track('t1'),
[{ serverId: 'srv', trackId: 't1' }],
0,
42,
);
expect(hoisted.prefetchMock).toHaveBeenCalled();
expect(hoisted.bumpGenMock).toHaveBeenCalled();
await vi.waitFor(() => {
expect(hoisted.promoteMock).toHaveBeenCalled();
expect(hoisted.engineLoadMock).toHaveBeenCalledWith(expect.objectContaining({
generation: 7,
atSeconds: 42,
wantPlaying: false,
}));
});
});
it('skips when transport is already playing', () => {
hoisted.playerState.isPlaying = true;
preparePausedRestoreOnStartup(track('t1'), [], 0, 10);
expect(hoisted.engineLoadMock).not.toHaveBeenCalled();
expect(hoisted.prefetchMock).not.toHaveBeenCalled();
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { QueueItemRef, Track } from './playerStoreTypes';
import { getQueueTracksView } from '../utils/library/queueTrackView';
import { scheduleHotCachePrefetchForTrack } from '../hotCachePrefetch';
import { getPlaybackCacheServerKey } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
import { bumpPlayGeneration, getPlayGeneration } from './engineState';
import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition';
import { emitPlaybackProgress } from './playbackProgress';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { usePlayerStore } from './playerStore';
import { setSeekFallbackVisualTarget } from './seekFallbackState';
/** Push restored position into the store + progress channel so the seekbar paints immediately. */
export function applyRestoredPlaybackVisual(track: Track, atSeconds: number): void {
const dur = track.duration > 0 ? track.duration : 0;
const seconds = Math.max(0, atSeconds);
const progress = dur > 0 ? Math.min(1, seconds / dur) : 0;
usePlayerStore.setState({ currentTime: seconds, progress, buffered: 0 });
emitPlaybackProgress({
currentTime: seconds,
progress,
buffered: 0,
buffering: false,
});
if (seconds > 0.05) {
setSeekFallbackVisualTarget({
trackId: track.id,
seconds,
setAtMs: Date.now(),
});
}
}
/**
* After `getPlayQueue` restores a paused session: show the saved seek position,
* prefetch bytes for the current track, and load the engine paused at that spot
* so the next Play is a warm `audio_resume`.
*/
export function preparePausedRestoreOnStartup(
track: Track,
queueItems: QueueItemRef[],
queueIndex: number,
atSeconds: number,
): void {
const player = usePlayerStore.getState();
if (player.isPlaying || player.currentRadio) return;
applyRestoredPlaybackVisual(track, atSeconds);
scheduleHotCachePrefetchForTrack(track, getPlaybackCacheServerKey());
const generation = bumpPlayGeneration();
void (async () => {
const auth = useAuthStore.getState();
const promoteSid = getPlaybackCacheServerKey();
if (auth.hotCacheEnabled && promoteSid) {
await promoteCompletedStreamToHotCache(
track,
promoteSid,
auth.hotCacheDownloadDir || null,
);
}
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [track]);
engineLoadTrackAtPosition({
generation,
track,
queue,
queueIndex,
atSeconds,
wantPlaying: false,
});
})();
}
+1
View File
@@ -375,6 +375,7 @@ export function runPlayTrack(
analysisTrackId: scopedTrack.id, analysisTrackId: scopedTrack.id,
serverId: getPlaybackIndexKey() || null, serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null, streamFormatSuffix: scopedTrack.suffix ?? null,
startPaused: false,
}) })
.then(() => { .then(() => {
if (getPlayGeneration() !== gen) return; if (getPlayGeneration() !== gen) return;
+14
View File
@@ -230,6 +230,20 @@ describe('stop', () => {
expect(s.progress).toBe(0); expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0); expect(s.currentTime).toBe(0);
}); });
it('keeps the waveform of the still-shown track and re-hydrates it from the DB', () => {
const track = makeTrack({ id: 'wf-keep' });
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({ isPlaying: true, waveformBins: [10, 20, 30] });
onInvoke('analysis_get_waveform_for_track', () => null);
usePlayerStore.getState().stop();
// currentTrack survives a stop, so its waveform bins must not be wiped.
expect(usePlayerStore.getState().waveformBins).toEqual([10, 20, 30]);
expect(invokeMock).toHaveBeenCalledWith(
'analysis_get_waveform_for_track',
expect.objectContaining({ trackId: 'wf-keep' }),
);
});
}); });
describe('shuffleQueue', () => { describe('shuffleQueue', () => {
+6 -2
View File
@@ -130,7 +130,7 @@ describe('queueUndoRestoreAudioEngine', () => {
expect(seekCall).toBeUndefined(); expect(seekCall).toBeUndefined();
}); });
it('issues audio_pause when wantPlaying=false', async () => { it('loads with startPaused and skips audio_pause when wantPlaying=false', async () => {
queueUndoRestoreAudioEngine({ queueUndoRestoreAudioEngine({
generation: 1, generation: 1,
track: track('t1'), track: track('t1'),
@@ -141,7 +141,11 @@ describe('queueUndoRestoreAudioEngine', () => {
}); });
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_pause'); expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(pauseCall).toBeUndefined();
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true); expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
}); });
+3 -83
View File
@@ -1,19 +1,6 @@
import type { Track } from './playerStoreTypes'; import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core'; import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
import { getPlayGeneration, setIsAudioPaused } from './engineState';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache';
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting';
import { usePlayerStore } from './playerStore';
/** /**
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand * Reload the Rust audio engine to match a queue-undo snapshot. Zustand
* alone can rewrite the queue + currentTrack, but the engine is still * alone can rewrite the queue + currentTrack, but the engine is still
@@ -33,72 +20,5 @@ export function queueUndoRestoreAudioEngine(opts: {
atSeconds: number; atSeconds: number;
wantPlaying: boolean; wantPlaying: boolean;
}): void { }): void {
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts; engineLoadTrackAtPosition(opts);
const authState = useAuthStore.getState();
const vol = usePlayerStore.getState().volume;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, coldPrev, coldNext,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const playbackSid = playbackProfileIdForTrack(track);
const playbackCacheSid = playbackCacheKeyForTrack(track);
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
url,
volume: vol,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
serverId: playbackIndexKey || null,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
if (getPlayGeneration() !== generation) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const dur = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => {
if (getPlayGeneration() !== generation) return;
if (!wantPlaying) {
invoke('audio_pause').catch(console.error);
setIsAudioPaused(true);
usePlayerStore.setState({ isPlaying: false });
} else {
setIsAudioPaused(false);
}
};
if (canSeek) {
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
} else {
afterSeek();
}
})
.catch((err: unknown) => {
if (getPlayGeneration() !== generation) return;
console.error('[psysonic] queue-undo audio_play failed:', err);
usePlayerStore.setState({ isPlaying: false });
})
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, playbackCacheSid);
} }
+2
View File
@@ -180,6 +180,7 @@ export function runResume(set: SetState, get: GetState): void {
analysisTrackId: trackToPlay.id, analysisTrackId: trackToPlay.id,
serverId: coldServerId || null, serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null, streamFormatSuffix: trackToPlay.suffix ?? null,
startPaused: false,
}).then(() => { }).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) { if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error); invoke('audio_seek', { seconds: currentTime }).catch(console.error);
@@ -220,6 +221,7 @@ export function runResume(set: SetState, get: GetState): void {
analysisTrackId: currentTrack.id, analysisTrackId: currentTrack.id,
serverId: coldServerId || null, serverId: coldServerId || null,
streamFormatSuffix: currentTrack.suffix ?? null, streamFormatSuffix: currentTrack.suffix ?? null,
startPaused: false,
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return; if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
+10 -2
View File
@@ -9,6 +9,7 @@ import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState'; import { clearSeekTarget } from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock'; import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = ( type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>), partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -31,7 +32,8 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
stop: () => { stop: () => {
void playListenSessionFinalize('stop'); void playListenSessionFinalize('stop');
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
if (get().currentRadio) { const wasRadio = !!get().currentRadio;
if (wasRadio) {
stopRadio(); stopRadio();
} else { } else {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
@@ -39,13 +41,16 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
setIsAudioPaused(false); setIsAudioPaused(false);
clearSeekFallbackRetry(); clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
// Stop keeps `currentTrack` (the bar still shows the stopped song), so its
// waveform stays valid. Radio has no analysis waveform — drop the bins.
const keptTrackId = wasRadio ? null : get().currentTrack?.id ?? null;
set({ set({
isPlaying: false, isPlaying: false,
progress: 0, progress: 0,
buffered: 0, buffered: 0,
currentTime: 0, currentTime: 0,
currentRadio: null, currentRadio: null,
waveformBins: null, ...(keptTrackId ? {} : { waveformBins: null }),
normalizationNowDb: null, normalizationNowDb: null,
normalizationTargetLufs: null, normalizationTargetLufs: null,
normalizationEngineLive: 'off', normalizationEngineLive: 'off',
@@ -56,6 +61,9 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
scheduledResumeAtMs: null, scheduledResumeAtMs: null,
scheduledResumeStartMs: null, scheduledResumeStartMs: null,
}); });
// Re-hydrate from the analysis DB in case the bins were never loaded or
// only partially filled while the (now stopped) track was playing.
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
}, },
pause: () => { pause: () => {