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"] }
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
|
||||
+232
-26
@@ -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<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(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
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<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();
|
||||
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<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>>,
|
||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||
pub generation: Arc<AtomicU64>,
|
||||
@@ -1411,25 +1422,90 @@ impl AudioCurrent {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(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<rodio::OutputStreamHandle>)>(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<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
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::<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 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<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -447,6 +447,9 @@ export const ruTranslation = {
|
||||
hotCacheDebounceImmediate: 'Сразу',
|
||||
hotCacheDebounceSeconds: '{{n}} с',
|
||||
hotCacheClearBtn: 'Очистить горячий кэш',
|
||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||
showArtistImages: 'Фото исполнителей',
|
||||
showArtistImagesDesc:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
|
||||
@@ -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: '显示托盘图标',
|
||||
|
||||
+36
-1
@@ -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() {
|
||||
</div>
|
||||
</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;
|
||||
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<AuthState>()(
|
||||
showFullscreenLyrics: true,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
enableHiRes: false,
|
||||
hotCacheEnabled: false,
|
||||
hotCacheMaxMb: 256,
|
||||
hotCacheDebounceSec: 30,
|
||||
@@ -222,6 +227,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 }),
|
||||
|
||||
@@ -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<PlayerState>()(
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
@@ -825,6 +827,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
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<PlayerState>()(
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
|
||||
Reference in New Issue
Block a user