feat(ui): surface BitPerfectMode in QualityBadge (#288)
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
This commit is contained in:
8 files changed
+110
-7
No files matched your search
@@ -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<BitPerfectMode | null>(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 @@
|
||||
|
||||
<div class="badges-group">
|
||||
<div class="quality-indicator">
|
||||
<QualityBadge {quality} {bitDepth} {samplingRate} {originalBitDepth} {originalSamplingRate} {format} />
|
||||
<QualityBadge {quality} {bitDepth} {samplingRate} {originalBitDepth} {originalSamplingRate} {format} {bitPerfectMode} />
|
||||
<div class="audio-badges-row">
|
||||
<AudioOutputBadges {samplingRate} />
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<div class="badge-text">
|
||||
<span class="tier-label">
|
||||
{tierLabel}
|
||||
<span class="downgrade-indicator" class:visible={isDowngraded || isQualityAdjusted} class:adjusted={isQualityAdjusted}>↓</span>
|
||||
<span
|
||||
class="downgrade-indicator"
|
||||
class:visible={isDowngraded || isQualityAdjusted || isPluginFallback}
|
||||
class:adjusted={isQualityAdjusted}
|
||||
class:plughw={isPluginFallback}
|
||||
>↓</span>
|
||||
<span class="bitperfect-indicator" class:visible={isBitPerfect} title={$t('quality.tooltip.bitPerfect')}>✓</span>
|
||||
</span>
|
||||
<span class="quality-info">{displayText}</span>
|
||||
</div>
|
||||
|
||||
<!-- Custom tooltip for ALSA plughw fallback (software resample inside ALSA) -->
|
||||
{#if isPluginFallback && !isDowngraded && !isQualityAdjusted && isHovering}
|
||||
<div class="quality-tooltip">
|
||||
<div class="tooltip-section">
|
||||
<div class="tooltip-label">{$t('quality.tooltip.source')}</div>
|
||||
<div class="tooltip-value">{displayText}</div>
|
||||
</div>
|
||||
<div class="tooltip-section">
|
||||
<div class="tooltip-label">{$t('quality.tooltip.output')}</div>
|
||||
<div class="tooltip-value">{effectiveOutputRateKHz ? `${effectiveOutputRateKHz} kHz` : displayText}</div>
|
||||
</div>
|
||||
<div class="tooltip-divider"></div>
|
||||
<div class="tooltip-warning">{$t('quality.tooltip.plughwFallback')}</div>
|
||||
<div class="tooltip-fix">{$t('quality.tooltip.plughwFix')}</div>
|
||||
</div>
|
||||
<!-- Custom tooltip for quality-adjusted state (QBZ chose lower quality for hw compat) -->
|
||||
{#if isQualityAdjusted && isHovering}
|
||||
{:else if isQualityAdjusted && isHovering}
|
||||
<div class="quality-tooltip">
|
||||
<div class="tooltip-section">
|
||||
<div class="tooltip-label">{$t('quality.tooltip.source')}</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
if (seekRequestInFlight) return;
|
||||
if (pendingSeekPosition === null) return;
|
||||
@@ -766,6 +778,7 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
|
||||
// Update normalization gain state
|
||||
normalizationGain = event.normalization_gain;
|
||||
bufferProgress = event.buffer_progress ?? null;
|
||||
bitPerfectMode = event.bit_perfect_mode ?? null;
|
||||
|
||||
notifyListeners();
|
||||
|
||||
|
||||
Reference in new issue
Block a user