feat(dac-wizard): add DAC capabilities query from PipeWire/PulseAudio

Backend (Rust):
- Add query_dac_capabilities command that queries pw-cli and pactl
- Returns supported sample rates, formats, channels, and device description
- Parses both PipeWire and PulseAudio outputs for maximum compatibility

Frontend (Svelte):
- Add "Query DAC Capabilities" button in detect-dac step
- Display detected sample rates, formats, channels
- Show disclaimer about checking manufacturer docs if values don't match
- Add translations for query UI (EN/ES/FR/DE)
This commit is contained in:
vicrodh committed 2026-02-03 01:47:42 -06:00
1 parent 5ce5a57d5b
commit 278136762d
7 files changed
+466 -1

No files matched your search

+247
View File
@@ -166,3 +166,250 @@ pub fn get_linux_distro() -> LinuxDistroInfo {
install_command,
}
}
/// DAC capabilities information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DacCapabilities {
pub node_name: String,
pub sample_rates: Vec<u32>,
pub formats: Vec<String>,
pub channels: Option<u32>,
pub description: Option<String>,
pub error: Option<String>,
}
/// Query DAC capabilities from PipeWire
#[tauri::command]
pub fn query_dac_capabilities(node_name: String) -> DacCapabilities {
use std::process::Command;
log::info!("Command: query_dac_capabilities({})", node_name);
let mut caps = DacCapabilities {
node_name: node_name.clone(),
sample_rates: Vec::new(),
formats: Vec::new(),
channels: None,
description: None,
error: None,
};
// First, try to get the node ID from the node name using pw-cli
let pw_dump = Command::new("pw-cli")
.args(["list-objects", "Node"])
.output();
let node_id = match pw_dump {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
// Find the node ID for this node name
// Format: id X, type PipeWire:Interface:Node/3
// ... node.name = "alsa_output.usb-..."
let mut found_id: Option<String> = None;
let mut current_id: Option<String> = None;
for line in stdout.lines() {
if line.contains("id ") && line.contains("type PipeWire:Interface:Node") {
// Extract ID from line like "id 46, type PipeWire:Interface:Node/3"
if let Some(id_part) = line.split("id ").nth(1) {
if let Some(id) = id_part.split(',').next() {
current_id = Some(id.trim().to_string());
}
}
}
if line.contains("node.name") && line.contains(&node_name) {
found_id = current_id.clone();
break;
}
}
found_id
}
_ => None,
};
// If we found the node ID, query its properties
if let Some(id) = node_id {
log::info!("Found node ID: {} for {}", id, node_name);
// Query node properties using pw-cli
let inspect = Command::new("pw-cli")
.args(["info", &id])
.output();
if let Ok(output) = inspect {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
parse_pw_info(&stdout, &mut caps);
}
}
// Also try pactl for additional info
let pactl = Command::new("pactl")
.args(["list", "sinks"])
.output();
if let Ok(output) = pactl {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
parse_pactl_sinks(&stdout, &node_name, &mut caps);
}
}
} else {
// Try pactl directly if pw-cli didn't find it
let pactl = Command::new("pactl")
.args(["list", "sinks"])
.output();
match pactl {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
if !parse_pactl_sinks(&stdout, &node_name, &mut caps) {
caps.error = Some("Device not found. Make sure the node name is correct.".to_string());
}
}
Ok(_) => {
caps.error = Some("pactl command failed".to_string());
}
Err(e) => {
caps.error = Some(format!("Failed to run pactl: {}", e));
}
}
}
// If we still don't have sample rates, add common defaults with a note
if caps.sample_rates.is_empty() && caps.error.is_none() {
caps.error = Some("Could not detect sample rates. Check device manually.".to_string());
}
log::info!("DAC capabilities: {:?}", caps);
caps
}
fn parse_pw_info(output: &str, caps: &mut DacCapabilities) {
for line in output.lines() {
let line = line.trim();
// Look for audio.rate or similar
if line.contains("audio.rate") || line.contains("default.clock.rate") {
if let Some(rate_str) = line.split('=').last() {
if let Ok(rate) = rate_str.trim().trim_matches('"').parse::<u32>() {
if !caps.sample_rates.contains(&rate) {
caps.sample_rates.push(rate);
}
}
}
}
// Look for allowed rates
if line.contains("clock.allowed-rates") || line.contains("audio.allowed-rates") {
// Format: [ 44100 48000 88200 96000 176400 192000 ]
if let Some(rates_part) = line.split('[').last() {
if let Some(rates_str) = rates_part.split(']').next() {
for rate_str in rates_str.split_whitespace() {
if let Ok(rate) = rate_str.parse::<u32>() {
if !caps.sample_rates.contains(&rate) {
caps.sample_rates.push(rate);
}
}
}
}
}
}
// Look for audio format
if line.contains("audio.format") || line.contains("format.dsp") {
if let Some(format) = line.split('=').last() {
let format = format.trim().trim_matches('"').to_string();
if !format.is_empty() && !caps.formats.contains(&format) {
caps.formats.push(format);
}
}
}
// Look for channels
if line.contains("audio.channels") {
if let Some(ch_str) = line.split('=').last() {
if let Ok(ch) = ch_str.trim().trim_matches('"').parse::<u32>() {
caps.channels = Some(ch);
}
}
}
// Look for description
if line.contains("node.description") {
if let Some(desc) = line.split('=').last() {
caps.description = Some(desc.trim().trim_matches('"').to_string());
}
}
}
}
fn parse_pactl_sinks(output: &str, node_name: &str, caps: &mut DacCapabilities) -> bool {
let mut in_target_sink = false;
let mut found = false;
for line in output.lines() {
let line_trimmed = line.trim();
// Check if we're entering a new sink block
if line.starts_with("Sink #") {
in_target_sink = false;
}
// Check if this is our target sink
if line_trimmed.contains("Name:") && line_trimmed.contains(node_name) {
in_target_sink = true;
found = true;
}
if in_target_sink {
// Get description
if line_trimmed.starts_with("Description:") {
if let Some(desc) = line_trimmed.strip_prefix("Description:") {
caps.description = Some(desc.trim().to_string());
}
}
// Get sample spec (contains rate and format)
if line_trimmed.starts_with("Sample Specification:") {
// Format: "s32le 2ch 192000Hz"
if let Some(spec) = line_trimmed.strip_prefix("Sample Specification:") {
let parts: Vec<&str> = spec.trim().split_whitespace().collect();
for part in parts {
// Format like s16le, s24le, s32le, float32le
if part.starts_with('s') || part.starts_with("float") {
let format = part.to_uppercase();
if !caps.formats.contains(&format) {
caps.formats.push(format);
}
}
// Channels like 2ch
if part.ends_with("ch") {
if let Ok(ch) = part.trim_end_matches("ch").parse::<u32>() {
caps.channels = Some(ch);
}
}
// Rate like 192000Hz
if part.ends_with("Hz") {
if let Ok(rate) = part.trim_end_matches("Hz").parse::<u32>() {
if !caps.sample_rates.contains(&rate) {
caps.sample_rates.push(rate);
}
}
}
}
}
}
// Look for alternate sample rates in properties
if line_trimmed.contains("device.buffering.buffer_size") {
// End of useful properties for this sink
}
}
}
// Sort sample rates
caps.sample_rates.sort();
found
}
+1
View File
@@ -801,6 +801,7 @@ pub fn run() {
commands::get_alsa_plugins,
commands::check_alsa_utils_installed,
commands::get_linux_distro,
commands::query_dac_capabilities,
// Download settings commands
config::download_settings::get_download_settings,
config::download_settings::set_download_root,
+182 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { X, CheckCircle, Check, AlertTriangle, XCircle } from 'lucide-svelte';
import { X, CheckCircle, Check, AlertTriangle, XCircle, Search, Loader2 } from 'lucide-svelte';
import { invoke } from '@tauri-apps/api/core';
import { t } from '$lib/i18n';
import WizardStepper, { type Step } from './wizard/WizardStepper.svelte';
import CommandBlock from './wizard/CommandBlock.svelte';
@@ -7,6 +8,16 @@
import DistroSelector from './wizard/DistroSelector.svelte';
import BitPerfectAppSelector from './wizard/BitPerfectAppSelector.svelte';
// DAC capabilities from Tauri backend
interface DacCapabilities {
node_name: string;
sample_rates: number[];
formats: string[];
channels: number | null;
description: string | null;
error: string | null;
}
// DAC node name validation
type DacType = 'usb' | 'pci' | 'bluetooth' | 'virtual' | 'unknown';
type ValidationStatus = 'empty' | 'valid' | 'invalid';
@@ -71,6 +82,36 @@
let showRollback = $state(false);
let selectedDistro = $state('debian');
// DAC capabilities query state
let dacCapabilities = $state<DacCapabilities | null>(null);
let isQueryingDac = $state(false);
async function queryDacCapabilities() {
if (!dacNodeName.trim() || dacValidation !== 'valid') return;
isQueryingDac = true;
dacCapabilities = null;
try {
const caps = await invoke<DacCapabilities>('query_dac_capabilities', {
nodeName: dacNodeName
});
dacCapabilities = caps;
} catch (err) {
console.error('Failed to query DAC capabilities:', err);
dacCapabilities = {
node_name: dacNodeName,
sample_rates: [],
formats: [],
channels: null,
description: null,
error: String(err)
};
} finally {
isQueryingDac = false;
}
}
// Reset state when modal opens
$effect(() => {
if (isOpen) {
@@ -81,6 +122,8 @@
precheckDone = false;
restartDone = false;
showRollback = false;
dacCapabilities = null;
isQueryingDac = false;
}
});
@@ -327,6 +370,71 @@
</div>
{/if}
</div>
<!-- Query DAC Capabilities -->
{#if dacValidation === 'valid'}
<div class="query-section">
<button
class="query-btn"
onclick={queryDacCapabilities}
disabled={isQueryingDac}
>
{#if isQueryingDac}
<Loader2 size={16} class="spin" />
{$t('dacWizard.detectDac.query.querying')}
{:else}
<Search size={16} />
{$t('dacWizard.detectDac.query.button')}
{/if}
</button>
{#if dacCapabilities}
<div class="capabilities-result">
{#if dacCapabilities.description}
<div class="cap-row">
<span class="cap-label">{$t('dacWizard.detectDac.query.device')}:</span>
<span class="cap-value">{dacCapabilities.description}</span>
</div>
{/if}
{#if dacCapabilities.sample_rates.length > 0}
<div class="cap-row">
<span class="cap-label">{$t('dacWizard.detectDac.query.sampleRates')}:</span>
<span class="cap-value rates">
{dacCapabilities.sample_rates.map(r => `${(r / 1000).toFixed(1)}kHz`).join(', ')}
</span>
</div>
{/if}
{#if dacCapabilities.formats.length > 0}
<div class="cap-row">
<span class="cap-label">{$t('dacWizard.detectDac.query.formats')}:</span>
<span class="cap-value">{dacCapabilities.formats.join(', ')}</span>
</div>
{/if}
{#if dacCapabilities.channels}
<div class="cap-row">
<span class="cap-label">{$t('dacWizard.detectDac.query.channels')}:</span>
<span class="cap-value">{dacCapabilities.channels}</span>
</div>
{/if}
{#if dacCapabilities.error}
<WarningBanner
variant="warning"
body={dacCapabilities.error}
/>
{:else}
<WarningBanner
variant="info"
body={$t('dacWizard.detectDac.query.disclaimer')}
/>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{:else if currentStep === 'backup'}
@@ -732,6 +840,79 @@
background: rgba(239, 68, 68, 0.1);
}
.query-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.query-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--accent-primary);
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 150ms ease;
}
.query-btn:hover:not(:disabled) {
opacity: 0.9;
}
.query-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.query-btn :global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.capabilities-result {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.cap-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.cap-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cap-value {
font-size: 14px;
color: var(--text-primary);
}
.cap-value.rates {
font-family: var(--font-mono, monospace);
color: var(--color-success, #22c55e);
}
.targeting-info {
display: flex;
flex-direction: column;
+9
View File
@@ -999,6 +999,15 @@
"pciDac": "Internes Audio - Bit-Perfect abhängig von Hardware",
"bluetoothDac": "Bluetooth-Gerät - Bit-Perfect nicht möglich",
"virtualDac": "Virtuelles Gerät - Bit-Perfect nicht anwendbar"
},
"query": {
"button": "DAC-Fähigkeiten abfragen",
"querying": "Abfrage läuft...",
"device": "Gerät",
"sampleRates": "Abtastraten",
"formats": "Formate",
"channels": "Kanäle",
"disclaimer": "Wenn diese Werte nicht mit den DAC-Spezifikationen übereinstimmen, prüfen Sie die Herstellerdokumentation."
}
},
"backup": {
+9
View File
@@ -1058,6 +1058,15 @@
"pciDac": "Internal audio - bit-perfect depends on hardware",
"bluetoothDac": "Bluetooth device - bit-perfect not possible",
"virtualDac": "Virtual device - bit-perfect not applicable"
},
"query": {
"button": "Query DAC Capabilities",
"querying": "Querying...",
"device": "Device",
"sampleRates": "Sample Rates",
"formats": "Formats",
"channels": "Channels",
"disclaimer": "If these values don't match your DAC specs, check the manufacturer documentation."
}
},
"backup": {
+9
View File
@@ -1058,6 +1058,15 @@
"pciDac": "Audio interno - bit-perfect depende del hardware",
"bluetoothDac": "Dispositivo Bluetooth - bit-perfect no es posible",
"virtualDac": "Dispositivo virtual - bit-perfect no aplica"
},
"query": {
"button": "Consultar Capacidades del DAC",
"querying": "Consultando...",
"device": "Dispositivo",
"sampleRates": "Sample Rates",
"formats": "Formatos",
"channels": "Canales",
"disclaimer": "Si estos valores no coinciden con las especificaciones de tu DAC, consulta la documentación del fabricante."
}
},
"backup": {
+9
View File
@@ -999,6 +999,15 @@
"pciDac": "Audio interne - bit-perfect dépend du matériel",
"bluetoothDac": "Appareil Bluetooth - bit-perfect impossible",
"virtualDac": "Appareil virtuel - bit-perfect non applicable"
},
"query": {
"button": "Interroger les capacités du DAC",
"querying": "Interrogation...",
"device": "Appareil",
"sampleRates": "Taux d'échantillonnage",
"formats": "Formats",
"channels": "Canaux",
"disclaimer": "Si ces valeurs ne correspondent pas aux spécifications de votre DAC, consultez la documentation du fabricant."
}
},
"backup": {