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:
1 parent
5ce5a57d5b
commit
278136762d
7 files changed
+466
-1
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user