mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
184e87a469
* 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.
307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
//! 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));
|
|
}
|
|
}
|