mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(audio): hi-res transition blend rate for crossfade, AutoDJ, and gapless (#1171)
This commit is contained in:
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use super::decode::build_source;
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
@@ -51,6 +52,7 @@ pub async fn audio_play(
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
@@ -285,6 +287,13 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(
|
||||
hi_res_enabled,
|
||||
crossfade_enabled || (gapless && !manual),
|
||||
hi_res_crossfade_resample_hz,
|
||||
);
|
||||
let resample_target_hz = blend_rate.unwrap_or(0);
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -301,6 +310,7 @@ pub async fn audio_play(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
@@ -339,6 +349,26 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let outgoing_blend: Option<OutgoingBlendSnapshot> =
|
||||
if let Some(blend) = blend_rate {
|
||||
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
|
||||
hi_res_blend::capture_outgoing_blend_snapshot(
|
||||
&state,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if outgoing_blend.is_some() {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
@@ -347,11 +377,12 @@ pub async fn audio_play(
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
let target_rate = if let Some(blend) = blend_rate {
|
||||
blend
|
||||
} else if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
@@ -383,6 +414,23 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
|
||||
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
|
||||
&app,
|
||||
&state,
|
||||
snap,
|
||||
blend,
|
||||
gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
@@ -399,7 +447,7 @@ pub async fn audio_play(
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
@@ -562,6 +610,7 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
hi_res_crossfade_resample_hz: Option<u32>,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
@@ -675,8 +724,9 @@ pub async fn audio_chain_preload(
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
// Always 0 unless hi-res gapless blend resampling is active.
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
|
||||
let target_rate: u32 = blend_rate.unwrap_or(0);
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
@@ -702,23 +752,70 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
// Hi-res gapless: resample the chained track to the blend rate and realign
|
||||
// the output stream when its Hz differs from the current track.
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
if let Some(br) = blend_rate {
|
||||
if stream_rate > 0 && stream_rate != br {
|
||||
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
|
||||
if hi_res_enabled && br > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
|
||||
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
|
||||
&app,
|
||||
&state,
|
||||
&snap,
|
||||
br,
|
||||
snapshot_gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend skipped: current track not cached for realign"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
resample_target_hz: 0,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
|
||||
//! AutoDJ, or gapless must cross a sample-rate boundary.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::playback_rate::raw_counter_samples_for_content_position;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
const BLEND_44100: u32 = 44_100;
|
||||
const BLEND_88200: u32 = 88_200;
|
||||
const BLEND_96000: u32 = 96_000;
|
||||
|
||||
/// User-selected blend rate for hi-res transitions; `None` when inactive.
|
||||
pub(crate) fn blend_rate_hz(
|
||||
hi_res_enabled: bool,
|
||||
transition_blend_active: bool,
|
||||
hz: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if !hi_res_enabled || !transition_blend_active {
|
||||
return None;
|
||||
}
|
||||
let raw = hz.unwrap_or(BLEND_44100);
|
||||
match raw {
|
||||
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
|
||||
_ => Some(BLEND_44100),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutgoingBlendSnapshot {
|
||||
pub(crate) url: String,
|
||||
pub(crate) position_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Capture the currently playing track before a hi-res blend stream reopen.
|
||||
pub(crate) fn capture_outgoing_blend_snapshot(
|
||||
state: &AudioEngine,
|
||||
outgoing_fade_secs: f32,
|
||||
actual_fade_secs: f32,
|
||||
) -> Option<OutgoingBlendSnapshot> {
|
||||
let url = state.current_playback_url.lock().unwrap().clone()?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let playing = cur.sink.is_some() && cur.paused_at.is_none();
|
||||
(
|
||||
cur.position(),
|
||||
cur.duration_secs,
|
||||
cur.base_volume,
|
||||
cur.replay_gain_linear,
|
||||
playing,
|
||||
)
|
||||
};
|
||||
if !playing {
|
||||
return None;
|
||||
}
|
||||
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
|
||||
Some(OutgoingBlendSnapshot {
|
||||
url,
|
||||
position_secs,
|
||||
duration_secs,
|
||||
base_volume,
|
||||
gain_linear,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
analysis_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the live main sink so a stream reopen does not leave dangling players.
|
||||
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.fadeout_trigger = None;
|
||||
cur.fadeout_samples = None;
|
||||
}
|
||||
|
||||
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
|
||||
if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[hi-res-blend]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
});
|
||||
}
|
||||
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|t| t.url == url)
|
||||
.map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|s| s.url == url)
|
||||
.map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => std::fs::read(&p).ok()?,
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
|
||||
pub(crate) async fn spawn_outgoing_blend_resample(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
let fade_secs = snap.outgoing_fade_secs;
|
||||
if fade_secs > 0.0 {
|
||||
let rate = blend_rate;
|
||||
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
ps.built
|
||||
.fadeout_samples
|
||||
.store(fade_total.max(1), Ordering::SeqCst);
|
||||
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
sink.play();
|
||||
*state.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
|
||||
snap.position_secs,
|
||||
fade_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
|
||||
/// chain realign) so the next source can append to the same sink.
|
||||
pub(crate) async fn rebuild_current_track_at_blend_rate(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] current track not cached for gapless realign: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
state
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
sink.play();
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = ps.built.duration_secs;
|
||||
cur.seek_offset = snap.position_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = snap.gain_linear;
|
||||
cur.base_volume = snap.base_volume;
|
||||
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
|
||||
cur.fadeout_samples = Some(ps.built.fadeout_samples);
|
||||
}
|
||||
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
snap.position_secs,
|
||||
ps.built.output_rate,
|
||||
ps.built.output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
|
||||
snap.position_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_rate_inactive_without_hi_res_or_transition() {
|
||||
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
|
||||
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_rate_sanitizes_hz() {
|
||||
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod hi_res_blend;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
|
||||
@@ -33,9 +33,20 @@ pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
|
||||
pub resample_target_hz: u32,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
|
||||
struct PlaybackSourceShape {
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
resample_target_hz: u32,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
@@ -183,6 +194,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
@@ -196,15 +208,15 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
let shape = PlaybackSourceShape {
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
)
|
||||
};
|
||||
|
||||
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
@@ -261,10 +273,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
&shape,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -296,10 +305,13 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
&PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -317,29 +329,32 @@ async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
shape: &PlaybackSourceShape,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = shape;
|
||||
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
|
||||
let target_rate: u32 = *resample_target_hz;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
*hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
@@ -361,13 +376,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
@@ -387,13 +402,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
|
||||
Reference in New Issue
Block a user