From 33761c64621bc4ab08b898cd1e39050b0a991db5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:38:06 -0600 Subject: [PATCH] feat(ui): surface BitPerfectMode in QualityBadge (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QualityBadge now has three new visual states wired off the bit_perfect_mode field the backend emits in PlaybackEvent: - DirectHardware → subtle green check next to the tier label, border tinted green. Reassurance that audiophile listeners are getting the stream untouched. - PluginFallback → purple down-arrow + new tooltip explaining the DAC doesn't support the source rate so ALSA (plughw) is doing software resampling. The tooltip's suggested fix is explicit about the mitigation (pick lower-rate source or a broader DAC) since this is a hardware-capability constraint, not a user setting issue. - Disabled / null → no change. Plumbed through the existing playerStore subscription pattern so the indicator reflects the stream the moment it opens. i18n keys added for all 5 locales (en/es/de/fr/pt). cc --- src/lib/components/NowPlayingBar.svelte | 19 ++++++- src/lib/components/QualityBadge.svelte | 68 +++++++++++++++++++++++-- src/lib/i18n/locales/de.json | 3 ++ src/lib/i18n/locales/en.json | 5 +- src/lib/i18n/locales/es.json | 3 ++ src/lib/i18n/locales/fr.json | 3 ++ src/lib/i18n/locales/pt.json | 3 ++ src/lib/stores/playerStore.ts | 13 +++++ 8 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/lib/components/NowPlayingBar.svelte b/src/lib/components/NowPlayingBar.svelte index b70278975..d2f76390a 100644 --- a/src/lib/components/NowPlayingBar.svelte +++ b/src/lib/components/NowPlayingBar.svelte @@ -36,7 +36,12 @@ refreshStatus, type OfflineReason } from '$lib/stores/offlineStore'; - import { toggleMute } from '$lib/stores/playerStore'; + import { + toggleMute, + getBitPerfectMode, + subscribe as subscribePlayer, + type BitPerfectMode, + } from '$lib/stores/playerStore'; import { subscribe as subscribeDegraded, isDegraded @@ -182,6 +187,16 @@ return unsubDegraded; }); + // Bit-perfect mode reported by the audio backend (null until first stream). + let bitPerfectMode = $state(getBitPerfectMode()); + + $effect(() => { + const unsubPlayer = subscribePlayer(() => { + bitPerfectMode = getBitPerfectMode(); + }); + return unsubPlayer; + }); + // Get human-readable offline reason function getOfflineReasonText(reason: OfflineReason | null): string { switch (reason) { @@ -440,7 +455,7 @@
- +
diff --git a/src/lib/components/QualityBadge.svelte b/src/lib/components/QualityBadge.svelte index d6d3e9c91..581f236e2 100644 --- a/src/lib/components/QualityBadge.svelte +++ b/src/lib/components/QualityBadge.svelte @@ -8,6 +8,8 @@ is_active: boolean; } + type BitPerfectMode = 'DirectHardware' | 'PluginFallback' | 'Disabled'; + interface Props { quality?: string; bitDepth?: number; @@ -16,6 +18,9 @@ originalSamplingRate?: number; format?: string; compact?: boolean; + /** Reported by the audio backend when a stream is opened. Lets us distinguish + * ALSA plughw software resample (PluginFallback) from system resampling. */ + bitPerfectMode?: BitPerfectMode | null; } let { @@ -25,7 +30,8 @@ originalBitDepth, originalSamplingRate, format, - compact = false + compact = false, + bitPerfectMode = null, }: Props = $props(); // Hardware sample rate from /proc/asound (sub-ms read) @@ -69,6 +75,14 @@ return false; }); + // ALSA plughw fallback is its own failure mode: the stream is being resampled + // inside the kernel/ALSA space because the DAC does not accept the source + // rate (see issue #288). Distinct from isDowngraded which is about the + // pipewire/system path — both get surfaced in the UI but with different + // explanations so users know where to look for the fix. + const isPluginFallback = $derived(bitPerfectMode === 'PluginFallback'); + const isBitPerfect = $derived(bitPerfectMode === 'DirectHardware'); + // Quality adjustment detection: QBZ fetched lower quality for hardware compatibility. // Decoded rate < original metadata rate, but hardware plays it natively (no resampling). const isQualityAdjusted = $derived.by(() => { @@ -198,7 +212,9 @@ class="quality-badge" class:downgraded={isDowngraded} class:adjusted={isQualityAdjusted} - title={isDowngraded || isQualityAdjusted ? undefined : `${tierLabel}: ${displayText}`} + class:plughw={isPluginFallback} + class:bitperfect={isBitPerfect} + title={isDowngraded || isQualityAdjusted || isPluginFallback ? undefined : `${tierLabel}: ${displayText}`} onmouseenter={() => { isHovering = true; }} onmouseleave={() => { isHovering = false; }} > @@ -216,13 +232,34 @@
{tierLabel} - ↓ + ↓ + ✓ {displayText}
+ + {#if isPluginFallback && !isDowngraded && !isQualityAdjusted && isHovering} +
+
+
{$t('quality.tooltip.source')}
+
{displayText}
+
+
+
{$t('quality.tooltip.output')}
+
{effectiveOutputRateKHz ? `${effectiveOutputRateKHz} kHz` : displayText}
+
+
+
{$t('quality.tooltip.plughwFallback')}
+
{$t('quality.tooltip.plughwFix')}
+
- {#if isQualityAdjusted && isHovering} + {:else if isQualityAdjusted && isHovering}
{$t('quality.tooltip.source')}
@@ -332,6 +369,14 @@ border-color: rgba(234, 179, 8, 0.3); } + .quality-badge.plughw { + border-color: rgba(168, 85, 247, 0.3); + } + + .quality-badge.bitperfect { + border-color: rgba(34, 197, 94, 0.25); + } + .downgrade-indicator { color: #eab308; font-weight: 600; @@ -347,6 +392,21 @@ color: #60a5fa; } + .downgrade-indicator.plughw { + color: #a855f7; + } + + .bitperfect-indicator { + color: #22c55e; + font-weight: 600; + margin-left: 2px; + visibility: hidden; + } + + .bitperfect-indicator.visible { + visibility: visible; + } + /* Custom tooltip for degraded quality */ .quality-tooltip { position: absolute; diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 88f387d96..cad234df2 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -687,6 +687,9 @@ "output": "Ausgabe", "resampled": "Vom System resampled", "qualityAdjusted": "Qualität für Gerätekompatibilität angepasst — bit-perfekt, kein Resampling", + "plughwFallback": "Von ALSA resampelt (plughw)", + "plughwFix": "Der DAC unterstützt diese Abtastrate nicht nativ. Wählen Sie eine Quelle mit niedrigerer Rate oder einen DAC mit breiterer Unterstützung.", + "bitPerfect": "Bit-perfekt: direkte Hardware-Ausgabe", "fix": "Aktiviere DAC Passthrough oder Exklusivmodus in den Einstellungen" }, "stereo": "Stereo", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 5d324c92d..8b0ba4929 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -687,7 +687,10 @@ "output": "Output", "resampled": "Resampled by system", "qualityAdjusted": "Quality adjusted for device compatibility — bit-perfect, no resampling", - "fix": "Enable DAC Passthrough or Exclusive Mode in Settings" + "fix": "Enable DAC Passthrough or Exclusive Mode in Settings", + "plughwFallback": "Resampled by ALSA (plughw)", + "plughwFix": "DAC doesn't support this sample rate natively. Pick a lower-rate source or a DAC with broader rate support.", + "bitPerfect": "Bit-perfect: direct hardware output" }, "stereo": "Stereo", "mono": "Mono" diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 352cd16e6..e71bfd0dc 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -687,6 +687,9 @@ "output": "Salida", "resampled": "Remuestreado por el sistema", "qualityAdjusted": "Calidad ajustada por compatibilidad — bit-perfect, sin remuestreo", + "plughwFallback": "Remuestreado por ALSA (plughw)", + "plughwFix": "El DAC no soporta esta tasa de muestreo nativamente. Elige una fuente de menor tasa o un DAC con mayor soporte.", + "bitPerfect": "Bit-perfect: salida directa al hardware", "fix": "Activa DAC Passthrough o Modo Exclusivo en Ajustes" }, "stereo": "Estéreo", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 0884bba12..990750eee 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -687,6 +687,9 @@ "output": "Sortie", "resampled": "Rééchantillonné par le système", "qualityAdjusted": "Qualité ajustée pour compatibilité — bit-perfect, sans rééchantillonnage", + "plughwFallback": "Rééchantillonné par ALSA (plughw)", + "plughwFix": "Le DAC ne prend pas en charge cette fréquence nativement. Choisissez une source à fréquence plus basse ou un DAC avec un support plus large.", + "bitPerfect": "Bit-perfect : sortie matérielle directe", "fix": "Activez DAC Passthrough ou Mode Exclusif dans les Paramètres" }, "stereo": "Stéréo", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index 7a8f67e11..5cd95f555 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -687,6 +687,9 @@ "output": "Saída", "resampled": "Reamostrado pelo sistema", "qualityAdjusted": "Qualidade ajustada para compatibilidade do dispositivo — bit-perfect, sem reamostragem", + "plughwFallback": "Reamostrado pelo ALSA (plughw)", + "plughwFix": "O DAC não suporta esta taxa de amostragem nativamente. Escolha uma fonte de menor taxa ou um DAC com suporte mais amplo.", + "bitPerfect": "Bit-perfect: saída direta de hardware", "fix": "Ative DAC Passthrough ou Modo Exclusivo nas Configurações" }, "stereo": "Estéreo", diff --git a/src/lib/stores/playerStore.ts b/src/lib/stores/playerStore.ts index 9864a4ec8..ae40a3a23 100644 --- a/src/lib/stores/playerStore.ts +++ b/src/lib/stores/playerStore.ts @@ -70,6 +70,12 @@ interface BackendPlaybackState { volume: number; } +/** + * Bit-perfect mode of the active audio stream, reported by the Rust backend. + * Matches the `qbz_audio::BitPerfectMode` enum via serde. + */ +export type BitPerfectMode = 'DirectHardware' | 'PluginFallback' | 'Disabled'; + // Event payload from backend interface PlaybackEvent { is_playing: boolean; @@ -83,6 +89,7 @@ interface PlaybackEvent { gapless_ready: boolean; // Backend wants next track queued for gapless gapless_next_track_id: number; // Track ID queued for gapless (0 = none) buffer_progress?: number | null; // Streaming buffer progress (0-1, null = fully cached) + bit_perfect_mode?: BitPerfectMode | null; // None until first stream opens } // Queue track from backend (for external track sync) @@ -147,6 +154,7 @@ let isSkipping = false; let queueEnded = false; let normalizationGain: number | null = null; // Current normalization gain (null = not active) let bufferProgress: number | null = null; // Streaming buffer progress (0-1, null = fully cached) +let bitPerfectMode: BitPerfectMode | null = null; // Reported by backend when stream opens let pendingSeekPosition: number | null = null; let seekRequestInFlight = false; let seekTargetPosition: number | null = null; @@ -227,6 +235,10 @@ export function getNormalizationGain(): number | null { return normalizationGain; } +export function getBitPerfectMode(): BitPerfectMode | null { + return bitPerfectMode; +} + async function flushPendingSeek(): Promise { if (seekRequestInFlight) return; if (pendingSeekPosition === null) return; @@ -766,6 +778,7 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise { // Update normalization gain state normalizationGain = event.normalization_gain; bufferProgress = event.buffer_progress ?? null; + bitPerfectMode = event.bit_perfect_mode ?? null; notifyListeners();