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
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -447,6 +447,9 @@ export const ruTranslation = {
hotCacheDebounceImmediate: 'Сразу',
hotCacheDebounceSeconds: '{{n}} с',
hotCacheClearBtn: 'Очистить горячий кэш',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию ограничивает вывод до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
showArtistImages: 'Фото исполнителей',
showArtistImagesDesc:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
+3
View File
@@ -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
View File
@@ -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>
</>
)}
+6
View File
@@ -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 }),
+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);