mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(audio): bit-perfect hi-res playback + underrun hardening (opt-in alpha)
Safe mode (default): all audio outputs at 44.1 kHz, rodio resamples internally. Maximum stability out of the box. Hi-Res mode (alpha toggle): stream opens at the file's native sample rate (e.g. 88.2/96/192 kHz) for bit-perfect output. Rust (audio.rs): - open_stream_for_rate(): CPAL queries device supported configs, finds exact rate match or falls back to highest available / device default. - create_engine() thread: ThreadPriority::Max (silently ignored without CAP_SYS_NICE), loop handles rate-switch requests, PIPEWIRE_LATENCY scales with rate (8192 frames for >48 kHz ≈ 93 ms quantum), keeps PULSE_LATENCY_MSEC in sync. - audio_play(): hi_res_enabled param gates effective_rate (44100 vs native); stream re-opens only when needed; 150 ms PipeWire settle + 500 ms sink pre-fill (pause→append→sleep→play) for hi-res tracks. - audio_chain_preload(): hi_res_enabled param; rate-mismatch bail skipped in safe mode so gapless chains always work at 44.1 kHz. - SizedDecoder MSS buffer: 4 MB (was 512 KB) to reduce Symphonia read overhead on high-bitrate hi-res files. - thread-priority crate added to Cargo.toml. Frontend: - authStore: enableHiRes (bool, default false) + setEnableHiRes. - playerStore: hiResEnabled passed to all audio_play / audio_chain_preload invoke calls. - Settings → Audio tab: "Native Hi-Res Playback" toggle with Alpha badge and stability warning, i18n for all 7 languages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,3 +44,4 @@ tauri-plugin-process = "2"
|
|||||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||||
discord-rich-presence = "0.2"
|
discord-rich-presence = "0.2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
thread-priority = "1"
|
||||||
|
|||||||
+232
-26
@@ -13,7 +13,7 @@ use symphonia::core::{
|
|||||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||||
io::{MediaSource, MediaSourceStream},
|
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||||
meta::MetadataOptions,
|
meta::MetadataOptions,
|
||||||
probe::Hint,
|
probe::Hint,
|
||||||
units::{self, Time},
|
units::{self, Time},
|
||||||
@@ -908,9 +908,13 @@ impl SizedDecoder {
|
|||||||
inner: Cursor::new(data),
|
inner: Cursor::new(data),
|
||||||
len: data_len,
|
len: data_len,
|
||||||
};
|
};
|
||||||
|
// 4 MB read-ahead buffer for Symphonia. Since the source is an in-memory
|
||||||
|
// Cursor<Vec<u8>>, reads are free — the large buffer reduces the number of
|
||||||
|
// Read::read() calls Symphonia makes while demuxing, lowering decode-loop
|
||||||
|
// overhead for high-bitrate hi-res files (88.2kHz/24-bit FLAC ≈1800 kbps).
|
||||||
let mss = MediaSourceStream::new(
|
let mss = MediaSourceStream::new(
|
||||||
Box::new(source) as Box<dyn MediaSource>,
|
Box::new(source) as Box<dyn MediaSource>,
|
||||||
Default::default(),
|
MediaSourceStreamOptions { buffer_len: 4 * 1024 * 1024 },
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
@@ -996,7 +1000,9 @@ impl SizedDecoder {
|
|||||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||||
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
|
||||||
let mss = MediaSourceStream::new(media, Default::default());
|
// Larger read-ahead buffer for the live radio SPSC consumer — reduces
|
||||||
|
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||||
|
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
||||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||||
@@ -1350,7 +1356,12 @@ pub(crate) struct ChainedInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioEngine {
|
pub struct AudioEngine {
|
||||||
pub stream_handle: Arc<rodio::OutputStreamHandle>,
|
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
|
||||||
|
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||||
|
pub stream_sample_rate: Arc<AtomicU32>,
|
||||||
|
/// Sends `(desired_rate, reply_tx)` to the audio-stream thread to re-open the
|
||||||
|
/// output device at a different native sample rate.
|
||||||
|
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
||||||
pub current: Arc<Mutex<AudioCurrent>>,
|
pub current: Arc<Mutex<AudioCurrent>>,
|
||||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||||
pub generation: Arc<AtomicU64>,
|
pub generation: Arc<AtomicU64>,
|
||||||
@@ -1411,25 +1422,90 @@ impl AudioCurrent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
/// Open the system default output device at `desired_rate` Hz (0 = device default).
|
||||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. Exact rate match in the device's supported config ranges.
|
||||||
|
/// 2. Highest available rate (for hardware that doesn't support the source rate).
|
||||||
|
/// 3. Device default.
|
||||||
|
/// 4. System default (last resort).
|
||||||
|
///
|
||||||
|
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
|
||||||
|
fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
|
||||||
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
|
||||||
// Request a larger audio buffer from PipeWire/PulseAudio to reduce ALSA underruns.
|
let host = rodio::cpal::default_host();
|
||||||
// Only set if the user hasn't already configured these themselves.
|
|
||||||
// PIPEWIRE_LATENCY: 4096 frames / 48000 Hz ≈ 85 ms — enough to absorb scheduler jitter.
|
if let Some(device) = host.default_output_device() {
|
||||||
#[cfg(target_os = "linux")]
|
if desired_rate > 0 {
|
||||||
{
|
if let Ok(supported) = device.supported_output_configs() {
|
||||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
let configs: Vec<_> = supported.collect();
|
||||||
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
.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)
|
||||||
|
{
|
||||||
|
eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||||
|
return (stream, 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);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||||
|
rate, desired_rate
|
||||||
|
);
|
||||||
|
return (stream, handle, rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
|
||||||
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
// 3. Device default.
|
||||||
|
if let Ok((stream, handle)) = rodio::OutputStream::try_from_device(&device) {
|
||||||
|
let rate = device
|
||||||
|
.default_output_config()
|
||||||
|
.map(|c| c.sample_rate().0)
|
||||||
|
.unwrap_or(44100);
|
||||||
|
eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||||
|
return (stream, handle, rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Last resort: system default.
|
||||||
|
eprintln!("[psysonic] audio stream falling back to system default");
|
||||||
|
let (stream, handle) = rodio::OutputStream::try_default()
|
||||||
|
.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)
|
||||||
|
.unwrap_or(44100);
|
||||||
|
(stream, handle, rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||||
// macOS: request a smaller CoreAudio buffer to reduce output latency.
|
// macOS: request a smaller CoreAudio buffer to reduce output latency.
|
||||||
// Smaller buffers = lower latency between decoded samples and DAC output,
|
|
||||||
// which tightens the gap between actual audio and UI event delivery.
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
|
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
|
||||||
@@ -1437,21 +1513,67 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channels: main thread ←→ audio-stream thread.
|
||||||
|
// init_tx/rx : (OutputStreamHandle, 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);
|
||||||
|
let (reopen_tx, reopen_rx) =
|
||||||
|
std::sync::mpsc::sync_channel::<(u32, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("psysonic-audio-stream".into())
|
.name("psysonic-audio-stream".into())
|
||||||
.spawn(move || match rodio::OutputStream::try_default() {
|
.spawn(move || {
|
||||||
Ok((_stream, handle)) => {
|
// Set PipeWire / PulseAudio latency hints before the first open.
|
||||||
tx.send(handle).ok();
|
#[cfg(target_os = "linux")]
|
||||||
loop { std::thread::park(); }
|
{
|
||||||
|
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||||
|
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
||||||
|
}
|
||||||
|
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||||
|
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost scheduler priority so the audio thread is not pre-empted
|
||||||
|
// during high-rate playback. Silently ignored without CAP_SYS_NICE.
|
||||||
|
thread_priority::set_current_thread_priority(
|
||||||
|
thread_priority::ThreadPriority::Max
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
let (mut _stream, handle, rate) = open_stream_for_rate(0);
|
||||||
|
init_tx.send((handle, rate)).ok();
|
||||||
|
|
||||||
|
// Keep the stream alive and handle sample-rate switch requests.
|
||||||
|
while let Ok((desired_rate, reply_tx)) = reopen_rx.recv() {
|
||||||
|
drop(_stream); // close old stream before opening new one
|
||||||
|
|
||||||
|
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||||
|
// latency stays roughly constant (≈93 ms) at all rates.
|
||||||
|
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||||
|
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||||
|
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
|
||||||
|
// get the same wall-clock quantum as PipeWire.
|
||||||
|
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||||
|
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate);
|
||||||
|
_stream = new_stream;
|
||||||
|
reply_tx.send(new_handle).ok();
|
||||||
}
|
}
|
||||||
Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
|
|
||||||
})
|
})
|
||||||
.expect("spawn audio stream thread");
|
.expect("spawn audio stream thread");
|
||||||
|
|
||||||
let stream_handle = rx.recv().expect("audio stream handle");
|
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
|
||||||
|
|
||||||
let engine = AudioEngine {
|
let engine = AudioEngine {
|
||||||
stream_handle: Arc::new(stream_handle),
|
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
|
||||||
|
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||||
|
stream_reopen_tx: reopen_tx,
|
||||||
current: Arc::new(Mutex::new(AudioCurrent {
|
current: Arc::new(Mutex::new(AudioCurrent {
|
||||||
sink: None,
|
sink: None,
|
||||||
duration_secs: 0.0,
|
duration_secs: 0.0,
|
||||||
@@ -1605,6 +1727,7 @@ pub async fn audio_play(
|
|||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||||
|
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -1757,9 +1880,59 @@ pub async fn audio_play(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
// ── Native-rate stream switch ─────────────────────────────────────────────
|
||||||
|
// If the decoded track's sample rate differs from the current output stream,
|
||||||
|
// ask the audio-stream thread to re-open the device at the native rate.
|
||||||
|
// This keeps the signal bit-perfect (no rodio resampler in the path).
|
||||||
|
// Falls back silently if the switch times out (rodio will resample instead).
|
||||||
|
// Safe mode (default): lock to 44.1 kHz — rodio resamples internally.
|
||||||
|
// Hi-Res mode (alpha): request the file's native rate from the audio thread.
|
||||||
|
let effective_rate = if hi_res_enabled { output_rate } else { 44_100 };
|
||||||
|
|
||||||
|
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||||
|
let stream_was_switched = effective_rate != current_stream_rate && effective_rate > 0;
|
||||||
|
if stream_was_switched {
|
||||||
|
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||||
|
if state.stream_reopen_tx.send((effective_rate, reply_tx)).is_ok() {
|
||||||
|
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||||
|
Ok(new_handle) => {
|
||||||
|
*state.stream_handle.lock().unwrap() = new_handle;
|
||||||
|
state.stream_sample_rate.store(effective_rate, Ordering::Relaxed);
|
||||||
|
// Give PipeWire time to reconfigure its processing graph at
|
||||||
|
// the new sample rate before we open a Sink and start feeding
|
||||||
|
// frames. Without this delay the first quantum is often stale
|
||||||
|
// and triggers an snd_pcm_recover underrun.
|
||||||
|
if effective_rate > 48_000 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
||||||
sink.set_volume(effective_volume);
|
sink.set_volume(effective_volume);
|
||||||
|
|
||||||
|
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||||
|
// At sample rates > 48 kHz the hardware quantum is larger and the first
|
||||||
|
// period demands more decoded frames than at 44.1/48 kHz.
|
||||||
|
// Strategy: pause the sink before appending so rodio's internal mixer
|
||||||
|
// decodes into its ring buffer ahead of the hardware. After a short delay
|
||||||
|
// we resume — the buffer is already full and the hardware gets its frames
|
||||||
|
// without an underrun on the very first period.
|
||||||
|
let needs_prefill = effective_rate > 48_000;
|
||||||
|
if needs_prefill {
|
||||||
|
sink.pause();
|
||||||
|
}
|
||||||
|
|
||||||
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
||||||
// Only when this is an auto-advance (near end), not on manual skip.
|
// Only when this is an auto-advance (near end), not on manual skip.
|
||||||
if !gapless {
|
if !gapless {
|
||||||
@@ -1783,6 +1956,19 @@ pub async fn audio_play(
|
|||||||
|
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
|
|
||||||
|
if needs_prefill {
|
||||||
|
// 500 ms lets rodio decode several seconds of hi-res audio into its
|
||||||
|
// internal buffer while the sink is paused. The hardware sees no gap
|
||||||
|
// because the output is held — it only starts draining after sink.play().
|
||||||
|
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
|
||||||
|
// absorbing scheduler jitter and PipeWire graph wake-up latency.
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(()); // skipped during pre-fill — abort silently
|
||||||
|
}
|
||||||
|
sink.play();
|
||||||
|
}
|
||||||
|
|
||||||
// Atomically swap sinks — extract old sink + its fade-out trigger.
|
// Atomically swap sinks — extract old sink + its fade-out trigger.
|
||||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||||
let mut cur = state.current.lock().unwrap();
|
let mut cur = state.current.lock().unwrap();
|
||||||
@@ -1869,6 +2055,7 @@ pub async fn audio_chain_preload(
|
|||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
|
hi_res_enabled: bool,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Idempotent: already chained this track → nothing to do.
|
// Idempotent: already chained this track → nothing to do.
|
||||||
@@ -1959,6 +2146,25 @@ pub async fn audio_chain_preload(
|
|||||||
return Ok(());
|
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 };
|
||||||
|
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||||
|
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||||
|
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.
|
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||||
{
|
{
|
||||||
let cur = state.current.lock().unwrap();
|
let cur = state.current.lock().unwrap();
|
||||||
@@ -2498,7 +2704,7 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||||
|
|
||||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(counting);
|
sink.append(counting);
|
||||||
|
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ export const deTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Sofort',
|
hotCacheDebounceImmediate: 'Sofort',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Hot-Cache leeren',
|
hotCacheClearBtn: 'Hot-Cache leeren',
|
||||||
|
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||||
|
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
||||||
|
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
|
||||||
showArtistImages: 'Künstlerbilder anzeigen',
|
showArtistImages: 'Künstlerbilder anzeigen',
|
||||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||||
showTrayIcon: 'Tray-Icon anzeigen',
|
showTrayIcon: 'Tray-Icon anzeigen',
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ export const enTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Immediate',
|
hotCacheDebounceImmediate: 'Immediate',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Clear hot cache',
|
hotCacheClearBtn: 'Clear hot cache',
|
||||||
|
hiResTitle: 'Native Hi-Res Playback',
|
||||||
|
hiResEnabled: 'Enable native hi-res playback',
|
||||||
|
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
|
||||||
showArtistImages: 'Show Artist Images',
|
showArtistImages: 'Show Artist Images',
|
||||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||||
showTrayIcon: 'Show Tray Icon',
|
showTrayIcon: 'Show Tray Icon',
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ export const frTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Immédiat',
|
hotCacheDebounceImmediate: 'Immédiat',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Vider le cache à chaud',
|
hotCacheClearBtn: 'Vider le cache à chaud',
|
||||||
|
hiResTitle: 'Lecture haute résolution native',
|
||||||
|
hiResEnabled: 'Activer la lecture haute résolution native',
|
||||||
|
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
|
||||||
showArtistImages: 'Afficher les images d\'artistes',
|
showArtistImages: 'Afficher les images d\'artistes',
|
||||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||||
showTrayIcon: 'Afficher l\'icône dans la barre système',
|
showTrayIcon: 'Afficher l\'icône dans la barre système',
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const nbTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Umiddelbart',
|
hotCacheDebounceImmediate: 'Umiddelbart',
|
||||||
hotCacheDebounceSeconds: '{{n}} sek',
|
hotCacheDebounceSeconds: '{{n}} sek',
|
||||||
hotCacheClearBtn: 'Tøm varm buffer',
|
hotCacheClearBtn: 'Tøm varm buffer',
|
||||||
|
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||||
|
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
|
||||||
|
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
|
||||||
showArtistImages: 'Vis artistbilder',
|
showArtistImages: 'Vis artistbilder',
|
||||||
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
|
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
|
||||||
minimizeToTray: 'Minimer til oppgavelinjen',
|
minimizeToTray: 'Minimer til oppgavelinjen',
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ export const nlTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Direct',
|
hotCacheDebounceImmediate: 'Direct',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Warme cache wissen',
|
hotCacheClearBtn: 'Warme cache wissen',
|
||||||
|
hiResTitle: 'Natieve hi-res-weergave',
|
||||||
|
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
||||||
|
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
|
||||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||||
showTrayIcon: 'Tray-pictogram weergeven',
|
showTrayIcon: 'Tray-pictogram weergeven',
|
||||||
|
|||||||
@@ -447,6 +447,9 @@ export const ruTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Сразу',
|
hotCacheDebounceImmediate: 'Сразу',
|
||||||
hotCacheDebounceSeconds: '{{n}} с',
|
hotCacheDebounceSeconds: '{{n}} с',
|
||||||
hotCacheClearBtn: 'Очистить горячий кэш',
|
hotCacheClearBtn: 'Очистить горячий кэш',
|
||||||
|
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||||
|
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||||
|
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||||
showArtistImages: 'Фото исполнителей',
|
showArtistImages: 'Фото исполнителей',
|
||||||
showArtistImagesDesc:
|
showArtistImagesDesc:
|
||||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||||
|
|||||||
@@ -426,6 +426,9 @@ export const zhTranslation = {
|
|||||||
hotCacheDebounceImmediate: '立即',
|
hotCacheDebounceImmediate: '立即',
|
||||||
hotCacheDebounceSeconds: '{{n}} 秒',
|
hotCacheDebounceSeconds: '{{n}} 秒',
|
||||||
hotCacheClearBtn: '清空热缓存',
|
hotCacheClearBtn: '清空热缓存',
|
||||||
|
hiResTitle: '原生高清晰度播放',
|
||||||
|
hiResEnabled: '启用原生高清晰度播放',
|
||||||
|
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||||
showArtistImages: '显示艺术家图片',
|
showArtistImages: '显示艺术家图片',
|
||||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||||
showTrayIcon: '显示托盘图标',
|
showTrayIcon: '显示托盘图标',
|
||||||
|
|||||||
+36
-1
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download
|
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -545,6 +545,41 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Native Hi-Res Playback */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Waves size={18} />
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{t('settings.hiResTitle')}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
|
||||||
|
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}>
|
||||||
|
{t('settings.hotCacheAlphaBadge')}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={auth.enableHiRes}
|
||||||
|
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||||
|
id="hires-enabled-toggle"
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ interface AuthState {
|
|||||||
showChangelogOnUpdate: boolean;
|
showChangelogOnUpdate: boolean;
|
||||||
lastSeenChangelogVersion: string;
|
lastSeenChangelogVersion: string;
|
||||||
|
|
||||||
|
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||||
|
enableHiRes: boolean;
|
||||||
|
|
||||||
/** Alpha: ephemeral queue prefetch cache on disk */
|
/** Alpha: ephemeral queue prefetch cache on disk */
|
||||||
hotCacheEnabled: boolean;
|
hotCacheEnabled: boolean;
|
||||||
hotCacheMaxMb: number;
|
hotCacheMaxMb: number;
|
||||||
@@ -95,6 +98,7 @@ interface AuthState {
|
|||||||
setShowFullscreenLyrics: (v: boolean) => void;
|
setShowFullscreenLyrics: (v: boolean) => void;
|
||||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||||
setLastSeenChangelogVersion: (v: string) => void;
|
setLastSeenChangelogVersion: (v: string) => void;
|
||||||
|
setEnableHiRes: (v: boolean) => void;
|
||||||
setHotCacheEnabled: (v: boolean) => void;
|
setHotCacheEnabled: (v: boolean) => void;
|
||||||
setHotCacheMaxMb: (v: number) => void;
|
setHotCacheMaxMb: (v: number) => void;
|
||||||
setHotCacheDebounceSec: (v: number) => void;
|
setHotCacheDebounceSec: (v: number) => void;
|
||||||
@@ -142,6 +146,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
showFullscreenLyrics: true,
|
showFullscreenLyrics: true,
|
||||||
showChangelogOnUpdate: true,
|
showChangelogOnUpdate: true,
|
||||||
lastSeenChangelogVersion: '',
|
lastSeenChangelogVersion: '',
|
||||||
|
enableHiRes: false,
|
||||||
hotCacheEnabled: false,
|
hotCacheEnabled: false,
|
||||||
hotCacheMaxMb: 256,
|
hotCacheMaxMb: 256,
|
||||||
hotCacheDebounceSec: 30,
|
hotCacheDebounceSec: 30,
|
||||||
@@ -222,6 +227,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||||
|
|
||||||
|
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||||
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
||||||
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
|
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
|
||||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
durationHint: nextTrack.duration,
|
durationHint: nextTrack.duration,
|
||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
||||||
@@ -744,6 +745,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
manual,
|
manual,
|
||||||
|
hiResEnabled: authState.enableHiRes,
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
setDeferHotCachePrefetch(false);
|
setDeferHotCachePrefetch(false);
|
||||||
@@ -825,6 +827,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
replayGainDb: replayGainDbCold,
|
replayGainDb: replayGainDbCold,
|
||||||
manual: false,
|
manual: false,
|
||||||
replayGainPeak: replayGainPeakCold,
|
replayGainPeak: replayGainPeakCold,
|
||||||
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
if (playGeneration === gen && currentTime > 1) {
|
if (playGeneration === gen && currentTime > 1) {
|
||||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||||
@@ -855,6 +858,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
replayGainDb: replayGainDbCold,
|
replayGainDb: replayGainDbCold,
|
||||||
replayGainPeak: replayGainPeakCold,
|
replayGainPeak: replayGainPeakCold,
|
||||||
manual: false,
|
manual: false,
|
||||||
|
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||||
}).catch((err: unknown) => {
|
}).catch((err: unknown) => {
|
||||||
if (playGeneration !== gen) return;
|
if (playGeneration !== gen) return;
|
||||||
setDeferHotCachePrefetch(false);
|
setDeferHotCachePrefetch(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user