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
+4
View File
@@ -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);