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:
vicrodh committed 2026-08-11 11:26:50 -06:00
1 parent 79e3cdc46b
commit 8ba9483462
28 files changed
+3983 -3

No files matched your search

+10
View File
@@ -6359,6 +6359,15 @@ dependencies = [
"slint",
]
[[package]]
name = "qbz-dac-wizard-core"
version = "2.0.2"
dependencies = [
"qbz-audio",
"qbz-i18n",
"qbz-models",
]
[[package]]
name = "qbz-dsd"
version = "2.0.2"
@@ -6634,6 +6643,7 @@ dependencies = [
"qbz-cast",
"qbz-core",
"qbz-credentials",
"qbz-dac-wizard-core",
"qbz-external-reco",
"qbz-i18n",
"qbz-integrations",
+8
View File
@@ -24,6 +24,14 @@ members = [
"qconnect-transport-ws",
"qconnect-app",
"qbz-ui",
# The HiFi Wizard's frontend-agnostic core (ADR-006), extracted for the Qt
# port. `qbz-dac-wizard` is the SLINT one and is deliberately NOT wired to
# this crate: the Slint tree is frozen (owner, 2026-08-11 — "ya no hacemos
# cambios para Slint"), and rewiring it would have been a change to a tree
# nobody compiles or tests any more. The two therefore hold the same logic
# twice; that is safe only BECAUSE the Slint side is frozen, and it is the
# reason a fix here never needs porting back.
"qbz-dac-wizard-core",
"qbz-dac-wizard",
"qbz-text-utils",
"qbz-slint-common",
+19
View File
@@ -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" }
+774
View File
@@ -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);
}
}
+4
View File
@@ -55,6 +55,10 @@ arboard = { version = "3", features = ["wayland-data-control"] }
qbz-core = { path = "../qbz-core" }
qbz-app = { path = "../qbz-app" }
qbz-audio = { path = "../qbz-audio" }
# The HiFi Wizard's frontend-agnostic core. NOT `qbz-dac-wizard` — that one is
# the Slint adapter and pulls in qbz-ui + slint, the ~1.6 M-line generated
# module this whole track exists to stay out of.
qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
+3 -1
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -67,6 +67,7 @@ fn main() {
"qml/cards/TrackCard.qml",
"qml/controls/AddToMixtapeModal.qml",
"qml/controls/CardMenu.qml",
"qml/controls/CommandBlock.qml",
"qml/controls/CardOverlayButton.qml",
"qml/controls/CardOverlayRow.qml",
"qml/controls/FolderEditPanel.qml",
@@ -244,6 +245,7 @@ fn main() {
"qml/controls/WarningBanner.qml",
"qml/settings/AudioSettings.qml",
"qml/settings/BlacklistSettings.qml",
"qml/settings/DacWizardModal.qml",
"qml/settings/DeveloperSettings.qml",
"qml/settings/LibFolderEditModal.qml",
"qml/settings/LibraryFolderTable.qml",
+8
View File
@@ -273,6 +273,14 @@ ApplicationWindow {
// importer makes (the whole progress panel, the log, the summary) is
// dropped on the floor with nothing logged on either side.
QbzPlaylistImport.boot()
// HiFi Wizard. Nothing to seed — the modal is closed until the
// Settings > Audio row opens it — but without this line every publish
// the wizard makes is dropped on the floor: the health verdict, the
// enumerated DACs, the generated config and the live read-back would
// all stay at their defaults, and nothing would be logged on either
// side. The wizard is the loudest case after QbzBlacklist, because its
// first step ends on "Checking your audio stack…" forever.
QbzDacWizard.boot()
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted
// like every other domain singleton, after QbzSession: `boot()` is what
// registers the Qt-thread hop, and without it `mini_bridge::ui()` is a
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#4285f4" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#4285f4" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#4285f4" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#4285f4" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#4285f4" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#4285f4" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#4285f4" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#4285f4" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#000000" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#000000" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#000000" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#000000" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#000000" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#000000" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#000000" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#000000" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#888888" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#888888" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#888888" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#888888" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#888888" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#888888" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#888888" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#888888" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#ffffff" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#ffffff" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#ffffff" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#ffffff" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#ffffff" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#ffffff" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#ffffff" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#ffffff" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#cccccc" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#cccccc" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#cccccc" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#cccccc" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#cccccc" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#cccccc" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#cccccc" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#cccccc" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
<g>
<path fill="#fbbf24" d="
M 44.00 5.56
Q 44.18 5.56 44.24 5.52
C 48.35 3.31 54.84 4.30 59.34 6.39
Q 73.67 13.03 75.28 28.51
Q 77.00 45.04 81.97 93.69
A 1.92 1.92 0.0 0 1 80.24 95.80
L 80.13 95.81
A 1.95 1.95 0.0 0 1 78.01 94.07
Q 74.00 55.61 71.84 33.50
Q 70.90 23.96 68.99 20.35
C 64.91 12.62 56.73 7.78 47.81 8.34
A 2.80 2.79 -61.0 0 0 46.66 8.67
C 45.87 9.10 44.88 9.96 44.00 9.96
C 43.12 9.96 42.13 9.10 41.34 8.67
A 2.80 2.79 61.0 0 0 40.19 8.34
C 31.27 7.78 23.09 12.62 19.01 20.35
Q 17.10 23.96 16.16 33.50
Q 14.00 55.61 9.98 94.07
A 1.95 1.95 0.0 0 1 7.86 95.81
L 7.75 95.80
A 1.92 1.92 0.0 0 1 6.02 93.69
Q 11.00 45.04 12.72 28.51
Q 14.33 13.03 28.66 6.39
C 33.16 4.30 39.65 3.31 43.76 5.52
Q 43.82 5.56 44.00 5.56
Z"
/>
<path fill="#fbbf24" d="
M 88.00 4.33
Q 88.20 4.33 88.27 4.44
Q 97.14 18.60 97.64 19.43
A 1.75 1.67 31.1 0 1 97.89 20.35
Q 97.86 27.45 95.17 40.45
Q 93.80 47.13 90.33 51.14
A 1.37 1.34 65.9 0 0 90.00 52.03
L 90.00 95.35
A 0.65 0.65 0.0 0 1 89.35 96.00
Q 89.05 96.00 88.00 96.00
Q 86.95 96.00 86.65 96.00
A 0.65 0.65 0.0 0 1 86.00 95.35
L 86.00 52.03
A 1.37 1.34 -65.9 0 0 85.67 51.14
Q 82.19 47.13 80.82 40.45
Q 78.13 27.45 78.10 20.35
A 1.75 1.67 -31.1 0 1 78.35 19.43
Q 78.85 18.60 87.72 4.44
Q 87.79 4.33 88.00 4.33
Z
M 87.99 16.37
Q 88.16 16.37 88.95 13.91
A 1.31 1.31 0.0 0 0 88.90 12.99
Q 88.43 11.92 88.00 11.92
Q 87.58 11.92 87.10 12.98
A 1.31 1.31 0.0 0 0 87.05 13.91
Q 87.82 16.37 87.99 16.37
Z
M 83.74 18.25
L 82.28 20.78
A 1.66 1.66 0.0 0 0 82.07 21.79
L 83.34 33.00
A 0.20 0.20 0.0 0 0 83.71 33.07
Q 87.68 25.64 84.59 18.31
A 0.48 0.47 48.8 0 0 83.74 18.25
Z
M 92.26 18.20
A 0.48 0.48 0.0 0 0 91.40 18.26
Q 88.31 25.62 92.30 33.08
A 0.20 0.20 0.0 0 0 92.67 33.01
L 93.94 21.75
A 1.67 1.66 -56.9 0 0 93.73 20.74
L 92.26 18.20
Z
M 88.00 47.62
Q 88.34 47.62 89.14 46.13
Q 89.90 44.71 91.10 40.33
A 1.08 1.06 -51.2 0 0 91.03 39.62
L 88.14 33.79
A 0.09 0.08 79.5 0 0 88.07 33.73
Q 88.03 33.73 88.00 33.73
Q 87.97 33.73 87.94 33.73
A 0.09 0.08 -79.5 0 0 87.87 33.79
L 84.97 39.62
A 1.08 1.06 51.3 0 0 84.90 40.33
Q 86.09 44.71 86.85 46.13
Q 87.65 47.62 88.00 47.62
Z"
/>
<path fill="#fbbf24" d="
M 44.00 24.65
Q 44.18 24.65 44.65 24.16
C 52.89 15.80 65.71 21.08 65.96 32.76
Q 66.04 36.51 65.99 92.81
A 2.01 2.01 0.0 0 1 64.05 94.82
L 63.97 94.82
A 1.91 1.90 -0.9 0 1 62.00 92.92
L 62.00 65.35
A 1.24 1.20 -67.8 0 0 61.66 64.50
Q 57.89 60.53 53.58 58.45
A 0.75 0.75 0.0 0 0 52.70 58.61
C 50.31 61.10 47.59 61.96 44.00 61.96
C 40.41 61.96 37.69 61.10 35.30 58.61
A 0.75 0.75 0.0 0 0 34.42 58.45
Q 30.11 60.53 26.34 64.50
A 1.24 1.20 67.8 0 0 26.00 65.35
L 26.00 92.92
A 1.91 1.90 0.9 0 1 24.03 94.82
L 23.95 94.82
A 2.01 2.01 0.0 0 1 22.01 92.81
Q 21.96 36.51 22.04 32.76
C 22.28 21.08 35.10 15.80 43.34 24.16
Q 43.82 24.65 44.00 24.65
Z
M 44.00 57.96
C 46.12 57.96 49.49 57.46 50.07 55.04
Q 50.40 53.66 51.73 53.46
A 1.97 1.91 -38.5 0 1 52.66 53.55
Q 57.64 55.37 61.44 58.95
A 0.32 0.32 0.0 0 0 61.98 58.71
Q 62.04 40.89 61.97 32.78
C 61.91 25.07 54.00 21.48 47.99 26.16
C 46.66 27.20 45.76 29.97 43.99 29.97
C 42.23 29.97 41.33 27.20 39.99 26.16
C 33.98 21.49 26.07 25.08 26.02 32.79
Q 25.95 40.90 26.02 58.72
A 0.32 0.32 0.0 0 0 26.56 58.96
Q 30.36 55.38 35.34 53.56
A 1.97 1.91 38.5 0 1 36.27 53.46
Q 37.60 53.66 37.93 55.04
C 38.51 57.46 41.89 57.96 44.00 57.96
Z"
/>
<rect fill="#fbbf24" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
</g>
<g>
<path fill="#fbbf24" d="
M 35.01 33.16
C 36.76 33.18 40.51 34.06 39.85 36.58
A 1.92 1.90 21.6 0 1 37.08 37.76
Q 35.98 37.17 34.97 37.16
Q 33.95 37.14 32.84 37.72
A 1.92 1.90 -20.3 0 1 30.09 36.48
C 29.49 33.94 33.26 33.14 35.01 33.16
Z"
/>
<path fill="#fbbf24" d="
M 53.03 37.15
Q 52.02 37.17 50.91 37.76
A 1.92 1.90 -21.6 0 1 48.15 36.58
C 47.49 34.06 51.24 33.17 52.99 33.15
C 54.74 33.13 58.52 33.94 57.91 36.48
A 1.92 1.90 20.4 0 1 55.16 37.72
Q 54.05 37.14 53.03 37.15
Z"
/>
<circle fill="#fbbf24" cx="34.75" cy="42.20" r="2.27"/>
<circle fill="#fbbf24" cx="54.00" cy="42.00" r="1.96"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+142
View File
@@ -0,0 +1,142 @@
// CommandBlock — a copy-paste shell command / config snippet. QML port of
// crates/qbz-ui/ui/primitives/CommandBlock.slint.
//
// Surface-elevated card (radiusSm, no pills — ADR-008) with an optional
// caption, a monospace body that WRAPS, and a copy button that flips to a
// green check for 2 s. READ-ONLY: nothing here is ever executed. The height is
// content-sized, so a multi-line command grows the block; callers place it in
// a Column and never set a fixed height.
//
// THE CLIPBOARD. QML has no clipboard API of its own and this port has no
// clipboard invokable, so the copy rides an off-screen TextEdit's own
// `copy()` — the same carrier SandboxSettings.qml already uses. It is
// duplicated there rather than shared because that file's block is a
// different shape (a one-line row with a labelled "Copy" button); this one is
// the wizard's, with a caption above and an icon button pinned to the top.
//
// WHY THE ICON IS `accent` AND NOT `success`. The check glyph after a copy is
// green in the reference (a literal #3fae6a). `tintName: "success"` has NO qrc
// bake directory, so whenever the runtime tint set is unreachable the glyph
// would render as NOTHING at all rather than in a fallback colour
// (theme/QbzIcon.qml, "the one way a bake still silently lies"). The port
// already made this exact call once — WarningBanner.qml maps its `success`
// variant's glyph to the `accent` tint for the same reason — so this follows
// that precedent instead of inventing a one-glyph `success/` directory. The
// TEXT colours in this modal are unaffected: `theme.success` is #3fae6a
// verbatim, which is what the stepper dots and the read-back tick use.
import QtQuick
import "../theme"
Rectangle {
id: root
property string command: ""
// Optional caption rendered above the command (e.g. what it does).
property string caption: ""
property bool justCopied: false
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: col.implicitHeight + 24
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: col
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 8
Text {
visible: root.caption !== ""
width: parent.width
text: root.caption
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
Item {
width: parent.width
height: Math.max(cmdText.implicitHeight, 30)
// Monospace body — wraps, takes the width left by the button.
// WrapAnywhere, not WordWrap: these are shell lines and long paths
// with no spaces to break on, and WordWrap would push them out of
// the card instead of folding them.
Text {
id: cmdText
anchors.left: parent.left
anchors.right: copyBtn.left
anchors.rightMargin: 10
anchors.top: parent.top
text: root.command
color: theme.textPrimary
font.family: "monospace"
font.pixelSize: theme.fontLegal
wrapMode: Text.WrapAnywhere
}
// Off-screen carrier: TextEdit.copy() is the only clipboard write
// QML offers without a C++ seam.
TextEdit {
id: carrier
visible: false
text: root.command
}
// Copy button — fixed 30x30, pinned to the TOP of the row so a
// tall command does not centre it halfway down the block.
Rectangle {
id: copyBtn
anchors.right: parent.right
anchors.top: parent.top
width: 30
height: 30
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: copyTa.containsMouse ? theme.surfaceHover : theme.surfaceCard
QbzIcon {
anchors.centerIn: parent
width: 15
height: 15
name: root.justCopied ? "check" : "copy"
tintName: root.justCopied
? "accent"
: (copyTa.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: copyTa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
carrier.selectAll()
carrier.copy()
carrier.deselect()
root.justCopied = true
resetTimer.restart()
}
}
}
}
}
// Resets the "copied" check after 2s (reference: `reset-timer`, 2s).
Timer {
id: resetTimer
interval: 2000
repeat: false
onTriggered: root.justCopied = false
}
}
+28 -2
View File
@@ -9,8 +9,12 @@
// - "Detected device limit" + its fallback disclosure (#638): the device-cap
// probe (`device_cap.rs`) is Slint glue that was never ported, so there is
// no measured value to show.
// - "HiFi Wizard": the guided PipeWire setup (DacWizardActions) is a whole
// modal flow, not a settings row.
//
// The HiFi Wizard IS shipped (2026-08-11): the last row of OUTPUT opens
// settings/DacWizardModal.qml, mounted at the SettingsView root. Its logic
// lives in `qbz-dac-wizard-core`, the frontend-agnostic crate extracted for
// this port. Placement and backend gating diverge from the reference — see
// the row itself.
import QtQuick
import com.blitzfc.qbz
@@ -125,6 +129,28 @@ Column {
onSelected: function (i) { QbzBridge.settingsSelect("dsd-mode", i) }
}
}
// HiFi Wizard — guided bit-perfect DAC setup. The LAST row of OUTPUT,
// under "Output device", and shown for EVERY backend.
//
// TWO DIVERGENCES FROM THE REFERENCE, both owner rulings (2026-08-11):
// it lives in OUTPUT, not at the end of BIT-PERFECT; and it is NOT gated
// on `backend-is-pipewire` the way `AudioSettings.slint:282` gates it.
// The reference hides it off PipeWire because the config it generates is
// PipeWire/WirePlumber — but that is exactly who needs it: someone on ALSA
// direct who wants to set their DAC up cannot reach the wizard that would
// walk them through it. Do not "restore parity" by putting the gate back.
//
// Opening it resets the wizard and kicks the audio-stack probe off the UI
// thread, so there is nothing to arm here.
SettingRow {
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
SettingsButton {
iconName: "gandalf"
text: QbzSession.tr("Open Wizard", QbzSession.trRev)
onClicked: QbzDacWizard.open()
}
}
SettingsSpacer { }
SettingsDivider { }
File diff suppressed because it is too large. Load diff
@@ -328,4 +328,14 @@ Item {
// panel that opens it. The reference mounts its counterpart at the
// AppShell root for exactly the same reason (LibFolderEditModal.slint:5-8).
LibFolderEditModal { doc: root.doc }
// The HiFi Wizard, opened from Settings > Audio. Mounted here for the same
// reason as the two above — a modal inside the scrolled panel would be
// sized by the content and ride the scroll — and it reads its OWN document
// (QbzDacWizard), not the settings one, so it takes no `doc`.
//
// It fills the view rather than the window: Settings is the full content
// area, and the reference is an overlay inside the app shell too, not a
// separate window (DacWizardModal.slint:8-9).
DacWizardModal { }
}
+205
View File
@@ -0,0 +1,205 @@
//! QbzDacWizard — the HiFi Wizard's QML singleton.
//!
//! A singleton of its own rather than more surface on `QbzBridge`, for the same
//! reason `QbzFolderEdit` is separate: the wizard is one self-contained modal
//! with a document nobody else reads, and its ONE caller (the Settings > Audio
//! row) does not want the settings document republished on every wizard poll —
//! the read-back ticks every 1.5 s while the test plays.
//!
//! **No `#[qsignal]`.** Every outcome is a republish of `wizardJson`; the modal
//! renders from the document and keeps no copy of anything Rust owns
//! (`playlist_picker_bridge.rs:13`).
//!
//! The module is `qbz_dac_wizard_bridge` — a bridge module is never named after
//! a workspace crate (E0659, and `qbz-dac-wizard` IS one). The QML type name
//! comes from the QObject, so QML is unaffected.
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_dac_wizard_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
/// The whole wizard state (see `dac_wizard_qt::WizardDoc`). Parseable
/// default so QML's `JSON.parse` never throws on the pre-publish frame.
#[qproperty(QString, wizard_json)]
type QbzDacWizard = super::QbzDacWizardRust;
/// Registers this object's Qt-thread hop. Without it every publish
/// from Rust is dropped on the floor, silently and forever: the modal
/// mounts, no document ever arrives, and nothing is logged on either
/// side.
#[qinvokable]
fn boot(self: Pin<&mut QbzDacWizard>);
/// Settings > Audio > "Open Wizard": reset + seed the dropdowns, then
/// probe the audio stack off the UI thread.
#[qinvokable]
fn open(self: Pin<&mut QbzDacWizard>);
/// Scrim / header X / footer Close. Closing NEVER stops playback — the
/// test step deliberately keeps playing underneath (reference §modal
/// recipe: an overlay, not a window grab).
#[qinvokable]
fn close(self: Pin<&mut QbzDacWizard>);
/// Check step: the user overrode the distribution (package manager).
#[qinvokable]
fn set_distro(self: Pin<&mut QbzDacWizard>, index: i32);
/// Check step: the user overrode the init system (service commands).
#[qinvokable]
fn set_init(self: Pin<&mut QbzDacWizard>, index: i32);
/// Entering the DACs step — enumerate sinks off the UI thread.
#[qinvokable]
fn run_detect(self: Pin<&mut QbzDacWizard>);
/// Flip one enumerated candidate's checkbox.
#[qinvokable]
fn toggle_dac(self: Pin<&mut QbzDacWizard>, index: i32);
/// Validate a pasted `node.name` (the manual escape hatch).
#[qinvokable]
fn validate_manual(self: Pin<&mut QbzDacWizard>, text: QString);
/// Entering the review step — generate the per-DAC config snippets.
#[qinvokable]
fn gen_configs(self: Pin<&mut QbzDacWizard>);
/// Collapse/expand one DAC's generated-config accordion.
#[qinvokable]
fn toggle_config(self: Pin<&mut QbzDacWizard>, index: i32);
/// Test step: resolve the four curated tracks and play them.
#[qinvokable]
fn start_test(self: Pin<&mut QbzDacWizard>);
/// Test step: pause and drop the "playing" state.
#[qinvokable]
fn stop_test(self: Pin<&mut QbzDacWizard>);
/// Test step: jump straight to one of the four tracks.
#[qinvokable]
fn test_play_index(self: Pin<&mut QbzDacWizard>, index: i32);
/// Test step: run the read-back against the user's OWN queue.
#[qinvokable]
fn verify_own(self: Pin<&mut QbzDacWizard>);
/// One read-back tick (requested vs negotiated). Driven by the modal's
/// 1.5 s Timer while the test plays.
#[qinvokable]
fn poll_test(self: Pin<&mut QbzDacWizard>);
}
impl cxx_qt::Threading for QbzDacWizard {}
}
use qbz_dac_wizard_bridge::QbzDacWizard;
/// Rust side of the wizard bridge (plain storage; the real state lives in
/// `dac_wizard_qt`).
pub struct QbzDacWizardRust {
wizard_json: QString,
}
impl Default for QbzDacWizardRust {
fn default() -> Self {
Self {
wizard_json: QString::from(&crate::dac_wizard_qt::initial_json()),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzDacWizard>> = OnceLock::new();
/// Queue a wizard-document publish onto the Qt event loop (no-op before
/// `boot()` registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzDacWizard>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_dac_wizard_bridge::QbzDacWizard {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] dac wizard Qt thread already registered");
}
}
pub fn open(self: Pin<&mut Self>) {
crate::dac_wizard_qt::open();
}
pub fn close(self: Pin<&mut Self>) {
crate::dac_wizard_qt::close();
}
pub fn set_distro(self: Pin<&mut Self>, index: i32) {
crate::dac_wizard_qt::set_distro(index);
}
pub fn set_init(self: Pin<&mut Self>, index: i32) {
crate::dac_wizard_qt::set_init(index);
}
pub fn run_detect(self: Pin<&mut Self>) {
crate::dac_wizard_qt::run_detect();
}
pub fn toggle_dac(self: Pin<&mut Self>, index: i32) {
crate::dac_wizard_qt::toggle_dac(index);
}
pub fn validate_manual(self: Pin<&mut Self>, text: QString) {
crate::dac_wizard_qt::validate_manual(&text.to_string());
}
pub fn gen_configs(self: Pin<&mut Self>) {
crate::dac_wizard_qt::gen_configs();
}
pub fn toggle_config(self: Pin<&mut Self>, index: i32) {
crate::dac_wizard_qt::toggle_config(index);
}
pub fn start_test(self: Pin<&mut Self>) {
crate::dac_wizard_qt::start_test();
}
pub fn stop_test(self: Pin<&mut Self>) {
crate::dac_wizard_qt::stop_test();
}
pub fn test_play_index(self: Pin<&mut Self>, index: i32) {
crate::dac_wizard_qt::test_play_index(index);
}
pub fn verify_own(self: Pin<&mut Self>) {
crate::dac_wizard_qt::verify_own();
}
pub fn poll_test(self: Pin<&mut Self>) {
crate::dac_wizard_qt::poll_test();
}
}
+670
View File
@@ -0,0 +1,670 @@
//! HiFi Wizard (DAC setup) — the Qt controller.
//!
//! Port of `crates/qbz/src/main.rs:21613-21845` (the Slint glue) + the state
//! half of `primitives/DacWizardModal.slint`. Every COMPUTATION lives in
//! `qbz-dac-wizard-core`, which the Slint adapter also uses — the wizard emits
//! PipeWire/WirePlumber snippets the user pastes into their own system, so a
//! second implementation of that logic is the one divergence this port must
//! never create (ADR-006; the crate split landed with this file).
//!
//! READ-ONLY, like the reference: this never writes a system file and never
//! runs a command. It probes, it formats text, and on the test step it plays
//! music through the normal queue path.
//!
//! ## What Rust owns and what QML owns
//!
//! Rust owns everything it COMPUTES (the probe verdict, the candidate list and
//! its checkboxes, the manual node name + its validity, the generated configs
//! and their accordion state, the read-back labels) plus `open` — because the
//! Settings row opens the wizard and the reset + probe must be one atomic act.
//!
//! QML owns the pure NAVIGATION state: `step`, the welcome checkbox, the three
//! review progress checkboxes and the manual-entry disclosure. That is exactly
//! the reference's split — `DacWizardModal.slint` mutates those inline and
//! never calls Rust for them (`:817 step -= 1`, `:853 step += 1`). They reset
//! on `openSeq`, the counter this file bumps on every open.
//!
//! ## One document, one publish
//!
//! `QbzDacWizard.wizardJson` carries the whole state (the port's settings
//! pattern). Rust mutates `STATE` and republishes; QML re-renders. No control
//! keeps its own copy of anything Rust owns.
use std::sync::Mutex;
use qbz_dac_wizard_core as core;
use qbz_models::Track;
use serde::Serialize;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// One enumerated sink plus the user's checkbox. `checked` is Rust-owned so
/// `checked_dacs()` cannot disagree with what the user sees.
#[derive(Clone, Default, Serialize)]
struct Candidate {
id: String,
description: String,
bus: String,
#[serde(rename = "isDefault")]
is_default: bool,
#[serde(rename = "looksLikeDac")]
looks_like_dac: bool,
checked: bool,
#[serde(rename = "ratesLabel")]
rates_label: String,
}
/// One selected DAC's generated config + its accordion state.
#[derive(Clone, Default, Serialize)]
struct ConfigRow {
name: String,
#[serde(rename = "nodeName")]
node_name: String,
#[serde(rename = "pipewireConf")]
pipewire_conf: String,
#[serde(rename = "pulseConf")]
pulse_conf: String,
#[serde(rename = "wireplumberConf")]
wireplumber_conf: String,
expanded: bool,
}
#[derive(Clone, Default, Serialize)]
struct Remediation {
caption: String,
command: String,
}
#[derive(Clone, Default, Serialize)]
struct WizardDoc {
open: bool,
/// Bumped on every open — QML drops its navigation state when it changes
/// (the `PlaylistImportModal.resetSeq` convention).
#[serde(rename = "openSeq")]
open_seq: u32,
// check
#[serde(rename = "distroOptions")]
distro_options: Vec<String>,
#[serde(rename = "distroIndex")]
distro_index: i32,
#[serde(rename = "initOptions")]
init_options: Vec<String>,
#[serde(rename = "initIndex")]
init_index: i32,
#[serde(rename = "healthOk")]
health_ok: bool,
#[serde(rename = "healthSummary")]
health_summary: String,
remediations: Vec<Remediation>,
sandboxed: bool,
#[serde(rename = "sandboxName")]
sandbox_name: String,
// select-dacs
candidates: Vec<Candidate>,
detecting: bool,
#[serde(rename = "hasEnumeration")]
has_enumeration: bool,
#[serde(rename = "anyDacSelected")]
any_dac_selected: bool,
#[serde(rename = "manualNodeName")]
manual_node_name: String,
#[serde(rename = "manualValid")]
manual_valid: bool,
#[serde(rename = "manualDacType")]
manual_dac_type: String,
// test
#[serde(rename = "testRequestedLabel")]
test_requested_label: String,
#[serde(rename = "testNegotiatedLabel")]
test_negotiated_label: String,
#[serde(rename = "testRateMatched")]
test_rate_matched: bool,
#[serde(rename = "testPlaying")]
test_playing: bool,
/// The four curated test tracks, PRE-FORMATTED here so QML never builds a
/// label (the port's rule; also keeps the strings in one catalog).
#[serde(rename = "testTracks")]
test_tracks: Vec<String>,
// review + done
#[serde(rename = "dacConfigs")]
dac_configs: Vec<ConfigRow>,
#[serde(rename = "backupCmd")]
backup_cmd: String,
#[serde(rename = "restartCmd")]
restart_cmd: String,
#[serde(rename = "createdPaths")]
created_paths: Vec<String>,
}
/// Everything the wizard owns, including the pieces that are NOT in the
/// document: the cached probe (so a distro override recomputes without
/// re-shelling) and the resolved test tracks (so the user can jump between
/// them).
#[derive(Default)]
struct State {
doc: WizardDoc,
last_health: Option<qbz_audio::AudioStackHealth>,
test_tracks: Vec<Track>,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
fn with_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
let mut guard = STATE.lock().unwrap();
f(guard.get_or_insert_with(State::default))
}
/// Serialize + hand the document to the Qt thread.
fn publish() {
let json = with_state(|s| serde_json::to_string(&s.doc).unwrap_or_else(|_| "{}".to_string()));
crate::dac_wizard_bridge::ui(move |mut b| {
b.as_mut().set_wizard_json(cxx_qt_lib::QString::from(&json));
});
}
/// The document as QML first sees it — closed, and parseable so a binding that
/// reads `doc.open` on frame 1 cannot throw.
pub fn initial_json() -> String {
serde_json::to_string(&WizardDoc {
has_enumeration: true,
..Default::default()
})
.unwrap_or_else(|_| "{\"open\":false}".to_string())
}
// ---------------------------------------------------------------------------
// Open / close
// ---------------------------------------------------------------------------
/// Settings > Audio > "Open Wizard". Resets the whole wizard, seeds the two
/// dropdowns from auto-detection, shows the "checking…" placeholder, then runs
/// the (shelling) health probe off the UI thread.
///
/// 1:1 with `dac_wizard::open_immediate` + the `spawn_blocking` probe that
/// follows it in the reference's `on_open`.
pub fn open() {
with_state(|s| {
let seq = s.doc.open_seq.wrapping_add(1);
let sandbox = core::sandbox_name();
s.last_health = None;
s.test_tracks.clear();
s.doc = WizardDoc {
open: true,
open_seq: seq,
distro_options: core::distro_options(),
distro_index: core::detected_distro_index(),
init_options: core::init_options(),
init_index: core::detected_init_index(),
sandboxed: !sandbox.is_empty(),
sandbox_name: sandbox.to_string(),
health_summary: qbz_i18n::t("Checking your audio stack…"),
has_enumeration: true,
test_tracks: vec![
qbz_i18n::t("16-bit / 44.1 kHz — George Harrison · My Sweet Lord"),
qbz_i18n::t("24-bit / 44.1 kHz — Billie Eilish · LUNCH"),
qbz_i18n::t("24-bit / 96 kHz — Iron Maiden · Stratego"),
qbz_i18n::t("24-bit / 192 kHz — Toto · Africa"),
],
..Default::default()
};
});
publish();
log::info!("[qbz-qt] dac wizard opened (probing the audio stack)");
// The probe shells out (systemctl, aplay, pw-dump); never on the UI thread.
crate::spawn(async move {
let health = tokio::task::spawn_blocking(core::probe_health)
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] dac wizard: health probe panicked: {e}");
qbz_audio::AudioStackHealth {
wireplumber_active: false,
has_pw_dump: false,
cpal_sees_pipewire: false,
has_pactl: false,
any_devices: false,
}
});
with_state(|s| s.last_health = Some(health));
recompute_check();
let (ok, n) = with_state(|s| (s.doc.health_ok, s.doc.remediations.len()));
log::info!("[qbz-qt] dac wizard health: ok={ok} remediations={n}");
});
}
/// Close. Leaves the document otherwise intact — reopening resets it anyway,
/// and a close mid-test must not silently stop the music (the reference's
/// scrim/X close nothing but the overlay).
pub fn close() {
with_state(|s| s.doc.open = false);
publish();
}
// ---------------------------------------------------------------------------
// Check step
// ---------------------------------------------------------------------------
/// User overrode the distro (package manager) — recompute the commands.
pub fn set_distro(index: i32) {
with_state(|s| s.doc.distro_index = index);
recompute_check();
}
/// User overrode the init system (service commands) — recompute.
pub fn set_init(index: i32) {
with_state(|s| s.doc.init_index = index);
recompute_check();
// The review step's step-3 command is init-aware too; keep it honest if the
// user goes back and changes the init AFTER generating the configs.
let restart = with_state(|s| core::restart_command_for(s.doc.init_index));
let changed = with_state(|s| {
if s.doc.restart_cmd.is_empty() || s.doc.restart_cmd == restart {
false
} else {
s.doc.restart_cmd = restart;
true
}
});
if changed {
publish();
}
}
/// Rebuild the check step from the cached probe + the current dropdowns. Uses
/// the cache so a dropdown change never re-shells (reference `recompute`).
fn recompute_check() {
with_state(|s| {
let health = s.last_health.unwrap_or_else(core::probe_health);
let view = core::check_view(health, s.doc.distro_index, s.doc.init_index, s.doc.sandboxed);
s.doc.health_ok = view.health_ok;
s.doc.health_summary = view.summary;
s.doc.remediations = view
.remediations
.into_iter()
.map(|r| Remediation {
caption: r.caption,
command: r.command,
})
.collect();
});
publish();
}
// ---------------------------------------------------------------------------
// Select-DACs step
// ---------------------------------------------------------------------------
/// Entering the DACs step: show "detecting…", then enumerate off the UI thread.
/// Pre-selects the likely DACs; an empty enumeration flips `hasEnumeration` off
/// so the manual escape hatch opens unconditionally.
pub fn run_detect() {
with_state(|s| s.doc.detecting = true);
publish();
crate::spawn(async move {
let data = tokio::task::spawn_blocking(core::detect_blocking)
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] dac wizard: enumeration panicked: {e}");
Vec::new()
});
with_state(|s| {
let rows: Vec<Candidate> = data
.iter()
.map(|d| Candidate {
id: d.id.clone(),
description: d.description.clone(),
bus: d.bus.clone(),
is_default: d.is_default,
looks_like_dac: d.looks_like_dac,
checked: d.looks_like_dac,
rates_label: d.rates_label.clone(),
})
.collect();
s.doc.any_dac_selected = rows.iter().any(|r| r.checked);
s.doc.has_enumeration = !data.is_empty();
s.doc.candidates = rows;
s.doc.detecting = false;
});
publish();
});
}
/// Flip one candidate's checkbox + recompute the Next gate.
pub fn toggle_dac(index: i32) {
with_state(|s| {
if let Some(row) = s.doc.candidates.get_mut(index.max(0) as usize) {
row.checked = !row.checked;
}
s.doc.any_dac_selected = s.doc.candidates.iter().any(|r| r.checked);
});
publish();
}
/// Validate a manually-pasted `node.name` (escape hatch).
///
/// The typed text is stored here because `checked_dacs()` reads it, but QML
/// keeps its own field text and never re-reads this back — republishing into a
/// focused TextInput is how a cursor ends up jumping to the end mid-word.
pub fn validate_manual(text: &str) {
with_state(|s| {
s.doc.manual_node_name = text.to_string();
s.doc.manual_valid = core::validate_node_name(text);
s.doc.manual_dac_type = core::detect_dac_type(text).to_string();
});
publish();
}
/// (node_name, display_name) for every checked candidate, or a valid manual one.
fn checked_dacs() -> Vec<(String, String)> {
with_state(|s| {
let mut out: Vec<(String, String)> = s
.doc
.candidates
.iter()
.filter(|c| c.checked)
.map(|c| (c.id.clone(), c.description.clone()))
.collect();
if out.is_empty() {
let manual = s.doc.manual_node_name.trim().to_string();
if !manual.is_empty() && s.doc.manual_valid {
out.push((manual.clone(), manual));
}
}
out
})
}
// ---------------------------------------------------------------------------
// Review step
// ---------------------------------------------------------------------------
/// Entering the review step: re-probe rates and build the three snippets per
/// selected DAC, off the UI thread. One DAC → expanded; several → collapsed
/// accordions (reference `apply_configs`).
pub fn gen_configs() {
let dacs = checked_dacs();
crate::spawn(async move {
let data = tokio::task::spawn_blocking(move || core::gen_configs_blocking(dacs))
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] dac wizard: config generation panicked: {e}");
Vec::new()
});
with_state(|s| {
let single = data.len() == 1;
s.doc.created_paths = core::created_paths(&data);
s.doc.dac_configs = data
.into_iter()
.map(|d| ConfigRow {
name: d.name,
node_name: d.node_name,
pipewire_conf: d.pipewire_conf,
pulse_conf: d.pulse_conf,
wireplumber_conf: d.wireplumber_conf,
expanded: single,
})
.collect();
s.doc.backup_cmd = core::BACKUP_CMD.to_string();
s.doc.restart_cmd = core::restart_command_for(s.doc.init_index);
});
publish();
});
}
/// Collapse/expand one DAC's generated-config accordion.
pub fn toggle_config(index: i32) {
with_state(|s| {
if let Some(row) = s.doc.dac_configs.get_mut(index.max(0) as usize) {
row.expanded = !row.expanded;
}
});
publish();
}
// ---------------------------------------------------------------------------
// Test step
// ---------------------------------------------------------------------------
/// Show the "playing" state. The read-back probes whichever DAC is actively
/// playing (scan), so no node needs to be stashed.
fn begin_test(s: &mut State) {
s.doc.test_playing = true;
s.doc.test_rate_matched = false;
s.doc.test_requested_label = qbz_i18n::t("Starting…");
s.doc.test_negotiated_label = String::new();
}
/// "Play test": resolve the four curated tracks (id-hint first, then an
/// "artist title" search when the id 404s on a pulled licence — never
/// raw-id-only), stash them so the user can jump between them, and play.
pub fn start_test() {
with_state(begin_test);
publish();
crate::spawn(async move {
let runtime = crate::app();
let mut tracks: Vec<Track> = Vec::new();
for seed in core::TEST_SEEDS.iter() {
let mut chosen = match runtime.core().get_track(seed.id_hint).await {
Ok(t) if core::track_matches_seed(&t, seed) => Some(t),
_ => None,
};
if chosen.is_none() {
let q = format!("{} {}", seed.artist, seed.title);
if let Ok(page) = runtime.core().search_tracks(&q, 10, 0, None).await {
chosen = page
.items
.into_iter()
.find(|t| core::track_matches_seed(t, seed));
}
}
if let Some(t) = chosen {
tracks.push(t);
}
}
if tracks.is_empty() {
with_state(|s| {
s.doc.test_playing = false;
s.doc.test_requested_label =
qbz_i18n::t("Couldn't load the test tracks (offline?)");
s.doc.test_negotiated_label = String::new();
s.doc.test_rate_matched = false;
});
publish();
return;
}
with_state(|s| s.test_tracks = tracks.clone());
play_from(tracks, 0).await;
});
}
/// Jump straight to one of the four tracks (skip the long waits) by re-setting
/// the queue at that index through the working play path.
pub fn test_play_index(index: i32) {
crate::spawn(async move {
let tracks = with_state(|s| s.test_tracks.clone());
if tracks.is_empty() {
return;
}
let start = (index.max(0) as usize).min(tracks.len() - 1);
play_from(tracks, start).await;
});
}
/// Build the queue from catalog tracks and start at `start`. A FLAT list, so no
/// container context is stamped — 1:1 with the reference's
/// `play_tracks_ctx(.., None)`.
async fn play_from(tracks: Vec<Track>, start: usize) {
let runtime = crate::app();
let queue: Vec<qbz_models::QueueTrack> = tracks
.iter()
.map(crate::foryou_qt::to_queue_track)
.collect();
if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, start, false).await {
log::warn!("[qbz-qt] dac wizard: test playback failed: {e}");
}
}
/// Stop the test. Pauses (the reference does exactly this — it does not clear
/// the queue, so the user keeps whatever was loaded).
pub fn stop_test() {
crate::spawn(async move {
let _ = crate::app().core().pause();
with_state(|s| s.doc.test_playing = false);
publish();
});
}
/// "Use my current queue": start the read-back on the user's own music instead
/// of the curated tracks. Guardrail — an empty queue gets a hint rather than a
/// read-back that would sit forever on "Nothing playing".
pub fn verify_own() {
crate::spawn(async move {
let runtime = crate::app();
let (tracks, _) = runtime.core().get_all_queue_tracks().await;
if tracks.is_empty() {
with_state(|s| {
s.doc.test_playing = false;
s.doc.test_rate_matched = false;
s.doc.test_negotiated_label = String::new();
s.doc.test_requested_label = qbz_i18n::t(
"Your queue is empty — add some tracks first, or press Play test.",
);
});
publish();
return;
}
let _ = runtime.core().resume();
with_state(begin_test);
publish();
});
}
/// One poll of the read-back: what QBZ asked the device for vs what the DAC is
/// really clocked at (N6). Driven by a QML `Timer` at 1.5 s while the test
/// plays, exactly as the reference drives it.
pub fn poll_test() {
crate::spawn(async move {
let runtime = crate::app();
let player = runtime.core().player();
let req_rate = player.state.get_sample_rate();
let req_bits = player.state.get_bit_depth();
// The ALSA probe reads /proc; keep it off the UI thread like the rest.
let negotiated = tokio::task::spawn_blocking(qbz_audio::negotiated_active_rate)
.await
.unwrap_or(None);
let view = core::poll_view(req_rate, req_bits, negotiated);
with_state(|s| {
s.doc.test_requested_label = view.requested_label;
s.doc.test_negotiated_label = view.negotiated_label;
s.doc.test_rate_matched = view.rate_matched;
});
publish();
});
}
#[cfg(test)]
mod tests {
use super::*;
/// `STATE` is a process-global and cargo runs these in PARALLEL threads,
/// so three tests that each seed and clear it raced and failed each other
/// (caught by the gate, 2026-08-11). Every test that touches the wizard
/// state takes this lock for its whole body.
///
/// `lock()` rather than `lock().unwrap()`: a test that panics while
/// holding it would poison the mutex and turn one real failure into three,
/// hiding which one is the actual defect.
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
*STATE.lock().unwrap() = None;
g
}
/// The pre-publish document must parse in QML AND must not put the manual
/// escape hatch on screen before enumeration has ever run (`hasEnumeration`
/// false is what forces it open).
#[test]
fn initial_document_is_closed_and_parseable() {
let v: serde_json::Value = serde_json::from_str(&initial_json()).unwrap();
assert_eq!(v["open"], serde_json::json!(false));
assert_eq!(v["hasEnumeration"], serde_json::json!(true));
}
/// `checked_dacs` prefers the CHECKED candidates and only falls back to the
/// manual field when nothing is checked — and never accepts an invalid one.
#[test]
fn checked_dacs_prefers_candidates_then_valid_manual() {
let _g = guard();
with_state(|s| {
s.doc.candidates = vec![
Candidate {
id: "alsa_output.usb-a".into(),
description: "DacMagic".into(),
checked: true,
..Default::default()
},
Candidate {
id: "alsa_output.pci-b".into(),
description: "Onboard".into(),
checked: false,
..Default::default()
},
];
s.doc.manual_node_name = "alsa_output.usb-manual".into();
s.doc.manual_valid = true;
});
let picked = checked_dacs();
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].0, "alsa_output.usb-a");
// Nothing checked -> the valid manual entry.
with_state(|s| s.doc.candidates.clear());
let picked = checked_dacs();
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].0, "alsa_output.usb-manual");
// An INVALID manual entry is not a DAC.
with_state(|s| s.doc.manual_valid = false);
assert!(checked_dacs().is_empty());
}
/// Toggling a candidate must move the Next gate with it — the gate is what
/// stops the user reaching a review step with nothing to generate.
#[test]
fn toggling_a_candidate_moves_the_next_gate() {
let _g = guard();
with_state(|s| {
s.doc.candidates = vec![Candidate {
id: "alsa_output.usb-a".into(),
checked: false,
..Default::default()
}];
s.doc.any_dac_selected = false;
});
toggle_dac(0);
assert!(with_state(|s| s.doc.any_dac_selected));
toggle_dac(0);
assert!(!with_state(|s| s.doc.any_dac_selected));
}
/// An out-of-range index from QML must not panic (a recycled delegate can
/// fire one after the model shrank).
#[test]
fn out_of_range_toggles_are_ignored() {
let _g = guard();
toggle_dac(7);
toggle_config(-3);
assert!(with_state(|s| s.doc.candidates.is_empty()));
}
}
+5
View File
@@ -191,6 +191,11 @@ const MASTERS: &[(&str, &str)] = &[
("folder-open", include_str!("../qml/assets/icons/primary/folder-open.svg")),
("folder-plus", include_str!("../qml/assets/icons/primary/folder-plus.svg")),
("folder", include_str!("../qml/assets/icons/primary/folder.svg")),
// The HiFi Wizard's header glyph. Unlike every other master this one is a
// FILLED path set (`fill="#ffffff"`), not a stroked outline — the literal
// substitution does not care, but a future "why is this file shaped
// differently" reads better answered here.
("gandalf", include_str!("../qml/assets/icons/primary/gandalf.svg")),
("globe", include_str!("../qml/assets/icons/primary/globe.svg")),
("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")),
("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")),
+13
View File
@@ -72,6 +72,12 @@ mod playlist_edit_bridge;
// manager, opened from two shell surfaces that outlive each other, and its
// modal must survive the one that opened it (05 §5.8).
mod playlist_import_bridge;
// HiFi Wizard (DAC setup). Its own singleton rather than more surface on
// QbzBridge: the wizard is one self-contained modal whose document nobody else
// reads, and its read-back ticks every 1.5 s while the test plays — routing
// that through the settings document would republish the whole Settings view
// on every tick. Its controller half is `dac_wizard_qt` (a PLAIN module).
mod dac_wizard_bridge;
// Qobuz Connect (2026-08-01 contract §8, block B4-Rust): the QbzQConnect
// singleton — the QML surface of the facade/sink controllers below
// (qconnect_qt.rs / qconnect_event_sink_qt.rs).
@@ -101,6 +107,13 @@ mod fav_cache_qt;
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod folder_edit_qt;
mod folders_qt;
// The HiFi Wizard controller. A plain module — it declares no
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files. All of
// its COMPUTATION lives in the shared `qbz-dac-wizard-core` crate, which the
// Slint adapter uses too: the wizard emits PipeWire/WirePlumber snippets the
// user pastes into their own system, so two implementations would be the one
// divergence this port must never produce.
mod dac_wizard_qt;
mod foryou_qt;
mod genre_filter_qt;
mod home_qt;
@@ -3102,6 +3102,9 @@ msgstr "Playlist-Link wird geprüft..."
msgid "Checking your audio stack…"
msgstr "Ihr Audio-Stack wird geprüft…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "Die Testtitel konnten nicht geladen werden (offline?)"
msgid "Choose a cover image"
msgstr "Wählen Sie ein Coverbild"
@@ -3102,6 +3102,9 @@ msgstr "Verificando el enlace de la lista de reproducción..."
msgid "Checking your audio stack…"
msgstr "Verificando tu sistema de audio…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "No se pudieron cargar las pistas de prueba (¿sin conexión?)"
msgid "Choose a cover image"
msgstr "Elige una imagen de portada"
@@ -3102,6 +3102,9 @@ msgstr "Vérification du lien de la playlist..."
msgid "Checking your audio stack…"
msgstr "Vérification de votre pile audio…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "Impossible de charger les pistes de test (hors ligne ?)"
msgid "Choose a cover image"
msgstr "Choisissez une image de couverture"
@@ -2959,6 +2959,9 @@ msgstr "プレイリストのリンクを確認中..."
msgid "Checking your audio stack…"
msgstr "オーディオスタックを確認中…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "テストトラックを読み込めませんでした(オフライン?)"
msgid "Choose a cover image"
msgstr "カバー画像を選択"
@@ -3098,6 +3098,9 @@ msgstr "Afspeellijstlink controleren..."
msgid "Checking your audio stack…"
msgstr "Je audiostack controleren…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "Kan de testtracks niet laden (offline?)"
msgid "Choose a cover image"
msgstr "Kies een hoesafbeelding"
@@ -3102,6 +3102,9 @@ msgstr "A verificar a ligação da lista de reprodução..."
msgid "Checking your audio stack…"
msgstr "A verificar a sua pilha de áudio…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "Não foi possível carregar as faixas de teste (offline?)"
msgid "Choose a cover image"
msgstr "Escolha uma imagem de capa"
@@ -2983,6 +2983,9 @@ msgstr "Проверка ссылки на плейлист..."
msgid "Checking your audio stack…"
msgstr "Проверка аудиосистемы…"
msgid "Couldn't load the test tracks (offline?)"
msgstr "Не удалось загрузить тестовые треки (нет сети?)"
msgid "Choose a cover image"
msgstr "Выберите изображение обложки"