mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
a6ee0668c8
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
308 lines
11 KiB
Rust
308 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)),
|
|
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
|
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));
|
|
}
|
|
}
|