feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened from Settings > Audio. It probes the audio stack and renders per-distro remediations, enumerates DACs with a manual node.name escape hatch, generates the PipeWire / WirePlumber snippets to copy, and plays four tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated read-back. Read-only: it never writes a system file or runs a command. The logic was unreachable from Qt — every public function in qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals. It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core, which returns plain data. qbz-dac-wizard itself is left untouched. Rust owns everything it computes plus `open`, so the reset and the probe are one act; QML owns the step navigation and the four progress checkboxes, which is how the reference splits them too. Two deliberate divergences from the reference: the row sits at the end of OUTPUT rather than BIT-PERFECT, and it is offered on every backend instead of PipeWire only — someone on ALSA direct is exactly who needs the walkthrough. The green glyphs use the accent tint, not success: success has no qrc bake directory, so a degraded tint set would render nothing at all. Every success-toned text keeps theme.success (#3fae6a verbatim). Verified by 309 tests, both QML audits and the offscreen smoke, plus a forced-route probe that mounts Settings and walks the wizard's steps — the standard smoke only mounts Home, so it is no evidence for a modal. The modal was also driven and screenshotted over VNC. cc
This commit is contained in:
28 files changed
+3983
-3
No files matched your search
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "qbz-dac-wizard-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "HiFi/DAC setup wizard — frontend-agnostic core (probes, remediations, config generation)."
|
||||
|
||||
[lib]
|
||||
name = "qbz_dac_wizard_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# Deliberately NO frontend dependency (no slint, no qbz-ui, no cxx-qt). ADR-006:
|
||||
# the core is frontend-agnostic so Slint, Qt and qbzd consume the SAME logic.
|
||||
# Everything here returns plain data; pushing it into a UI is the frontend's job.
|
||||
[dependencies]
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-i18n = { path = "../qbz-i18n" }
|
||||
@@ -0,0 +1,774 @@
|
||||
//! HiFi Wizard (DAC setup) — the frontend-agnostic core.
|
||||
//!
|
||||
//! # Why this crate exists
|
||||
//!
|
||||
//! The wizard shipped as `qbz-dac-wizard`, whose every public function takes a
|
||||
//! `&AppWindow` and writes straight into Slint globals — so the Qt port could
|
||||
//! not reach any of it. This crate is that logic, lifted out and returning
|
||||
//! plain data: `qbz-qt` drives it through `dac_wizard_qt.rs`. ADR-006,
|
||||
//! frontend-agnostic core.
|
||||
//!
|
||||
//! # Its relationship to `qbz-dac-wizard` — read before "deduplicating"
|
||||
//!
|
||||
//! `qbz-dac-wizard` still holds its own copy of this logic and is NOT wired to
|
||||
//! this crate. That is deliberate: the Slint tree is FROZEN (owner,
|
||||
//! 2026-08-11 — "ya no hacemos cambios para Slint"), so rewiring it would have
|
||||
//! been an unverifiable change to a tree nobody compiles or tests any more,
|
||||
//! bought for a deduplication that a frozen tree cannot benefit from.
|
||||
//!
|
||||
//! The usual objection to two copies is drift. It does not apply while the
|
||||
//! other copy never changes — and if Slint is ever unfrozen, the merge is the
|
||||
//! diff of one file. Fixes land HERE; nothing needs porting back.
|
||||
//!
|
||||
//! `qbzd` duplicates parts of this again in `tui/wizard_core.rs`. THAT one is
|
||||
//! live code and is a genuine candidate to fold onto this crate.
|
||||
//!
|
||||
//! # What is guaranteed
|
||||
//!
|
||||
//! READ-ONLY. Nothing here writes a system file, opens a stream or runs a
|
||||
//! command. It probes (`qbz_audio::health`, sink enumeration, rate queries)
|
||||
//! and it formats text. That property is the wizard's whole safety story and
|
||||
//! it is enforced by construction: this crate has no `std::process::Command`
|
||||
//! and no write-side `std::fs`.
|
||||
|
||||
use qbz_audio::{AudioStackHealth, Distro, InitSystem, Sandbox};
|
||||
|
||||
// ===========================================================================
|
||||
// Environment detection (thin re-exports so a frontend needs ONE dependency)
|
||||
// ===========================================================================
|
||||
|
||||
/// Distro dropdown options, in `Distro::ALL` order (index = the dropdown's).
|
||||
pub fn distro_options() -> Vec<String> {
|
||||
Distro::ALL.iter().map(|d| d.label().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Init-system dropdown options, in `InitSystem::ALL` order.
|
||||
pub fn init_options() -> Vec<String> {
|
||||
InitSystem::ALL
|
||||
.iter()
|
||||
.map(|i| i.label().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Auto-detected distro, as a dropdown index.
|
||||
pub fn detected_distro_index() -> i32 {
|
||||
qbz_audio::detect_distro().index() as i32
|
||||
}
|
||||
|
||||
/// Auto-detected init system, as a dropdown index.
|
||||
pub fn detected_init_index() -> i32 {
|
||||
qbz_audio::detect_init().index() as i32
|
||||
}
|
||||
|
||||
/// `("Flatpak" | "Snap" | "")` — empty when the process is not sandboxed.
|
||||
pub fn sandbox_name() -> &'static str {
|
||||
match qbz_audio::detect_sandbox() {
|
||||
Sandbox::Flatpak => "Flatpak",
|
||||
Sandbox::Snap => "Snap",
|
||||
Sandbox::None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking audio-stack probe (shells out through `qbz_audio`; call it off the
|
||||
/// UI thread).
|
||||
pub fn probe_health() -> AudioStackHealth {
|
||||
qbz_audio::audio_stack_health()
|
||||
}
|
||||
|
||||
fn distro_at(index: i32) -> Distro {
|
||||
Distro::ALL
|
||||
.get(index.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(Distro::Other)
|
||||
}
|
||||
|
||||
fn init_at(index: i32) -> InitSystem {
|
||||
InitSystem::ALL
|
||||
.get(index.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(InitSystem::Unknown)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Check step — health verdict + per-distro remediations
|
||||
// ===========================================================================
|
||||
|
||||
/// One "you're missing X" fix: a caption and a copy-paste command.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Remediation {
|
||||
pub caption: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Everything the check step renders, for one (health, distro, init) triple.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CheckView {
|
||||
pub health_ok: bool,
|
||||
/// Empty in a sandbox — there is no verdict to give, only reference
|
||||
/// commands (the UI shows its sandbox banner instead).
|
||||
pub summary: String,
|
||||
pub remediations: Vec<Remediation>,
|
||||
}
|
||||
|
||||
/// Build the check step from a completed probe and the two dropdown indices
|
||||
/// (either of which the user may have overridden).
|
||||
///
|
||||
/// In a sandbox the host probes are blind, so no verdict is rendered — the
|
||||
/// caller gets reference setup commands for the chosen distro/init instead
|
||||
/// (Tauri-style, which never probed either).
|
||||
pub fn check_view(
|
||||
health: AudioStackHealth,
|
||||
distro_index: i32,
|
||||
init_index: i32,
|
||||
sandboxed: bool,
|
||||
) -> CheckView {
|
||||
let distro = distro_at(distro_index);
|
||||
let init = init_at(init_index);
|
||||
|
||||
if sandboxed {
|
||||
return CheckView {
|
||||
health_ok: false,
|
||||
summary: String::new(),
|
||||
remediations: reference_commands(distro, init),
|
||||
};
|
||||
}
|
||||
|
||||
let rows = remediations(health, distro, init);
|
||||
let ok = health.is_ready();
|
||||
let summary = if ok {
|
||||
qbz_i18n::t("Your audio stack is ready for bit-perfect playback.")
|
||||
} else {
|
||||
let n = rows.len();
|
||||
qbz_i18n::tf(
|
||||
"{} item needs attention before bit-perfect playback will work.",
|
||||
"{} items need attention before bit-perfect playback will work.",
|
||||
n as i64,
|
||||
&[&n.to_string()],
|
||||
)
|
||||
};
|
||||
CheckView {
|
||||
health_ok: ok,
|
||||
summary,
|
||||
remediations: rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// (caption, copy-paste command) per missing probe, for the given distro.
|
||||
///
|
||||
/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo,
|
||||
/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector
|
||||
/// `restartCommands`. Installs and the restart are kept as separate blocks so
|
||||
/// the multi-line Gentoo guidance never gets `&&`-joined.
|
||||
fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec<Remediation> {
|
||||
// NixOS is declarative: you don't `apt/pacman install` pieces — you enable
|
||||
// the PipeWire module and rebuild. So collapse all the missing pieces into
|
||||
// one config block instead of per-package commands.
|
||||
if d == Distro::NixOS {
|
||||
if h.is_ready() {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![Remediation {
|
||||
caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
command: NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut needs_restart = false;
|
||||
if !h.has_pw_dump {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the PipeWire tools (pw-dump)"),
|
||||
command: install(d, pkg_pw_tools(d)),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.cpal_sees_pipewire {
|
||||
// THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM.
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"),
|
||||
command: install(d, "pipewire-alsa"),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.has_pactl {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"),
|
||||
command: install(d, pkg_pulse(d)),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.any_devices {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t(
|
||||
"No sinks detected — reinstall the ALSA UCM profiles, then reboot",
|
||||
),
|
||||
command: install_reinstall(d, "alsa-ucm-conf"),
|
||||
});
|
||||
}
|
||||
// WirePlumber down, or we just installed something → (re)start the stack
|
||||
// with the ACTUAL init system running on this machine (not guessed from the
|
||||
// distro — Gentoo+systemd and Gentoo+OpenRC must differ).
|
||||
if !h.wireplumber_active || needs_restart {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
command: restart_cmd(init).to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Init-system-aware "(re)start the audio services" command. PipeWire is a
|
||||
/// user-session service, so only systemd has a first-class `--user` restart;
|
||||
/// the others either use their user-service supervisor or a re-login.
|
||||
fn restart_cmd(init: InitSystem) -> &'static str {
|
||||
match init {
|
||||
InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber",
|
||||
InitSystem::OpenRc => {
|
||||
"# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\
|
||||
# Log out and back in to restart it."
|
||||
}
|
||||
InitSystem::Runit => {
|
||||
"sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in"
|
||||
}
|
||||
InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in",
|
||||
InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in",
|
||||
InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in",
|
||||
}
|
||||
}
|
||||
|
||||
/// The restart command for a dropdown index — the review step's step 3.
|
||||
pub fn restart_command_for(init_index: i32) -> String {
|
||||
restart_cmd(init_at(init_index)).to_string()
|
||||
}
|
||||
|
||||
fn pkg_pw_tools(d: Distro) -> &'static str {
|
||||
match d {
|
||||
// Debian-family (incl. antiX) ship pw-* in pipewire-bin.
|
||||
Distro::Debian | Distro::Antix => "pipewire-bin",
|
||||
Distro::Fedora => "pipewire-utils",
|
||||
// Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire.
|
||||
_ => "pipewire",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pulse(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils",
|
||||
Distro::Fedora => "pipewire-pulseaudio",
|
||||
_ => "pipewire-pulse",
|
||||
}
|
||||
}
|
||||
|
||||
fn install(d: Distro, pkgs: &str) -> String {
|
||||
match d {
|
||||
// Package manager is a property of the distro family, NOT the init.
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"),
|
||||
Distro::Fedora => format!("sudo dnf install {pkgs}"),
|
||||
Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"),
|
||||
Distro::OpenSuse => format!("sudo zypper install {pkgs}"),
|
||||
Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"),
|
||||
Distro::Void => format!("sudo xbps-install -S {pkgs}"),
|
||||
// NixOS is special-cased in remediations(); this is an unreached fallback.
|
||||
Distro::NixOS => {
|
||||
format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}")
|
||||
}
|
||||
Distro::Other => format!("Install with your package manager: {pkgs}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_reinstall(d: Distro, pkg: &str) -> String {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"),
|
||||
Distro::Fedora => format!("sudo dnf reinstall {pkg}"),
|
||||
_ => install(d, pkg),
|
||||
}
|
||||
}
|
||||
|
||||
const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\
|
||||
services.pipewire = {\n\
|
||||
\u{20}\u{20}enable = true;\n\
|
||||
\u{20}\u{20}alsa.enable = true;\n\
|
||||
\u{20}\u{20}pulse.enable = true;\n\
|
||||
\u{20}\u{20}wireplumber.enable = true;\n\
|
||||
};\n\
|
||||
# then apply:\n\
|
||||
sudo nixos-rebuild switch";
|
||||
|
||||
/// Full reference setup commands for the chosen distro/init, shown when QBZ
|
||||
/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which
|
||||
/// always showed per-distro install + restart commands (no probing).
|
||||
fn reference_commands(d: Distro, init: InitSystem) -> Vec<Remediation> {
|
||||
if d == Distro::NixOS {
|
||||
return vec![Remediation {
|
||||
caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
command: NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
}];
|
||||
}
|
||||
vec![
|
||||
Remediation {
|
||||
caption: qbz_i18n::t("Install the PipeWire audio stack"),
|
||||
command: install(d, full_stack_pkgs(d)),
|
||||
},
|
||||
Remediation {
|
||||
caption: qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
command: restart_cmd(init).to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The full recommended package set (incl. `pipewire-alsa`, the bit the old
|
||||
/// Tauri list omitted — the cause of the Ubuntu empty-list bug).
|
||||
fn full_stack_pkgs(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Arch | Distro::Artix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils",
|
||||
Distro::Void => "pipewire wireplumber alsa-utils",
|
||||
Distro::NixOS => "",
|
||||
Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils",
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Select-DACs step — enumeration + the manual escape hatch
|
||||
// ===========================================================================
|
||||
|
||||
/// One enumerated sink the user can pick to configure.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DacCandidate {
|
||||
/// PipeWire `node.name` — the value the manual escape hatch asks for.
|
||||
pub id: String,
|
||||
/// Pretty name, e.g. "DacMagic Plus Analog Stereo".
|
||||
pub description: String,
|
||||
/// "usb" | "pci" | "bluetooth" | ""
|
||||
pub bus: String,
|
||||
pub is_default: bool,
|
||||
/// hardware && bus ∈ {usb,pci} → pre-selected.
|
||||
pub looks_like_dac: bool,
|
||||
/// Pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz".
|
||||
pub rates_label: String,
|
||||
}
|
||||
|
||||
/// Enumerate sinks via the pw-dump-robust path and probe rates for the likely
|
||||
/// DACs. BLOCKING — call it off the UI thread.
|
||||
pub fn detect_blocking() -> Vec<DacCandidate> {
|
||||
let devices = qbz_audio::backend::BackendManager::create_backend(
|
||||
qbz_audio::backend::AudioBackendType::PipeWire,
|
||||
)
|
||||
.and_then(|b| b.enumerate_devices())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for d in devices {
|
||||
let bus = d.device_bus.unwrap_or_default();
|
||||
let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci");
|
||||
// Only probe rates for likely DACs (skip virtual/monitor sinks).
|
||||
let rates_label = if looks_like_dac {
|
||||
format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let description = if d.name.is_empty() {
|
||||
d.id.clone()
|
||||
} else {
|
||||
d.name
|
||||
};
|
||||
out.push(DacCandidate {
|
||||
id: d.id,
|
||||
description,
|
||||
bus,
|
||||
is_default: d.is_default,
|
||||
looks_like_dac,
|
||||
rates_label,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a manually-pasted `node.name`. 1:1 with the Tauri
|
||||
/// `validateNodeName`.
|
||||
pub fn validate_node_name(name: &str) -> bool {
|
||||
let t = name.trim();
|
||||
!t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input"))
|
||||
}
|
||||
|
||||
/// Classify a pasted node name. 1:1 with the Tauri `detectDacType`.
|
||||
pub fn detect_dac_type(name: &str) -> &'static str {
|
||||
let l = name.to_lowercase();
|
||||
if l.contains("usb-") || l.contains(".usb") {
|
||||
"usb"
|
||||
} else if l.contains("pci-") || l.contains(".pci") {
|
||||
"pci"
|
||||
} else if l.contains("bluez") || l.contains("bluetooth") {
|
||||
"bluetooth"
|
||||
} else if l.contains("virtual") || l.contains("null") || l.contains("dummy") {
|
||||
"virtual"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer).
|
||||
pub fn format_rates(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<String> = rates
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r % 1000 == 0 {
|
||||
format!("{}", r / 1000)
|
||||
} else {
|
||||
format!("{:.1}", r as f64 / 1000.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{} kHz", parts.join(" / "))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Test step — the four curated tracks + the N6 read-back
|
||||
// ===========================================================================
|
||||
|
||||
/// One curated test track (owner-provided). Resolved by id-hint first, then by
|
||||
/// "artist title" search if the id 404s (a pulled license) — never raw-id-only.
|
||||
pub struct TestSeed {
|
||||
pub depth: u32,
|
||||
pub rate: f64,
|
||||
pub id_hint: u64,
|
||||
pub artist: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
|
||||
pub const TEST_SEEDS: [TestSeed; 4] = [
|
||||
TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" },
|
||||
TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" },
|
||||
TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" },
|
||||
TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" },
|
||||
];
|
||||
|
||||
/// True if a resolved track matches this seed's family (rate + bit depth — the
|
||||
/// two 44.1 seeds only differ by depth).
|
||||
pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool {
|
||||
let rate_ok = track
|
||||
.maximum_sampling_rate
|
||||
.map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0)
|
||||
.unwrap_or(false);
|
||||
let depth_ok = track
|
||||
.maximum_bit_depth
|
||||
.map(|d| d == seed.depth)
|
||||
.unwrap_or(false);
|
||||
rate_ok && depth_ok
|
||||
}
|
||||
|
||||
/// The two read-back lines plus the truth signal, for one poll.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PollView {
|
||||
pub requested_label: String,
|
||||
pub negotiated_label: String,
|
||||
/// The DAC's real clock matches what QBZ asked for.
|
||||
pub rate_matched: bool,
|
||||
}
|
||||
|
||||
/// Format one poll: the rate QBZ requested vs the DAC's real negotiated rate
|
||||
/// (N6).
|
||||
pub fn poll_view(
|
||||
requested_rate: u32,
|
||||
requested_bits: u32,
|
||||
negotiated: Option<qbz_audio::NegotiatedRate>,
|
||||
) -> PollView {
|
||||
let requested_label = if requested_rate > 0 {
|
||||
qbz_i18n::t_args(
|
||||
"QBZ requesting {} · {}-bit",
|
||||
&[&khz(requested_rate), &requested_bits.to_string()],
|
||||
)
|
||||
} else {
|
||||
qbz_i18n::t("Nothing playing")
|
||||
};
|
||||
match negotiated {
|
||||
Some(n) => PollView {
|
||||
requested_label,
|
||||
// The DAC's REAL hardware params (N6): rate + ALSA container format
|
||||
// (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the
|
||||
// bit-perfect proof — exactly what the hardware is clocked at.
|
||||
negotiated_label: qbz_i18n::t_args(
|
||||
"DAC: {} · {} · {} ch",
|
||||
&[&khz(n.sample_rate), &n.format, &n.channels.to_string()],
|
||||
),
|
||||
rate_matched: requested_rate > 0 && n.sample_rate == requested_rate,
|
||||
},
|
||||
None => PollView {
|
||||
requested_label,
|
||||
negotiated_label: qbz_i18n::t("Waiting for the DAC to start playing…"),
|
||||
rate_matched: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn khz(hz: u32) -> String {
|
||||
if hz % 1000 == 0 {
|
||||
format!("{} kHz", hz / 1000)
|
||||
} else {
|
||||
format!("{:.1} kHz", hz as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Review-and-apply step — per-DAC config generation
|
||||
// ===========================================================================
|
||||
|
||||
/// One selected DAC's generated copy-paste config (read-only).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DacConfig {
|
||||
pub name: String,
|
||||
pub node_name: String,
|
||||
pub pipewire_conf: String,
|
||||
pub pulse_conf: String,
|
||||
pub wireplumber_conf: String,
|
||||
}
|
||||
|
||||
/// Re-probe rates + build the three config snippets per DAC. BLOCKING.
|
||||
pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec<DacConfig> {
|
||||
dacs.into_iter()
|
||||
.map(|(node_name, name)| {
|
||||
let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates;
|
||||
let short = short_name(&name, &node_name);
|
||||
DacConfig {
|
||||
pipewire_conf: pipewire_conf(&short, &rates),
|
||||
pulse_conf: pulse_conf(&short),
|
||||
wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name),
|
||||
name,
|
||||
node_name,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The three `~/.config/...` paths one generated config would create — the
|
||||
/// done step's "Config files you can create" list, in the same order the
|
||||
/// review step shows the snippets.
|
||||
pub fn created_paths(configs: &[DacConfig]) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for d in configs {
|
||||
let short = short_name(&d.name, &d.node_name);
|
||||
paths.push(format!(
|
||||
"~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf"
|
||||
));
|
||||
paths.push(format!(
|
||||
"~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf"
|
||||
));
|
||||
paths.push(format!(
|
||||
"~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf"
|
||||
));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\"";
|
||||
|
||||
/// A short, filename-safe DAC name: slug of the description, else the node.name.
|
||||
fn short_name(name: &str, node_name: &str) -> String {
|
||||
let slug = slugify(name);
|
||||
if !slug.is_empty() {
|
||||
return slug;
|
||||
}
|
||||
let nslug = slugify(node_name);
|
||||
if nslug.is_empty() {
|
||||
"dac".to_string()
|
||||
} else {
|
||||
nslug
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn rates_list(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
"44100 48000 88200 96000 176400 192000".to_string()
|
||||
} else {
|
||||
rates
|
||||
.iter()
|
||||
.map(|r| r.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn pipewire_conf(short: &str, rates: &[u32]) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Sample Rate Switching".to_string(),
|
||||
"context.properties = {".to_string(),
|
||||
format!(" default.clock.allowed-rates = [ {rates} ]"),
|
||||
"}".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn pulse_conf(short: &str) -> String {
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/client.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Per-App Bit-Perfect".to_string(),
|
||||
"stream.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
" { application.process.binary = \"qbz\" }".to_string(),
|
||||
" { application.name = \"PipeWire ALSA [qbz]\" }".to_string(),
|
||||
" ]".to_string(),
|
||||
" actions = { update-props = { resample.disable = true, channelmix.disable = true } }"
|
||||
.to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(),
|
||||
format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
format!("# QBZ DAC Setup - {description}"),
|
||||
"monitor.alsa.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"),
|
||||
" ]".to_string(),
|
||||
" actions = {".to_string(),
|
||||
" update-props = {".to_string(),
|
||||
format!(" audio.allowed-rates = [ {rates} ]"),
|
||||
" resample.disable = true".to_string(),
|
||||
" channelmix.disable = true".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_node_names_like_tauri() {
|
||||
assert!(validate_node_name(
|
||||
"alsa_output.usb-Cambridge-00.analog-stereo"
|
||||
));
|
||||
assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo"));
|
||||
assert!(!validate_node_name(""));
|
||||
assert!(!validate_node_name(" "));
|
||||
assert!(!validate_node_name("bluez_output.AA_BB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dac_type() {
|
||||
assert_eq!(
|
||||
detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"),
|
||||
"usb"
|
||||
);
|
||||
assert_eq!(
|
||||
detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"),
|
||||
"pci"
|
||||
);
|
||||
assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth");
|
||||
assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual");
|
||||
assert_eq!(detect_dac_type("something.else"), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_rates_khz() {
|
||||
assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz");
|
||||
assert_eq!(format_rates(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugifies_descriptions() {
|
||||
assert_eq!(
|
||||
slugify("DacMagic Plus Analog Stereo"),
|
||||
"dacmagic-plus-analog-stereo"
|
||||
);
|
||||
assert_eq!(
|
||||
slugify("Built-in Audio Analog Stereo"),
|
||||
"built-in-audio-analog-stereo"
|
||||
);
|
||||
assert_eq!(slugify(" weird__name!! "), "weird-name");
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireplumber_conf_pins_node_and_rates() {
|
||||
let c = wireplumber_conf(
|
||||
"dacmagic",
|
||||
"alsa_output.usb-x.analog-stereo",
|
||||
&[44100, 192000],
|
||||
"DacMagic",
|
||||
);
|
||||
assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\""));
|
||||
assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]"));
|
||||
assert!(c.contains("99-qbz-dac-dacmagic.conf"));
|
||||
assert!(c.contains("resample.disable = true"));
|
||||
}
|
||||
|
||||
/// The done step's path list must line up with the snippets the review step
|
||||
/// shows — three paths per DAC, in the pipewire / client / wireplumber
|
||||
/// order the accordions use.
|
||||
#[test]
|
||||
fn created_paths_are_three_per_dac_in_order() {
|
||||
let cfg = DacConfig {
|
||||
name: "DacMagic Plus".to_string(),
|
||||
node_name: "alsa_output.usb-x.analog-stereo".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let paths = created_paths(&[cfg]);
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert!(paths[0].contains("pipewire.conf.d/99-qbz-dac-dacmagic-plus.conf"));
|
||||
assert!(paths[1].contains("client.conf.d/99-qbz-bitperfect-dacmagic-plus.conf"));
|
||||
assert!(paths[2].contains("wireplumber.conf.d/99-qbz-dac-dacmagic-plus.conf"));
|
||||
}
|
||||
|
||||
/// A sandbox has no verdict to give: no summary, and the rows are the
|
||||
/// reference install + restart rather than probe-derived fixes.
|
||||
#[test]
|
||||
fn sandbox_renders_reference_commands_and_no_verdict() {
|
||||
// A fully-healthy probe, so the assertion cannot pass by accident: in
|
||||
// a sandbox the verdict is suppressed REGARDLESS of what was probed.
|
||||
let health = AudioStackHealth {
|
||||
wireplumber_active: true,
|
||||
has_pw_dump: true,
|
||||
cpal_sees_pipewire: true,
|
||||
has_pactl: true,
|
||||
any_devices: true,
|
||||
};
|
||||
let view = check_view(health, 0, 0, true);
|
||||
assert!(view.summary.is_empty());
|
||||
assert!(!view.health_ok);
|
||||
assert_eq!(view.remediations.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user