System default on macOS is the CoreAudio exclusive backend: with
`exclusive_mode` set, qbz-audio takes Hog Mode, pins the device and switches
its nominal rate to the track (PR #391, shipped in 1.2.11 and never removed).
The Tauri settings gated the toggle on `ALSA || (macos && SystemDefault)`;
the Slint port copied only the ALSA half, and fc0d35e29 then hid the row on
macOS on the premise that no backend there could honour it. So 2.0.x showed
a grey row and 2.1.0 none, while the DB flag still engaged Hog Mode.
- SettingsDoc publishes `backendIsCoreAudio` (macOS && System default); the
row is visible and enabled on it. Linux and Windows are unchanged.
- The backend-switch cascade goes through one predicate
(`output_labels::backend_honours_exclusive`), so re-selecting Auto or
System default on macOS no longer clears the flag.
- The output LED reads EXCL on macOS System default + exclusive_mode instead
of DEFAULT while the device is hogged.
- Node test `scripts/test_qt_exclusive_gate.mjs` evaluates the row's real
gate expressions across platforms; added to qt-runtime-gate.sh. Rust tests
cover the predicate, the LED and the serde name QML reads.
Verified by smoke on the Mac mini with a USB DAC: the row appears, Hog Mode
is acquired, and browser audio moves to the built-in speakers while QBZ
plays. qbz-qt suite 782/782 on Linux; 13/13 with cfg(macos) on the Mac.
cc
383 lines
19 KiB
QML
383 lines
19 KiB
QML
// Settings > Audio — the QML port of crates/qbz-ui/ui/settings/
|
|
// AudioSettings.slint. Group order, labels, descriptions, gating and control
|
|
// types are 1:1 with the Slint; every control rides the single settingsJson
|
|
// document (root.doc) and the settingsBool/Select invokables — never local
|
|
// truth. The audio path itself is PROTECTED: this panel only calls the
|
|
// settings keys settings_qt.rs already dispatches.
|
|
//
|
|
// "Detected device limit" + its fallback disclosure (#638 fix 3) ARE shipped
|
|
// (2026-08-17): the probe moved out of the Slint binary crate into
|
|
// `qbz-app::device_cap`, and both rows read the cap cache off the settings
|
|
// document. They are read-only by design — see the comment at the rows.
|
|
//
|
|
// 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
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Column {
|
|
property bool kioskHost: false
|
|
|
|
id: root
|
|
|
|
property var doc: ({})
|
|
property string openedHardwareVolumeChoice: ""
|
|
|
|
function maybeOpenHardwareVolumeChoice() {
|
|
const status = root.doc.alsaHardwareVolumeStatus || ""
|
|
const needsChoice = status === "ambiguous" || status === "stale"
|
|
const token = root.doc.alsaHardwareVolumeChoiceToken || ""
|
|
if (!needsChoice) {
|
|
root.openedHardwareVolumeChoice = ""
|
|
return
|
|
}
|
|
if (token !== "" && token !== root.openedHardwareVolumeChoice) {
|
|
root.openedHardwareVolumeChoice = token
|
|
Qt.callLater(function () { hardwareVolumeSelect.openPopup() })
|
|
}
|
|
}
|
|
|
|
onDocChanged: maybeOpenHardwareVolumeChoice()
|
|
Component.onCompleted: maybeOpenHardwareVolumeChoice()
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
spacing: 4
|
|
|
|
// ============================ STREAMING ==============================
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("STREAMING", QbzSession.trRev) }
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Streaming quality", QbzSession.trRev)
|
|
description: QbzSession.tr("The quality tier QBZ requests for playback.", QbzSession.trRev)
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
menuWidth: 200
|
|
options: root.doc.streamingQualities || []
|
|
currentIndex: root.doc.streamingQualityIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("streaming-quality", i) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Limit quality to device", QbzSession.trRev)
|
|
description: QbzSession.tr("Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.limitQualityToDevice === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("limit-quality-to-device", v) }
|
|
}
|
|
}
|
|
// Read-only detected line — deliberately NOT a control (#638 fix 3): a Hz
|
|
// dropdown was the documented root-cause trap (it promised rates the app
|
|
// cannot request; Qobuz sells four discrete tiers), and with real detection
|
|
// there is nothing left to pick. The value is Rust-composed from the probe
|
|
// ("192 kHz · Hi-Res+") and is empty while nothing is cached, which is what
|
|
// hides the row rather than showing a blank value.
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.limitQualityToDevice === true
|
|
&& (root.doc.deviceCapSummary || "") !== ""
|
|
label: QbzSession.tr("Detected device limit", QbzSession.trRev)
|
|
Text {
|
|
text: root.doc.deviceCapSummary || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
// Fallback disclosure (#638 Decision B): plain informative copy — no
|
|
// warning icon, no error styling. The cap still applies on the assumed
|
|
// common set; it just is not measured truth for this hardware.
|
|
Text {
|
|
visible: root.doc.limitQualityToDevice === true
|
|
&& (root.doc.deviceCapSummary || "") !== ""
|
|
&& root.doc.deviceCapDetected === false
|
|
width: parent.width
|
|
text: QbzSession.tr("Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// ============================= OUTPUT ================================
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("OUTPUT", QbzSession.trRev) }
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Audio backend", QbzSession.trRev)
|
|
description: QbzSession.tr("The audio stack QBZ routes playback through.", QbzSession.trRev)
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
menuWidth: 220
|
|
options: root.doc.backends || []
|
|
currentIndex: root.doc.backendIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("backend", i) }
|
|
}
|
|
}
|
|
// JACK routes through the JACK graph (which resamples to the graph rate),
|
|
// so it can never be bit-perfect — say so instead of letting the
|
|
// bit-perfect toggles below imply otherwise.
|
|
WarningBanner {
|
|
visible: root.doc.backendIsJack === true
|
|
variant: "warning"
|
|
title: QbzSession.tr("JACK is not bit-perfect", QbzSession.trRev)
|
|
body: QbzSession.tr("The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough.", QbzSession.trRev)
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Output device", QbzSession.trRev)
|
|
description: QbzSession.tr("The DAC or sound device that receives audio.", QbzSession.trRev)
|
|
Row {
|
|
spacing: 8
|
|
// Refresh / release: frees a device QBZ holds exclusively (ALSA
|
|
// direct) and re-enumerates, so a freed or hot-plugged DAC shows
|
|
// up without an app restart.
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
iconName: "refresh-cw"
|
|
onClicked: QbzBridge.refreshDevices()
|
|
}
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
menuWidth: 300
|
|
popupWidth: 480
|
|
searchable: true
|
|
options: root.doc.devices || []
|
|
currentIndex: root.doc.deviceIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("device", i) }
|
|
}
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.backendIsAlsa === true
|
|
label: QbzSession.tr("ALSA plugin", QbzSession.trRev)
|
|
description: QbzSession.tr("How ALSA opens the device — hw is bit-perfect, plughw converts.", QbzSession.trRev)
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
menuWidth: 220
|
|
options: root.doc.alsaPlugins || []
|
|
currentIndex: root.doc.alsaPluginIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("alsa-plugin", i) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.backendIsAlsa === true && root.doc.alsaDirectSelected === true
|
|
label: QbzSession.tr("Hardware volume control", QbzSession.trRev)
|
|
description: QbzSession.tr("Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.alsaHardwareVolume === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("alsa-hardware-volume", v) }
|
|
}
|
|
}
|
|
Text {
|
|
visible: root.doc.backendIsAlsa === true
|
|
&& root.doc.alsaDirectSelected === true
|
|
&& (root.doc.alsaHardwareVolumeMessage || "") !== ""
|
|
width: parent.width
|
|
text: root.doc.alsaHardwareVolumeMessage || ""
|
|
color: (root.doc.alsaHardwareVolumeStatus || "") === "probing"
|
|
? theme.textMuted : theme.warning
|
|
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.backendIsAlsa === true
|
|
&& root.doc.alsaDirectSelected === true
|
|
&& (root.doc.alsaHardwareVolumeSelected || "") !== ""
|
|
label: QbzSession.tr("Selected mixer control", QbzSession.trRev)
|
|
Text {
|
|
text: root.doc.alsaHardwareVolumeSelected || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.backendIsAlsa === true
|
|
&& root.doc.alsaDirectSelected === true
|
|
&& ((root.doc.alsaHardwareVolumeStatus || "") === "ambiguous"
|
|
|| (root.doc.alsaHardwareVolumeStatus || "") === "stale"
|
|
|| (root.doc.alsaHardwareVolume === true
|
|
&& root.doc.alsaHardwareVolumeCanChange === true))
|
|
&& (root.doc.alsaHardwareVolumeOptions || []).length > 0
|
|
label: ((root.doc.alsaHardwareVolumeStatus || "") === "ambiguous"
|
|
|| (root.doc.alsaHardwareVolumeStatus || "") === "stale")
|
|
? QbzSession.tr("Choose mixer control", QbzSession.trRev)
|
|
: QbzSession.tr("Change mixer control", QbzSession.trRev)
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
id: hardwareVolumeSelect
|
|
menuWidth: 260
|
|
popupWidth: 480
|
|
searchable: (root.doc.alsaHardwareVolumeOptions || []).length > 8
|
|
options: root.doc.alsaHardwareVolumeOptions || []
|
|
currentIndex: root.doc.alsaHardwareVolumeIndex !== undefined
|
|
? root.doc.alsaHardwareVolumeIndex : -1
|
|
onSelected: function (i) {
|
|
QbzBridge.settingsSelect("alsa-hardware-volume-control", i)
|
|
}
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
// ALSA only. fc0d35e29 opened this to WASAPI expecting plan B Task 6
|
|
// (DoP over exclusive mode); the spike then measured this DAC refusing
|
|
// 176400, which is the DSD64 DoP carrier, and Task 6 lost its target.
|
|
// Both non-convert modes are `cfg(target_os = "linux")` in the audio
|
|
// thread and answer "DoP playback is Linux-only" with a stream error,
|
|
// so on Windows the row could only ever offer two ways to fail. DSD
|
|
// still plays there -- converted to PCM, which is the default.
|
|
visible: root.doc.backendIsAlsa === true
|
|
label: QbzSession.tr("DSD playback", QbzSession.trRev)
|
|
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
|
|
QbzSelect { kioskHost: root.kioskHost;
|
|
menuWidth: 280
|
|
options: root.doc.dsdModes || []
|
|
currentIndex: root.doc.dsdModeIndex || 0
|
|
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 { kioskHost: root.kioskHost;
|
|
// The generated config is PipeWire/WirePlumber and qbz-dac-wizard-core
|
|
// knows no other stack, so off Linux the wizard cannot finish. Hidden,
|
|
// not disabled: a disabled row invites a bug report. This is NOT the
|
|
// reference's `backend-is-pipewire` gate coming back - within Linux
|
|
// every backend still sees it, which is the owner ruling above.
|
|
visible: QbzShell.isLinux
|
|
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 { kioskHost: root.kioskHost;
|
|
iconName: "gandalf"
|
|
text: QbzSession.tr("Open Wizard", QbzSession.trRev)
|
|
onClicked: QbzDacWizard.open()
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// =========================== BIT-PERFECT =============================
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("BIT-PERFECT", QbzSession.trRev) }
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
// Visible wherever a backend can honour it, enabled only on that
|
|
// backend. Per platform:
|
|
//
|
|
// LINUX: unchanged since 2026-08-27 - always visible, enabled on ALSA.
|
|
//
|
|
// MACOS: System default IS the CoreAudio exclusive backend. With
|
|
// `exclusive_mode` set, qbz-audio backend.rs
|
|
// `create_output_stream_with_exclusive_guard` pins the device, takes
|
|
// Hog Mode and switches its nominal rate to the track (PR #391,
|
|
// shipped in 1.2.11 and never removed). fc0d35e29 hid this row on
|
|
// macOS on the wrong premise that no backend there could honour it;
|
|
// #748 restored it. `backendIsCoreAudio` is published by
|
|
// settings_qt.rs only on macOS, so the `=== true` idiom keeps every
|
|
// other platform exactly where it was.
|
|
//
|
|
// WINDOWS: hidden on purpose, even now that Phase B publishes
|
|
// `backendIsWasapi`. The exclusive path is chosen by the BACKEND
|
|
// dropdown ("WASAPI Exclusive"), not by this toggle -- the selection
|
|
// arm in qbz-player keys on `backend_type` and never reads
|
|
// `exclusive_mode`. Showing the toggle would put a control under the
|
|
// BIT-PERFECT header that changes nothing, which is worse than no
|
|
// control at all when the thing being judged IS bit-perfect. Whether
|
|
// Windows should instead mirror the Linux split (backend = transport,
|
|
// toggle = hw/plughw) is an open design question for the owner.
|
|
visible: QbzShell.isLinux || root.doc.backendIsCoreAudio === true
|
|
label: QbzSession.tr("Exclusive mode", QbzSession.trRev)
|
|
description: QbzSession.tr("Lock the device so no other app can resample it.", QbzSession.trRev)
|
|
rowEnabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true || root.doc.backendIsCoreAudio === true
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.exclusiveMode === true
|
|
enabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true || root.doc.backendIsCoreAudio === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("exclusive-mode", v) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
// D-Bus org.freedesktop.ReserveDevice1, which is Linux only. On
|
|
// Windows exclusive mode IS the reservation (Phase B); on macOS it is
|
|
// Hog Mode.
|
|
visible: QbzShell.isLinux
|
|
label: QbzSession.tr("Reserve DAC while running", QbzSession.trRev)
|
|
description: QbzSession.tr("Hold the device reserved so other apps can't grab it.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.reserveDac === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("reserve-dac", v) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("DAC passthrough", QbzSession.trRev)
|
|
description: QbzSession.tr("Send the bitstream untouched to the DAC.", QbzSession.trRev)
|
|
rowEnabled: root.doc.backendIsPipewire === true
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.dacPassthrough === true
|
|
enabled: root.doc.backendIsPipewire === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("dac-passthrough", v) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.dacPassthrough === true && root.doc.backendIsPipewire === true
|
|
label: QbzSession.tr("Force bit-perfect", QbzSession.trRev)
|
|
description: QbzSession.tr("Pin the PipeWire quantum and rate for the active track.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.pwForceBitperfect === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("pw-force-bitperfect", v) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Allow quality fallback", QbzSession.trRev)
|
|
description: QbzSession.tr("Drop to a lower tier when the requested one is unavailable.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.allowQualityFallback === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("allow-quality-fallback", v) }
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// ============================= STARTUP ===============================
|
|
GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("STARTUP", QbzSession.trRev) }
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
label: QbzSession.tr("Sync audio settings on startup", QbzSession.trRev)
|
|
description: QbzSession.tr("Reload saved audio settings into the player when QBZ launches.", QbzSession.trRev)
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.syncAudioOnStartup === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("sync-audio-on-startup", v) }
|
|
}
|
|
}
|
|
SettingRow { kioskHost: root.kioskHost;
|
|
visible: root.doc.backendIsPipewire === true
|
|
label: QbzSession.tr("Lock output device", QbzSession.trRev)
|
|
description: QbzSession.tr("Keep external routing intact — skip switching the default sink.", QbzSession.trRev)
|
|
rowEnabled: root.doc.dacPassthrough !== true
|
|
QbzToggle { kioskHost: root.kioskHost;
|
|
checked: root.doc.skipSinkSwitch === true
|
|
enabled: root.doc.dacPassthrough !== true
|
|
onToggled: function (v) { QbzBridge.settingsBool("skip-sink-switch", v) }
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 20 }
|
|
|
|
// Reset — restores Audio + Playback defaults.
|
|
SettingsButton { kioskHost: root.kioskHost;
|
|
text: QbzSession.tr("Reset to defaults", QbzSession.trRev)
|
|
onClicked: QbzBridge.settingsReset()
|
|
}
|
|
}
|