feat(audio): add PipeWire force bit-perfect mode
Add experimental toggle to force PipeWire clock.force-quantum alongside clock.force-rate for true bit-perfect playback. Quantum is matched to sample rate (1024 for <=48kHz, 2048 for <=96kHz, etc). Both values reset to 0 on stop and pause-suspend to avoid affecting other apps. Requires DAC Passthrough to be enabled, PipeWire backend only.
This commit is contained in:
1 parent
98ffd3dff2
commit
cbc2ff0b26
13 files changed
+181
-5
No files matched your search
@@ -112,6 +112,9 @@ pub struct BackendConfig {
|
||||
|
||||
/// Exclusive mode flag
|
||||
pub exclusive_mode: bool,
|
||||
|
||||
/// When true, force PipeWire clock.force-quantum for bit-perfect playback
|
||||
pub pw_force_bitperfect: bool,
|
||||
}
|
||||
|
||||
/// Result type for backend operations
|
||||
|
||||
@@ -21,6 +21,17 @@ pub struct PipeWireBackend {
|
||||
host: rodio::cpal::Host,
|
||||
}
|
||||
|
||||
/// Calculate the optimal PipeWire quantum for a given sample rate.
|
||||
/// Returns a power-of-2 value matching common audio interface expectations.
|
||||
fn quantum_for_sample_rate(sample_rate: u32) -> u32 {
|
||||
match sample_rate {
|
||||
r if r <= 48000 => 1024,
|
||||
r if r <= 96000 => 2048,
|
||||
r if r <= 192000 => 4096,
|
||||
_ => 8192,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeWireBackend {
|
||||
pub fn new() -> BackendResult<Self> {
|
||||
Ok(Self {
|
||||
@@ -28,6 +39,18 @@ impl PipeWireBackend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset PipeWire clock.force-rate and clock.force-quantum to 0.
|
||||
/// Call this when playback stops so other apps aren't stuck at a forced rate.
|
||||
pub fn reset_pipewire_clock() {
|
||||
log::info!("[PipeWire Backend] Resetting clock.force-rate and clock.force-quantum to 0");
|
||||
let _ = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-rate", "0"])
|
||||
.output();
|
||||
let _ = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-quantum", "0"])
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Parse pactl output to get device list with pretty names
|
||||
fn enumerate_pipewire_sinks(&self) -> BackendResult<Vec<AudioDevice>> {
|
||||
// Get default sink
|
||||
@@ -194,6 +217,28 @@ impl AudioBackend for PipeWireBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// Force quantum if bit-perfect mode is enabled
|
||||
if config.pw_force_bitperfect {
|
||||
let quantum = quantum_for_sample_rate(config.sample_rate);
|
||||
log::info!("[PipeWire Backend] Forcing quantum to {} via pw-metadata (bit-perfect)", quantum);
|
||||
let quantum_result = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-quantum", &quantum.to_string()])
|
||||
.output();
|
||||
|
||||
match quantum_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("[PipeWire Backend] Quantum forced to {}", quantum);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("[PipeWire Backend] Failed to force quantum: {}", stderr);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[PipeWire Backend] Error executing pw-metadata for quantum: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for PipeWire to apply the sample rate change
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct AudioSettings {
|
||||
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
|
||||
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
|
||||
pub gapless_enabled: bool,
|
||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
||||
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
|
||||
pub pw_force_bitperfect: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -68,6 +71,7 @@ impl Default for AudioSettings {
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: false, // Off by default — user opts in
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +156,10 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN device_sample_rate_limits TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -170,7 +178,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -206,6 +214,7 @@ impl AudioSettingsStore {
|
||||
normalization_enabled: row.get::<_, Option<i64>>(12)?.unwrap_or(0) != 0,
|
||||
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
|
||||
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -422,6 +431,16 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET pw_force_bitperfect = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set pw_force_bitperfect: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -468,7 +487,8 @@ impl AudioSettingsStore {
|
||||
normalization_enabled = ?13,
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -487,6 +507,7 @@ impl AudioSettingsStore {
|
||||
defaults.normalization_target_lufs as f64,
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
Reference in new issue
Block a user