diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 63710b9c..6a49e9c2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,3 +44,4 @@ tauri-plugin-process = "2" souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } discord-rich-presence = "0.2" url = "2" +thread-priority = "1" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 4f7c7ba8..2b6053fa 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -13,7 +13,7 @@ use symphonia::core::{ audio::{AudioBufferRef, SampleBuffer, SignalSpec}, codecs::{DecoderOptions, CODEC_TYPE_NULL}, formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, - io::{MediaSource, MediaSourceStream}, + io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, meta::MetadataOptions, probe::Hint, units::{self, Time}, @@ -908,9 +908,13 @@ impl SizedDecoder { inner: Cursor::new(data), len: data_len, }; + // 4 MB read-ahead buffer for Symphonia. Since the source is an in-memory + // Cursor>, 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( Box::new(source) as Box, - Default::default(), + MediaSourceStreamOptions { buffer_len: 4 * 1024 * 1024 }, ); let mut hint = Hint::new(); @@ -996,7 +1000,9 @@ impl SizedDecoder { /// Uses `enable_gapless: false` — live streams are not seekable; gapless /// trimming requires seeking to read the LAME/iTunSMPB end-padding info. fn new_streaming(media: Box, format_hint: Option<&str>) -> Result { - 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(); if let Some(ext) = format_hint { hint.with_extension(ext); } let format_opts = FormatOptions { enable_gapless: false, ..Default::default() }; @@ -1350,7 +1356,12 @@ pub(crate) struct ChainedInfo { } pub struct AudioEngine { - pub stream_handle: Arc, + pub stream_handle: Arc>, + /// Sample rate the output stream was last opened at (updated on every re-open). + pub stream_sample_rate: Arc, + /// 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)>, pub current: Arc>, /// Monotonically incremented on each audio_play (non-chain) / audio_stop call. pub generation: Arc, @@ -1411,25 +1422,90 @@ impl AudioCurrent { } } -pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { - let (tx, rx) = std::sync::mpsc::sync_channel::(0); +/// Open the system default output device at `desired_rate` Hz (0 = device default). +/// +/// 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. - // Only set if the user hasn't already configured these themselves. - // PIPEWIRE_LATENCY: 4096 frames / 48000 Hz ≈ 85 ms — enough to absorb scheduler jitter. - #[cfg(target_os = "linux")] - { - if std::env::var("PIPEWIRE_LATENCY").is_err() { - std::env::set_var("PIPEWIRE_LATENCY", "4096/48000"); + let host = rodio::cpal::default_host(); + + if let Some(device) = host.default_output_device() { + if desired_rate > 0 { + if let Ok(supported) = device.supported_output_configs() { + let configs: Vec<_> = supported.collect(); + + // 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. - // 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")] { 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)>(4); + let thread = std::thread::Builder::new() .name("psysonic-audio-stream".into()) - .spawn(move || match rodio::OutputStream::try_default() { - Ok((_stream, handle)) => { - tx.send(handle).ok(); - loop { std::thread::park(); } + .spawn(move || { + // Set PipeWire / PulseAudio latency hints before the first open. + #[cfg(target_os = "linux")] + { + 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"); - let stream_handle = rx.recv().expect("audio stream handle"); + let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle"); 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 { sink: None, duration_secs: 0.0, @@ -1605,6 +1727,7 @@ pub async fn audio_play( replay_gain_db: Option, replay_gain_peak: Option, 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, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -1757,9 +1880,59 @@ pub async fn audio_play( 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::(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 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. // Only when this is an auto-advance (near end), not on manual skip. if !gapless { @@ -1783,6 +1956,19 @@ pub async fn audio_play( 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. let (old_sink, old_fadeout_trigger, old_fadeout_samples) = { let mut cur = state.current.lock().unwrap(); @@ -1869,6 +2055,7 @@ pub async fn audio_chain_preload( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + hi_res_enabled: bool, state: State<'_, AudioEngine>, ) -> Result<(), String> { // Idempotent: already chained this track → nothing to do. @@ -1959,6 +2146,25 @@ pub async fn audio_chain_preload( return Ok(()); } + // In hi-res mode: if the next track's native rate differs from the current + // output stream, we cannot chain gaplessly — audio_play will do a hard cut + // with a stream re-open. Store raw bytes to avoid re-downloading. + // In safe mode (44.1 kHz locked): the stream rate is always 44100, so the + // chain proceeds and rodio resamples internally — no bail needed. + let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 }; + 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. { 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(()); } - 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.append(counting); diff --git a/src/locales/de.ts b/src/locales/de.ts index 5d7e20a6..f94fcf62 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -430,6 +430,9 @@ export const deTranslation = { hotCacheDebounceImmediate: 'Sofort', hotCacheDebounceSeconds: '{{n}} s', 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', 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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 2ae8fdb6..21478ae0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -430,6 +430,9 @@ export const enTranslation = { hotCacheDebounceImmediate: 'Immediate', hotCacheDebounceSeconds: '{{n}} s', 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', 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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index cd0665fc..ec470568 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -430,6 +430,9 @@ export const frTranslation = { hotCacheDebounceImmediate: 'Immédiat', hotCacheDebounceSeconds: '{{n}} s', 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', 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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index c6ae5405..ad2ef5e0 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -431,6 +431,9 @@ export const nbTranslation = { hotCacheDebounceImmediate: 'Umiddelbart', hotCacheDebounceSeconds: '{{n}} sek', 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', 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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index b820d187..c3c9d5e0 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -430,6 +430,9 @@ export const nlTranslation = { hotCacheDebounceImmediate: 'Direct', hotCacheDebounceSeconds: '{{n}} s', 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', 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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 77736f28..74bf7fc9 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -447,6 +447,9 @@ export const ruTranslation = { hotCacheDebounceImmediate: 'Сразу', hotCacheDebounceSeconds: '{{n}} с', hotCacheClearBtn: 'Очистить горячий кэш', + hiResTitle: 'Нативное воспроизведение Hi-Res', + hiResEnabled: 'Включить нативное Hi-Res воспроизведение', + hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", showArtistImages: 'Фото исполнителей', showArtistImagesDesc: 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index aa0fcb8f..261b912c 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -426,6 +426,9 @@ export const zhTranslation = { hotCacheDebounceImmediate: '立即', hotCacheDebounceSeconds: '{{n}} 秒', hotCacheClearBtn: '清空热缓存', + hiResTitle: '原生高清晰度播放', + hiResEnabled: '启用原生高清晰度播放', + hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", showArtistImages: '显示艺术家图片', showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。', showTrayIcon: '显示托盘图标', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 9cbab1bb..9786a0aa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, 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'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; @@ -545,6 +545,41 @@ export default function Settings() { + {/* Native Hi-Res Playback */} +
+
+ +

+ {t('settings.hiResTitle')} + + {t('settings.hotCacheAlphaBadge')} + +

+
+
+
+
+
{t('settings.hiResEnabled')}
+
{t('settings.hiResDesc')}
+
+ +
+
+
+ )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 176d99dd..0c9a5a67 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -47,6 +47,9 @@ interface AuthState { showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; + /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ + enableHiRes: boolean; + /** Alpha: ephemeral queue prefetch cache on disk */ hotCacheEnabled: boolean; hotCacheMaxMb: number; @@ -95,6 +98,7 @@ interface AuthState { setShowFullscreenLyrics: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; + setEnableHiRes: (v: boolean) => void; setHotCacheEnabled: (v: boolean) => void; setHotCacheMaxMb: (v: number) => void; setHotCacheDebounceSec: (v: number) => void; @@ -142,6 +146,7 @@ export const useAuthStore = create()( showFullscreenLyrics: true, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', + enableHiRes: false, hotCacheEnabled: false, hotCacheMaxMb: 256, hotCacheDebounceSec: 30, @@ -222,6 +227,7 @@ export const useAuthStore = create()( setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), + setEnableHiRes: (v) => set({ enableHiRes: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }), setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 047fafc2..cc3eb36c 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -310,6 +310,7 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, + hiResEnabled: useAuthStore.getState().enableHiRes, }).catch(() => {}); } else { // Gapless OFF: just pre-download bytes so audio_play finds them cached. @@ -744,6 +745,7 @@ export const usePlayerStore = create()( replayGainDb, replayGainPeak, manual, + hiResEnabled: authState.enableHiRes, }).catch((err: unknown) => { if (playGeneration !== gen) return; setDeferHotCachePrefetch(false); @@ -825,6 +827,7 @@ export const usePlayerStore = create()( replayGainDb: replayGainDbCold, manual: false, replayGainPeak: replayGainPeakCold, + hiResEnabled: useAuthStore.getState().enableHiRes, }).then(() => { if (playGeneration === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); @@ -855,6 +858,7 @@ export const usePlayerStore = create()( replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, manual: false, + hiResEnabled: useAuthStore.getState().enableHiRes, }).catch((err: unknown) => { if (playGeneration !== gen) return; setDeferHotCachePrefetch(false);