Environment upgrade & hot-cache playback (#463)

* chore: upgrade dependencies and migrate playback to rodio 0.22

Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags,
and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and
cpal device descriptions; drop the unused cpal patch. Align Vite 8 build
targets and chunking; remove redundant dynamic imports and fix hot-cache debug
logging imports.

* perf(build): lazy-load routes and restore default chunk warnings

Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root
Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite
uses the default 500 kB threshold.

* fix(windows): tray double-click without spurious menu; clean unused import

Disable tray menu on left mouse-up on Windows so a double-click to hide the
main window does not immediately reopen the context menu (tray-icon default
menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for
/proc-only code so Windows builds stay warning-free.

* fix(sidebar): preserve new-releases read state under storage cap

When merging seen album ids, keep the current newest sample first so the
500-id localStorage limit does not truncate freshly marked reads and bring
back the unread badge.

* fix(audio): hot-cache replay, analysis no-op skips, playback source UI

Retain stream_completed_cache across audio_stop so end-of-queue replay can
use RAM promote or disk hot file instead of re-ranging HTTP.

Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file
spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote
checks generation after await before filling the slot.

Frontend: promote on same-track and cold resume; set currentPlaybackSource on
resume, queue undo restore, and gapless track switch so cache/stream icons stay
accurate. Import tauri::Manager for try_state in audio_play.

* fix(ts): narrow activeServerId for hot-cache promote calls

promoteCompletedStreamToHotCache expects a string; bind non-null server ids
in repeat-one, playTrack prev/same-track, and cold resume paths so tauri
production build (tsc) succeeds.

* fix(player): handle same-track hot-cache promote promise chain

Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync
throws and unexpected rejections do not surface as unhandled in DevTools;
reset defer-hot-cache prefetch and isPlaying on failure.

* chore(nix): sync npmDepsHash with package-lock.json

* chore(release): finalize 1.46.0 CHANGELOG with PR #463 links

Document the release with full GitHub PR #463 on every subsection so
entries stay attributable if sections are reordered. Fix ContextMenu
lines where dynamic imports were accidentally merged onto one line.

* docs(contributors): credit cucadmuh for #463
This commit is contained in:
cucadmuh
2026-05-05 22:00:29 +03:00
committed by GitHub
parent 54e774ef24
commit 8d8c1aa8a3
29 changed files with 2381 additions and 2511 deletions
+643 -899
View File
File diff suppressed because it is too large Load Diff
+14 -22
View File
@@ -30,38 +30,39 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
md5 = "0.8"
tokio = { version = "1", features = ["rt", "time", "sync"] }
biquad = "0.4"
ringbuf = "0.3"
biquad = "0.6"
ringbuf = "0.5"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
discord-rich-presence = "1.1"
url = "2"
thread-priority = "1"
lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
thread-priority = "3"
lofty = "0.24"
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
rusqlite = { version = "0.37", features = ["bundled"] }
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.39", features = ["bundled"] }
ebur128 = "0.1"
dasp_sample = "0.11.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
zbus = { version = "5.15", default-features = false, features = ["blocking-api"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics",
"Win32_Graphics_Gdi",
@@ -80,12 +81,3 @@ windows = { version = "0.58", features = [
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
# Local patch for cpal's CoreAudio backend on macOS:
# - Forces IOType::DefaultOutput for all output streams so that macOS never
# triggers the TCC microphone-permission prompt. Upstream cpal uses
# IOType::HalOutput when the user pins a non-default device, which routes
# through AUHAL — that component is flagged as input-capable and triggers
# the mic consent dialog on first launch / after each update.
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
# system default). Surfaced in the Settings UI.
cpal = { path = "patches/cpal-0.15.3" }
+9
View File
@@ -303,6 +303,15 @@ impl AnalysisCache {
Ok(None)
}
/// Both waveform and loudness rows exist — a CPU seed from bytes/file would only
/// decode the file to immediately skip with `SkippedWaveformCacheHit`.
pub fn cpu_seed_redundant_for_track(&self, track_id: &str) -> Result<bool, String> {
Ok(
self.get_latest_waveform_for_track(track_id)?.is_some()
&& self.get_latest_loudness_for_track(track_id)?.is_some(),
)
}
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
+121 -60
View File
@@ -4,10 +4,12 @@ use std::sync::{Arc, Mutex, TryLockError};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use ringbuf::{HeapConsumer, HeapRb};
use rodio::{Sink, Source};
use ringbuf::{HeapCons, HeapRb};
use ringbuf::traits::Split;
use rodio::Player;
use rodio::Source;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use super::decode::{build_source, build_streaming_source, SizedDecoder};
use super::dev_io::*;
@@ -29,6 +31,9 @@ use super::stream::{
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
/// for HTTP streams (`playback_identity` is used as fallback).
///
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
pub async fn audio_play(
url: String,
@@ -42,6 +47,7 @@ pub async fn audio_play(
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
analysis_track_id: Option<String>,
stream_format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -210,47 +216,53 @@ pub async fn audio_play(
local_hint
);
if let Some(ref seed_id) = cache_id_for_tasks {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.clone();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
let skip_cpu_seed = app
.try_state::<crate::analysis_cache::AnalysisCache>()
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
.unwrap_or(false);
if !skip_cpu_seed {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.clone();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
data.len() as f64 / (1024.0 * 1024.0)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
}
}
let reader = LocalFileSource { file, len };
PlayInput::SeekableMedia {
@@ -258,7 +270,11 @@ pub async fn audio_play(
format_hint: local_hint,
tag: "local-file",
}
} else if manual && !stream_cache_hit && !preloaded_hit && !is_local {
} else if !stream_cache_hit && !preloaded_hit && !is_local {
// `manual` must NOT gate this branch: natural track end calls `next(false)`,
// so auto-advance must still use ranged HTTP when available. Gating used to
// force every auto-start through `fetch_data` (full RAM buffer + extra fetch log)
// instead of `RangedHttpSource`.
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
@@ -270,13 +286,22 @@ pub async fn audio_play(
return Err(msg);
}
let stream_hint = content_type_to_hint(
let mut stream_hint = content_type_to_hint(
response
.headers()
.get("content-type")
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
).or_else(|| format_hint.clone());
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(|cd| format_hint_from_content_disposition(cd))
})
.or_else(|| normalize_stream_suffix_for_hint(stream_format_suffix.as_deref()))
.or_else(|| format_hint.clone());
let supports_range = response.headers()
.get(reqwest::header::ACCEPT_RANGES)
@@ -284,6 +309,39 @@ pub async fn audio_play(
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(&state)
.get(&url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
match pr.bytes().await {
Ok(bytes) if !bytes.is_empty() => {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
if stream_hint.is_some() {
crate::app_deprintln!(
"[stream] ranged: format sniff from {} B prefix → hint={:?}",
bytes.len(),
stream_hint
);
}
}
_ => {}
}
}
}
}
}
// Guardrail: when format/container hint is unknown, some demuxers may
// seek near EOF during probe. With a progressively downloaded ranged
// source that can delay first audible samples until most/all bytes are
@@ -360,9 +418,9 @@ pub async fn audio_play(
cache_id_for_tasks.clone(),
));
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
let reader = AudioStreamReader {
cons,
cons: Mutex::new(cons),
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now()
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
@@ -570,7 +628,7 @@ pub async fn audio_play(
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
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 reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
@@ -596,7 +654,7 @@ pub async fn audio_play(
}
}
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
@@ -636,8 +694,8 @@ pub async fn audio_play(
let ch = source.channels();
let sr = source.sample_rate();
// 500 ms in whole frames, then expand to interleaved samples.
let frames = (sr / 2) as usize;
let total_samples = frames.saturating_mul(ch as usize);
let frames = (sr.get() / 2) as usize;
let total_samples = frames.saturating_mul(ch.get() as usize);
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
sink.append(silence);
}
@@ -1219,7 +1277,9 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
*state.current_playback_url.lock().unwrap() = None;
*state.current_analysis_track_id.lock().unwrap() = None;
*state.chained_info.lock().unwrap() = None;
*state.stream_completed_cache.lock().unwrap() = None;
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
// re-download on replay. The slot is only consumed on `take`/overwrite for another URL.
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
drop(state.radio_state.lock().unwrap().take());
let mut cur = state.current.lock().unwrap();
@@ -1604,7 +1664,7 @@ pub async fn audio_play_radio(
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
let (prod, cons) = rb.split();
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
let flags = Arc::new(RadioSharedFlags {
is_paused: AtomicBool::new(false),
is_hard_paused: AtomicBool::new(false),
@@ -1632,7 +1692,7 @@ pub async fn audio_play_radio(
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
let reader = AudioStreamReader {
cons,
cons: Mutex::new(cons),
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
gen_arc: state.generation.clone(),
@@ -1661,7 +1721,7 @@ pub async fn audio_play_radio(
state.samples_played.store(0, Ordering::Relaxed);
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
let dyn_src = DynSource::new(decoder);
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_pre_gain.clone());
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
@@ -1672,7 +1732,7 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(boosted);
@@ -1692,8 +1752,8 @@ pub async fn audio_play_radio(
*state.current_playback_url.lock().unwrap() = Some(url.clone());
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
state.current_channels.store(channels as u32, Ordering::Relaxed);
state.current_sample_rate.store(sample_rate.get(), Ordering::Relaxed);
state.current_channels.store(channels.get() as u32, Ordering::Relaxed);
app.emit("audio:playing", 0.0f64).ok();
@@ -1764,7 +1824,8 @@ pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.default_output_device().and_then(|d| d.name().ok())
host.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
})
}
@@ -1779,7 +1840,7 @@ pub async fn audio_set_device(
*state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
state.stream_reopen_tx
.send((rate, false, device_name, reply_tx))
.map_err(|e| e.to_string())?;
+48 -31
View File
@@ -104,7 +104,7 @@ pub(crate) struct SizedDecoder {
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<i16>,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
@@ -297,9 +297,9 @@ impl SizedDecoder {
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
@@ -338,10 +338,10 @@ impl SizedDecoder {
}
impl Iterator for SizedDecoder {
type Item = i16;
type Item = f32;
#[inline]
fn next(&mut self) -> Option<i16> {
fn next(&mut self) -> Option<f32> {
if self.current_frame_offset >= self.buffer.len() {
// Loop until a decodable packet is found or the stream ends.
// DecodeErrors (e.g. MP3 "invalid main_data offset") are non-fatal:
@@ -392,18 +392,19 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_frame_len(&self) -> Option<usize> {
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> u16 {
self.spec.channels.count() as u16
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> u32 {
self.spec.rate
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
@@ -432,18 +433,18 @@ impl Source for SizedDecoder {
pos.as_secs_f64().into()
};
let to_skip = self.current_frame_offset % self.channels() as usize;
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
std::sync::Arc::new(std::io::Error::other(e.to_string()))
))?;
self.refine_position(seek_res)
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
std::sync::Arc::new(std::io::Error::other(e))
))?;
self.current_frame_offset += to_skip;
@@ -573,35 +574,47 @@ pub(crate) fn build_source(
// then resample to the canonical target rate if needed.
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
let delay_dur = Duration::from_secs_f64(
gapless.delay_samples as f64 / sample_rate as f64
gapless.delay_samples as f64 / sample_rate.get() as f64
);
let base = decoder.convert_samples::<f32>().skip_duration(delay_dur);
let base = decoder.skip_duration(delay_dur);
if let Some(total) = gapless.total_valid_samples {
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate.get() as f64);
let trimmed = base.take_duration(valid_dur);
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate))
if target_rate > 0 && sample_rate.get() != target_rate {
DynSource::new(UniformSourceIterator::new(
trimmed,
channels,
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
))
} else {
DynSource::new(trimmed)
}
} else {
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(base, channels, target_rate))
if target_rate > 0 && sample_rate.get() != target_rate {
DynSource::new(UniformSourceIterator::new(
base,
channels,
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
))
} else {
DynSource::new(base)
}
}
} else {
let converted = decoder.convert_samples::<f32>();
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
let converted = decoder;
if target_rate > 0 && sample_rate.get() != target_rate {
DynSource::new(UniformSourceIterator::new(
converted,
channels,
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
))
} else {
DynSource::new(converted)
}
};
let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate };
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate { target_rate } else { sample_rate.get() };
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
@@ -617,7 +630,7 @@ pub(crate) fn build_source(
source: boosted,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
output_channels: channels.get(),
fadeout_trigger,
fadeout_samples,
})
@@ -650,17 +663,21 @@ pub(crate) fn build_streaming_source(
.unwrap_or(duration_hint)
};
let converted = decoder.convert_samples::<f32>();
let dyn_src: DynSource = if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
let converted = decoder;
let dyn_src: DynSource = if target_rate > 0 && sample_rate.get() != target_rate {
DynSource::new(UniformSourceIterator::new(
converted,
channels,
std::num::NonZeroU32::new(target_rate).unwrap_or(std::num::NonZeroU32::MIN),
))
} else {
DynSource::new(converted)
};
let output_rate = if target_rate > 0 && sample_rate != target_rate {
let output_rate = if target_rate > 0 && sample_rate.get() != target_rate {
target_rate
} else {
sample_rate
sample_rate.get()
};
let fadeout_trigger = Arc::new(AtomicBool::new(false));
@@ -677,7 +694,7 @@ pub(crate) fn build_streaming_source(
source: boosted,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
output_channels: channels.get(),
fadeout_trigger,
fadeout_samples,
})
+4 -1
View File
@@ -33,7 +33,10 @@ pub(crate) fn enumerate_output_device_names() -> Vec<String> {
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
})
}
+11 -5
View File
@@ -1,5 +1,6 @@
//! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tauri::Emitter;
@@ -21,7 +22,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
}).await.unwrap_or(None);
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
@@ -48,10 +49,15 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
let default = host.default_output_device().and_then(|d| d.name().ok());
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = host
.output_devices()
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default();
(default, available)
}).await.unwrap_or((None, vec![]));
@@ -94,7 +100,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
@@ -131,7 +137,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
+39 -35
View File
@@ -5,13 +5,13 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Sink;
use rodio::Player;
use tauri::{AppHandle, Manager};
use super::state::{ChainedInfo, PreloadedTrack};
pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
/// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the
@@ -19,7 +19,7 @@ pub struct AudioEngine {
pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
/// thread to re-open the output device. `device_name = None` → system default.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>,
/// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>,
@@ -40,7 +40,7 @@ pub struct AudioEngine {
pub(crate) current_is_seekable: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Sink>>>>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
pub gapless_enabled: Arc<AtomicBool>,
@@ -82,7 +82,7 @@ pub struct AudioEngine {
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
/// as the main sink (rodio mixes both internally) so we don't open a second
/// device handle — important on ALSA-exclusive hardware.
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Sink>>>>,
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
pub(crate) preview_gen: Arc<AtomicU64>,
@@ -95,7 +95,7 @@ pub struct AudioEngine {
}
pub struct AudioCurrent {
pub sink: Option<Arc<Sink>>,
pub sink: Option<Arc<Player>>,
pub duration_secs: f64,
pub seek_offset: f64,
pub play_started: Option<Instant>,
@@ -133,8 +133,8 @@ impl AudioCurrent {
/// 3. Device default.
/// 4. System default (last resort).
///
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
/// Returns `(stream_handle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
// Suppress ALSA stderr noise while enumerating devices on Unix.
@@ -163,7 +163,13 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
// `find_by_name` returns None and we drop through to `default_output_device`
// unchanged — no regression.
let find_by_name = |name: &str| -> Option<_> {
host.output_devices().ok()?.find(|d| d.name().ok().as_deref() == Some(name))
host.output_devices().ok()?.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(name)
})
};
let device = device_name
@@ -184,64 +190,60 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
// 1. Exact rate match — prefer more channels (stereo > mono).
let exact = configs.iter()
.filter(|c| {
c.min_sample_rate().0 <= desired_rate
&& desired_rate <= c.max_sample_rate().0
c.min_sample_rate() <= desired_rate
&& desired_rate <= c.max_sample_rate()
})
.max_by_key(|c| c.channels());
if let Some(cfg) = exact {
let config = cfg.clone()
.with_sample_rate(rodio::cpal::SampleRate(desired_rate));
if let Ok((stream, handle)) =
rodio::OutputStream::try_from_device_config(&device, config)
if exact.is_some() {
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
.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);
return (stream, handle, desired_rate);
return (Arc::new(handle), desired_rate);
}
}
// 2. No exact match — use the highest supported rate.
let best = configs.iter()
.max_by_key(|c| c.max_sample_rate().0);
.max_by_key(|c| c.max_sample_rate());
if let Some(cfg) = best {
let rate = cfg.max_sample_rate().0;
let config = cfg.clone()
.with_sample_rate(rodio::cpal::SampleRate(rate));
if let Ok((stream, handle)) =
rodio::OutputStream::try_from_device_config(&device, config)
let rate = cfg.max_sample_rate();
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{
crate::app_eprintln!(
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate
);
return (stream, handle, rate);
return (Arc::new(handle), rate);
}
}
}
}
// 3. Device default.
if let Ok((stream, handle)) = rodio::OutputStream::try_from_device(&device) {
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone()).and_then(|b| b.open_stream()) {
let rate = device
.default_output_config()
.map(|c| c.sample_rate().0)
.map(|c| c.sample_rate())
.unwrap_or(44100);
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (stream, handle, rate);
return (Arc::new(handle), rate);
}
}
// 4. Last resort: system default.
crate::app_eprintln!("[psysonic] audio stream falling back to system default");
let (stream, handle) = rodio::OutputStream::try_default()
let handle = rodio::DeviceSinkBuilder::open_default_sink()
.expect("cannot open any audio output device");
let rate = rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate().0)
.map(|c| c.sample_rate())
.unwrap_or(44100);
(stream, handle, rate)
(Arc::new(handle), rate)
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
@@ -254,12 +256,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
// Channels: main thread ←→ audio-stream thread.
// init_tx/rx : (OutputStreamHandle, actual_rate) sent once at startup.
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
@@ -280,7 +282,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, handle, rate) = open_stream_for_device_and_rate(None, 0);
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
let handle = _stream.clone();
init_tx.send((handle, rate)).ok();
// Keep the stream alive and handle sample-rate / device-switch requests.
@@ -310,7 +313,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, new_handle, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
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();
}
+140 -2
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use rodio::Sink;
use rodio::Player;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
@@ -96,8 +96,146 @@ pub(crate) fn content_type_to_hint(ct: &str) -> Option<String> {
else if ct.contains("flac") { Some("flac".into()) }
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
else if ct.contains("opus") { Some("opus".into()) }
// AAC/ALAC in MP4 — Navidrome/nginx often send `audio/mp4`; without a hint we skipped ranged open.
else if ct.contains("audio/mp4") || ct.contains("x-m4a") || ct.contains("/m4a") {
Some("m4a".into())
}
else { None }
}
/// `Content-Disposition: attachment; filename="…"` from some Subsonic proxies.
pub(crate) fn format_hint_from_content_disposition(cd: &str) -> Option<String> {
fn ext_ok(ext: &str) -> Option<String> {
let ext = ext.trim_matches(|c| c == '"' || c == '\'' || c == ' ').split(';').next()?.trim();
if !(1..=5).contains(&ext.len()) {
return None;
}
if !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
let e = ext.to_ascii_lowercase();
if matches!(
e.as_str(),
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
| "webm" | "mka"
) {
Some(e)
} else {
None
}
}
fn ext_from_filename(path: &str) -> Option<String> {
let base = path.rsplit('/').next()?.trim_matches(|c| c == '"' || c == ' ');
if base.is_empty() {
return None;
}
let ext = base.rsplit('.').next()?;
if ext == base {
return None;
}
ext_ok(ext)
}
for part in cd.split(';') {
let part = part.trim();
if let Some(rest) = part.strip_prefix("filename*=") {
// RFC 5987: `charset'lang'value`
let value = rest.split("''").nth(1).unwrap_or(rest).trim().trim_matches('"');
if let Some(ext) = ext_from_filename(value) {
return Some(ext);
}
} else if let Some(rest) = part.strip_prefix("filename=") {
let value = rest.trim().trim_matches('"');
if let Some(ext) = ext_from_filename(value) {
return Some(ext);
}
}
}
None
}
/// Subsonic [`song.suffix`](https://www.subsonic.org/pages/api.jsp#getSong) — stream.view URLs
/// usually have no file extension; this supplies `format_hint` for ranged open.
pub(crate) fn normalize_stream_suffix_for_hint(suffix: Option<&str>) -> Option<String> {
let s = suffix?.trim();
if s.is_empty() {
return None;
}
let e = s.to_ascii_lowercase();
if matches!(
e.as_str(),
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4" | "aac" | "wav" | "wave" | "ape" | "wv"
| "webm" | "mka"
) {
Some(e)
} else {
None
}
}
/// Max prefix length for an optional `Range` probe GET when ranged open needs a format hint.
pub(crate) const STREAM_FORMAT_SNIFF_PROBE_BYTES: usize = 256 * 1024;
fn id3v2_tag_len(data: &[u8]) -> usize {
if data.len() >= 10 && data[0..3] == *b"ID3" {
let size = ((data[6] as usize & 0x7f) << 21)
| ((data[7] as usize & 0x7f) << 14)
| ((data[8] as usize & 0x7f) << 7)
| (data[9] as usize & 0x7f);
10usize.saturating_add(size)
} else {
0
}
}
fn adts_frame_sync(b0: u8, b1: u8) -> bool {
b0 == 0xff && (b1 & 0xf6) == 0xf0
}
fn mp3_frame_sync(b0: u8, b1: u8) -> bool {
b0 == 0xff && (b1 & 0xe0) == 0xe0
}
/// Magic-byte sniff on the start of an HTTP body when headers / Subsonic suffix / path
/// did not yield a Symphonia [`Hint`] extension (needed for `RangedHttpSource`).
pub(crate) fn sniff_stream_format_extension(data: &[u8]) -> Option<String> {
if data.is_empty() {
return None;
}
if data.len() >= 4 && data[0..4] == *b"fLaC" {
return Some("flac".into());
}
if data.len() >= 4 && data[0..4] == *b"OggS" {
return Some("ogg".into());
}
if data.len() >= 12 && data[0..4] == *b"RIFF" && data[8..12] == *b"WAVE" {
return Some("wav".into());
}
// ISO-BMFF — `ftyp` inside a box; scan a small window (large `free`/`skip` before `ftyp` is rare but exists).
let scan = data.len().min(4096).saturating_sub(4);
for i in 0..=scan {
if data[i..i + 4] == *b"ftyp" {
return Some("m4a".into());
}
}
// EBML — WebM / Matroska (.mka)
if data.len() >= 4 && data[0] == 0x1a && data[1] == 0x45 && data[2] == 0xdf && data[3] == 0xa3 {
return Some("mka".into());
}
// AAC ADTS
let id3 = id3v2_tag_len(data);
if id3 < data.len().saturating_sub(2) && adts_frame_sync(data[id3], data[id3 + 1]) {
return Some("aac".into());
}
if data.len() >= 2 && adts_frame_sync(data[0], data[1]) {
return Some("aac".into());
}
// MPEG layer III / II — after ID3
let off = id3;
if off + 2 <= data.len() && mp3_frame_sync(data[off], data[off + 1]) {
return Some("mp3".into());
}
None
}
// ─── Event payloads ───────────────────────────────────────────────────────────
#[derive(Clone, Serialize)]
@@ -529,7 +667,7 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
gain_linear_to_db(gain_linear)
}
pub(crate) fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
if (to - from).abs() < 0.002 {
+4 -6
View File
@@ -3,7 +3,8 @@ use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::{Sink, Source};
use rodio::Player;
use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
@@ -184,7 +185,7 @@ pub async fn audio_preview_play(
// starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview
// then plays from 0, which is acceptable.
// No EQ / no crossfade / no ReplayGain — preview stays simple.
let mut source = decoder.convert_samples::<f32>();
let mut source = decoder;
if start_sec > 0.5 {
let _ = source.try_seek(Duration::from_secs_f64(start_sec));
}
@@ -193,10 +194,7 @@ pub async fn audio_preview_play(
let source = PriorityBoostSource::new(source);
// ── Build secondary sink on the existing OutputStream ────────────────────
let sink = Arc::new(
Sink::try_new(&*state.stream_handle.lock().unwrap())
.map_err(|e| format!("preview: sink new: {e}"))?,
);
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(source);
+38 -38
View File
@@ -14,8 +14,8 @@ const EQ_CHECK_INTERVAL: usize = 1024;
pub(crate) struct EqSource<S: Source<Item = f32>> {
inner: S,
sample_rate: u32,
channels: u16,
sample_rate: rodio::SampleRate,
channels: rodio::ChannelCount,
gains: Arc<[AtomicU32; 10]>,
enabled: Arc<AtomicBool>,
pre_gain: Arc<AtomicU32>,
@@ -30,16 +30,16 @@ impl<S: Source<Item = f32>> EqSource<S> {
let sample_rate = inner.sample_rate();
let channels = inner.channels();
let filters = std::array::from_fn(|band| {
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
std::array::from_fn(|_| {
let coeffs = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(0.0),
(sample_rate as f32).hz(),
(sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
).unwrap_or_else(|_| Coefficients::<f32>::from_params(
FilterType::PeakingEQ(0.0),
(sample_rate as f32).hz(),
(sample_rate.get() as f32).hz(),
1000.0f32.hz(),
EQ_Q,
).unwrap());
@@ -60,10 +60,10 @@ impl<S: Source<Item = f32>> EqSource<S> {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
if (gain_db - self.current_gains[band]).abs() > 0.01 {
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(self.sample_rate as f32).hz(),
(self.sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -88,12 +88,12 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
self.sample_counter = self.sample_counter.wrapping_add(1);
if !self.enabled.load(Ordering::Relaxed) {
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
return Some(sample);
}
let ch = self.channel_idx.min(1);
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
self.channel_idx = (self.channel_idx + 1) % self.channels.get() as usize;
let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed));
let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0);
@@ -106,9 +106,9 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
}
impl<S: Source<Item = f32>> Source for EqSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.channels }
fn sample_rate(&self) -> u32 { self.sample_rate }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -116,10 +116,10 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(self.sample_rate as f32).hz(),
(self.sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -141,8 +141,8 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
pub(crate) struct DynSource {
inner: Box<dyn Source<Item = f32> + Send>,
channels: u16,
sample_rate: u32,
channels: rodio::ChannelCount,
sample_rate: rodio::SampleRate,
}
impl DynSource {
@@ -159,9 +159,9 @@ impl Iterator for DynSource {
}
impl Source for DynSource {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.channels }
fn sample_rate(&self) -> u32 { self.sample_rate }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos)
@@ -189,11 +189,11 @@ pub(crate) struct EqualPowerFadeIn<S: Source<Item = f32>> {
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
pub(crate) fn new(inner: S, fade_dur: Duration) -> Self {
let sample_rate = inner.sample_rate();
let channels = inner.channels() as u64;
let channels = inner.channels().get() as u64;
let fade_samples = if fade_dur.is_zero() {
0
} else {
(fade_dur.as_secs_f64() * sample_rate as f64 * channels as f64) as u64
(fade_dur.as_secs_f64() * sample_rate.get() as f64 * channels as f64) as u64
};
Self { inner, sample_count: 0, fade_samples }
}
@@ -215,9 +215,9 @@ impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
}
impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// For mid-track seeks: skip straight to unity gain so the new position
@@ -294,9 +294,9 @@ impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
}
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// If we seek back during a fade, cancel the fade.
@@ -340,9 +340,9 @@ impl<S: Source<Item = f32>> Iterator for NotifyingSource<S> {
}
impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// If we seek backwards the source is no longer exhausted.
@@ -381,9 +381,9 @@ impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
}
impl<S: Source<Item = f32>> Source for CountingSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// Reset counter only after confirming the inner seek succeeded.
@@ -392,8 +392,8 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
// a permanent desync between displayed time and actual audio.
let result = self.inner.try_seek(pos);
if result.is_ok() {
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
* self.inner.channels() as f64) as u64;
let samples = (pos.as_secs_f64() * self.inner.sample_rate().get() as f64
* self.inner.channels().get() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
}
result
@@ -471,9 +471,9 @@ impl<S: Source<Item = f32>> Iterator for PriorityBoostSource<S> {
}
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos)
+40 -18
View File
@@ -5,7 +5,8 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use ringbuf::{HeapConsumer, HeapProducer};
use ringbuf::{HeapCons, HeapProd};
use ringbuf::traits::{Consumer, Observer, Producer};
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter};
@@ -161,21 +162,21 @@ fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
// ── AudioStreamReader — SPSC consumer → std::io::Read ────────────────────────
//
// Bridges HeapConsumer<u8> (non-blocking) into the synchronous Read interface
// Bridges HeapCons<u8> (non-blocking) into the synchronous Read interface
// that Symphonia requires. Designed to run inside tokio::task::spawn_blocking.
//
// Empty buffer: sleeps RADIO_YIELD_MS ms, retries. Never busy-spins.
// Timeout: after RADIO_READ_TIMEOUT_SECS with no data → TimedOut.
// Generation: if gen_arc != self.gen → Ok(0) (EOF; new track started).
// Reconnect: audio_resume sends a fresh HeapConsumer via new_cons_rx.
// Reconnect: audio_resume sends a fresh HeapCons via new_cons_rx.
// On the next read() we drain the channel (keep latest) and swap.
pub(crate) struct AudioStreamReader {
pub(crate) cons: HeapConsumer<u8>,
pub(crate) cons: Mutex<HeapCons<u8>>,
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
/// No real contention: only the audio thread ever calls read().
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapConsumer<u8>>>,
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
pub(crate) deadline: std::time::Instant,
pub(crate) gen_arc: Arc<AtomicU64>,
pub(crate) gen: u64,
@@ -196,12 +197,12 @@ impl Read for AudioStreamReader {
}
// Drain reconnect channel; keep only the most recently delivered consumer
// so a double-tap of resume doesn't leave stale data in place.
let mut newest: Option<HeapConsumer<u8>> = None;
let mut newest: Option<HeapCons<u8>> = None;
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
newest = Some(c);
}
if let Some(c) = newest {
self.cons = c;
*self.cons.lock().unwrap() = c;
self.deadline =
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
}
@@ -209,10 +210,10 @@ impl Read for AudioStreamReader {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
let available = self.cons.len();
let available = self.cons.lock().unwrap().occupied_len();
if available > 0 {
let n = buf.len().min(available);
let read = self.cons.pop_slice(&mut buf[..n]);
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
self.pos += read as u64;
// Reset deadline: data arrived, so connection is alive.
self.deadline =
@@ -402,8 +403,8 @@ pub(crate) struct RadioSharedFlags {
pub(crate) is_paused: AtomicBool,
/// Set by download task on hard disconnect; cleared on resume-reconnect.
pub(crate) is_hard_paused: AtomicBool,
/// Delivers a fresh HeapConsumer<u8> to AudioStreamReader on reconnect.
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapConsumer<u8>>>,
/// Delivers a fresh HeapCons<u8> to AudioStreamReader on reconnect.
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapCons<u8>>>,
}
/// Live state for the current radio session, stored in AudioEngine.
@@ -446,7 +447,7 @@ pub(crate) async fn radio_download_task(
mut initial_response: Option<reqwest::Response>,
http_client: reqwest::Client,
url: String,
mut prod: HeapProducer<u8>,
mut prod: HeapProd<u8>,
flags: Arc<RadioSharedFlags>,
app: AppHandle,
) {
@@ -513,14 +514,14 @@ pub(crate) async fn radio_download_task(
let since = stall_since.get_or_insert(std::time::Instant::now());
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
let fill_pct = ((1.0
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
- prod.vacant_len() as f32 / RADIO_BUF_CAPACITY as f32)
* 100.0) as u32;
crate::app_eprintln!(
"[radio] hard pause: {fill_pct}% full, \
paused >{RADIO_HARD_PAUSE_SECS}s disconnecting"
);
flags.is_hard_paused.store(true, Ordering::Release);
return; // Drop HeapProducer → TCP connection released.
return; // Drop HeapProd → TCP connection released.
}
} else {
stall_since = None;
@@ -597,7 +598,7 @@ pub(crate) async fn track_download_task(
app: AppHandle,
url: String,
initial_response: reqwest::Response,
mut prod: HeapProducer<u8>,
mut prod: HeapProd<u8>,
done: Arc<AtomicBool>,
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
normalization_engine: Arc<AtomicU32>,
@@ -718,6 +719,10 @@ pub(crate) async fn track_download_task(
}
}
if !capture_over_limit && !capture.is_empty() {
if gen_arc.load(Ordering::SeqCst) != gen {
done.store(true, Ordering::SeqCst);
return;
}
if let Some(track_id) = cache_track_id {
crate::app_deprintln!(
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
@@ -731,6 +736,10 @@ pub(crate) async fn track_download_task(
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
done.store(true, Ordering::SeqCst);
return;
}
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
url: url.clone(),
data: capture,
@@ -785,9 +794,15 @@ pub(crate) async fn ranged_download_task(
let mut reconnects: u32 = 0;
let mut next_response: Option<reqwest::Response> = Some(initial_response);
let dl_started = Instant::now();
let mut next_progress_mb: usize = 1;
let mut next_progress_mb: usize = 0;
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
crate::app_deprintln!(
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
total_size.saturating_div(1024),
total_size as f64 / (1024.0 * 1024.0)
);
'outer: loop {
let response = if let Some(r) = next_response.take() {
r
@@ -889,8 +904,12 @@ pub(crate) async fn ranged_download_task(
}
}
let mb = downloaded / (1024 * 1024);
if mb >= next_progress_mb {
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
while mb >= next_progress_mb {
let pct = if total_size > 0 {
(downloaded as f64 / total_size as f64 * 100.0) as u32
} else {
0u32
};
crate::app_deprintln!(
"[stream] dl progress: {} MB / {} MB ({}%)",
mb,
@@ -940,6 +959,9 @@ pub(crate) async fn ranged_download_task(
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
}
+1 -8
View File
@@ -215,14 +215,7 @@ fn cache_and_return(
/// logged so the renderer's terminal output shows exactly where the
/// connection breaks. Release builds stay completely silent.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = match DiscordIpcClient::new(DISCORD_APP_ID) {
Ok(c) => c,
Err(_e) => {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] new() failed (app_id={}): {}", DISCORD_APP_ID, _e);
return None;
}
};
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
if let Err(_e) = client.connect() {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
+4 -7
View File
@@ -446,8 +446,9 @@ async fn analysis_cpu_seed_worker_loop(
/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors
/// HTTP backfill head insertion for the currently playing track.
///
/// Emits `analysis:waveform-updated` once here when the DB row is ready (Upserted or cache hit),
/// so `audio` and other callers do not duplicate IPC.
/// Emits `analysis:waveform-updated` when analysis **wrote** new waveform data (`Upserted`).
/// Cache-hit skips (`SkippedWaveformCacheHit`) omit the event so the frontend does not
/// re-run loudness refresh / waveform IPC for rows that were already current.
pub(crate) async fn submit_analysis_cpu_seed(
app: tauri::AppHandle,
track_id: String,
@@ -467,11 +468,7 @@ pub(crate) async fn submit_analysis_cpu_seed(
Ok(res) => res?,
Err(_) => return Err("cpu-seed: result channel dropped".to_string()),
};
if matches!(
outcome,
analysis_cache::SeedFromBytesOutcome::Upserted
| analysis_cache::SeedFromBytesOutcome::SkippedWaveformCacheHit
) {
if matches!(outcome, analysis_cache::SeedFromBytesOutcome::Upserted) {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
@@ -1,5 +1,7 @@
use super::*;
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::fs;
#[derive(Debug, Clone, Serialize)]
+8 -6
View File
@@ -252,16 +252,18 @@ pub(crate) fn get_embedded_lyrics(path: String) -> Option<String> {
// ── FLAC / Vorbis / Opus / M4A: generic lofty tag API ────────────────────
// Vorbis SYNCEDLYRICS stores a complete LRC string in a plain comment field.
// It is not a known lofty ItemKey, so access it via ItemKey::Unknown.
// In newer lofty versions, construct dynamic keys via ItemKey::from_key.
let tagged = probe.read().ok()?;
for tag in tagged.tags() {
if let Some(lrc) = tag.get_string(&ItemKey::Unknown("SYNCEDLYRICS".to_owned())) {
let lrc = lrc.trim();
if !lrc.is_empty() {
return Some(lrc.to_owned());
if let Some(sync_key) = ItemKey::from_key(tag.tag_type(), "SYNCEDLYRICS") {
if let Some(lrc) = tag.get_string(sync_key) {
let lrc = lrc.trim();
if !lrc.is_empty() {
return Some(lrc.to_owned());
}
}
}
if let Some(plain) = tag.get_string(&ItemKey::Lyrics) {
if let Some(plain) = tag.get_string(ItemKey::Lyrics) {
let plain = plain.trim();
if !plain.is_empty() {
return Some(plain.to_owned());
+10
View File
@@ -21,6 +21,11 @@ pub(crate) async fn stream_to_file(response: reqwest::Response, dest_path: &std:
}
pub(crate) async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
return Ok(true);
}
}
let high = analysis_backfill_is_current_track(app, track_id);
let outcome = submit_analysis_cpu_seed(
app.clone(),
@@ -51,6 +56,11 @@ pub(crate) async fn enqueue_analysis_seed_from_file(
track_id: &str,
file_path: &std::path::Path,
) {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.cpu_seed_redundant_for_track(track_id).unwrap_or(false) {
return;
}
}
let bytes = match tokio::fs::read(file_path).await {
Ok(v) => v,
Err(_) => return,
+6 -1
View File
@@ -83,7 +83,12 @@ pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon>
let tray_builder = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip(&tooltip_with_icon);
.tooltip(&tooltip_with_icon)
// tray-icon defaults to opening the context menu on every WM_LBUTTONUP when this is true.
// A left double-click emits Down, Up, DoubleClick, Up — the final Up re-opens the menu right
// after we hide the window from DoubleClick. We only use left double-click for show/hide
// (see on_tray_icon_event); keep the menu on right-click like typical Windows tray apps.
.show_menu_on_left_click(false);
#[cfg(not(target_os = "windows"))]
let tray_builder = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())