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:
Psychotoxical
2026-04-07 12:20:07 +02:00
parent 0f3033d84e
commit 44287a7ceb
12 changed files with 300 additions and 27 deletions
+1
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
+3
View File
@@ -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
View File
@@ -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>
</> </>
)} )}
+6
View File
@@ -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 }),
+4
View File
@@ -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);