Fix virtual mic routing: WirePlumber was restoring the stream to the speakers
WirePlumber's node.stream.restore-target remembers where each stream last played and restores it, ignoring the default sink, so vois.rs output kept going to the headphones. setup() now installs a wireplumber.conf.d override (restore-target/props = false) and restarts WirePlumber on first use. Also: virtual mic setup runs before any device queries, and keep_others_off pulls our own stream back onto vois.rs if it drifts.
This commit is contained in:
parent
aacaf7d9cf
commit
7e8972e716
4 changed files with 142 additions and 22 deletions
|
|
@ -8,6 +8,27 @@ SINK_NAME="vois.rs"
|
||||||
SINK_DESC="vois.rs virtual mic"
|
SINK_DESC="vois.rs virtual mic"
|
||||||
REMAP_NAME="vois.rs"
|
REMAP_NAME="vois.rs"
|
||||||
|
|
||||||
|
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/wireplumber/wireplumber.conf.d"
|
||||||
|
CONF_FILE="${CONF_DIR}/51-vois.conf"
|
||||||
|
|
||||||
|
# WirePlumber remembers stream targets and restores them, so streams that once
|
||||||
|
# played to the speakers keep going there even after the default sink changes.
|
||||||
|
# Disable that so the app's output follows the default (vois.rs).
|
||||||
|
if [ ! -f "${CONF_FILE}" ] || ! grep -q "restore-target = false" "${CONF_FILE}" 2>/dev/null; then
|
||||||
|
mkdir -p "${CONF_DIR}"
|
||||||
|
cat > "${CONF_FILE}" <<EOF
|
||||||
|
wireplumber.settings = {
|
||||||
|
node.stream.restore-target = false
|
||||||
|
node.stream.restore-props = false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "installed WirePlumber fix (${CONF_FILE})"
|
||||||
|
systemctl --user restart wireplumber || true
|
||||||
|
echo "restarted WirePlumber"
|
||||||
|
else
|
||||||
|
echo "WirePlumber fix already present"
|
||||||
|
fi
|
||||||
|
|
||||||
# Idempotency check: field 2 of `pactl list short <x>` is the object name.
|
# Idempotency check: field 2 of `pactl list short <x>` is the object name.
|
||||||
if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then
|
if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then
|
||||||
pactl load-module module-null-sink \
|
pactl load-module module-null-sink \
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,22 @@ fn pick_config(
|
||||||
pub fn run(cfg: &AppConfig) -> Result<()> {
|
pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
// Virtual mic FIRST: point the default sink at `vois.rs` before ANY audio
|
||||||
|
// device is opened, so the output node is created with vois.rs as its
|
||||||
|
// target (otherwise it latches onto the current default = your speakers).
|
||||||
|
let prev_sink = if cfg.virtual_mic {
|
||||||
|
virtual_mic::VirtualMic::setup()?;
|
||||||
|
virtual_mic::VirtualMic::get_default_sink()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if prev_sink.is_some() {
|
||||||
|
virtual_mic::VirtualMic::set_default_sink("vois.rs");
|
||||||
|
// Wait for the session manager to apply the new default before the
|
||||||
|
// output node is created, so it latches onto vois.rs and not speakers.
|
||||||
|
virtual_mic::VirtualMic::wait_default_sink("vois.rs");
|
||||||
|
}
|
||||||
|
|
||||||
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
|
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
|
||||||
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
|
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
|
||||||
|
|
||||||
|
|
@ -120,20 +136,6 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||||
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
|
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
|
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
|
||||||
|
|
||||||
// Virtual mic: keep `vois.rs` as the default sink for the whole run so the
|
|
||||||
// app's output lands in the virtual mic. A helper thread parks other
|
|
||||||
// apps' streams back on the real sink so their audio doesn't leak into the
|
|
||||||
// mic. The default sink is restored on exit.
|
|
||||||
let prev_sink = if cfg.virtual_mic {
|
|
||||||
virtual_mic::VirtualMic::setup()?;
|
|
||||||
virtual_mic::VirtualMic::get_default_sink()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if prev_sink.is_some() {
|
|
||||||
virtual_mic::VirtualMic::set_default_sink("vois.rs");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
|
let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
|
||||||
let output_stream = playback::start(&output_device, &out_supported, out_cons)?;
|
let output_stream = playback::start(&output_device, &out_supported, out_cons)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,20 @@ use std::time::Duration;
|
||||||
|
|
||||||
pub struct VirtualMic;
|
pub struct VirtualMic;
|
||||||
|
|
||||||
|
/// WirePlumber config that stops it from pinning streams to remembered sinks.
|
||||||
|
fn wireplumber_conf() -> std::path::PathBuf {
|
||||||
|
let base = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(|h| std::path::PathBuf::from(h).join(".config"))
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||||
|
});
|
||||||
|
base.join("wireplumber")
|
||||||
|
.join("wireplumber.conf.d")
|
||||||
|
.join("51-vois.conf")
|
||||||
|
}
|
||||||
|
|
||||||
impl VirtualMic {
|
impl VirtualMic {
|
||||||
pub fn setup() -> Result<Option<String>> {
|
pub fn setup() -> Result<Option<String>> {
|
||||||
let pactl = "pactl";
|
let pactl = "pactl";
|
||||||
|
|
@ -72,9 +86,54 @@ impl VirtualMic {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Self::ensure_wireplumber_fix() {
|
||||||
|
// First run: the fix needs a WirePlumber restart to apply.
|
||||||
|
Self::restart_wireplumber();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some("vois.rs".to_string()))
|
Ok(Some("vois.rs".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WirePlumber remembers the target of each stream and restores it, so a
|
||||||
|
/// stream that once played to the speakers keeps going there even after the
|
||||||
|
/// default sink is switched. Disabling that makes ALSA streams follow the
|
||||||
|
/// default sink again. Returns true if a config was (re)written.
|
||||||
|
pub fn ensure_wireplumber_fix() -> bool {
|
||||||
|
let conf = wireplumber_conf();
|
||||||
|
let conf_text = "wireplumber.settings = {\n\
|
||||||
|
\x20 node.stream.restore-target = false\n\
|
||||||
|
\x20 node.stream.restore-props = false\n\
|
||||||
|
}\n";
|
||||||
|
if let Ok(existing) = std::fs::read_to_string(&conf) {
|
||||||
|
if existing.contains("restore-target = false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dir) = conf.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
std::fs::write(&conf, conf_text).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart WirePlumber so a newly written config takes effect, and wait
|
||||||
|
/// until the audio server responds again.
|
||||||
|
pub fn restart_wireplumber() {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "restart", "wireplumber"])
|
||||||
|
.output();
|
||||||
|
for _ in 0..40 {
|
||||||
|
if Command::new("pactl")
|
||||||
|
.args(["info"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Current default sink name, if any.
|
/// Current default sink name, if any.
|
||||||
pub fn get_default_sink() -> Option<String> {
|
pub fn get_default_sink() -> Option<String> {
|
||||||
let out = Command::new("pactl")
|
let out = Command::new("pactl")
|
||||||
|
|
@ -95,13 +154,26 @@ impl VirtualMic {
|
||||||
.output();
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep every sink-input that is NOT this app's (client name "vois") away
|
/// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber
|
||||||
/// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`.
|
/// applies `set-default-sink` asynchronously; if the app creates its output
|
||||||
|
/// node before that, the node latches onto the old default.
|
||||||
|
pub fn wait_default_sink(name: &str) {
|
||||||
|
for _ in 0..40 {
|
||||||
|
if Self::get_default_sink().as_deref() == Some(name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep this app's streams (client name "vois") on the `vois.rs` sink and
|
||||||
|
/// every other app's streams off it (parked on `prev_sink`). Runs until
|
||||||
|
/// `stop`.
|
||||||
///
|
///
|
||||||
/// WirePlumber routes ALSA streams to the default sink, so while the app
|
/// WirePlumber routes ALSA streams to the default sink, so if our output
|
||||||
/// runs with `vois.rs` as default, other apps' audio would leak into the
|
/// node is created before the default is switched, it can end up on the
|
||||||
/// virtual mic. Moving their streams explicitly sticks, so this thread
|
/// speakers; this thread pulls it back onto the virtual mic. Moving
|
||||||
/// parks them on the real output.
|
/// streams explicitly sticks, so other apps' audio never leaks into it.
|
||||||
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
|
@ -111,7 +183,13 @@ impl VirtualMic {
|
||||||
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
|
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
|
||||||
for (id, sink, client) in &inputs {
|
for (id, sink, client) in &inputs {
|
||||||
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
|
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
|
||||||
if sink == &lv && !ours {
|
if ours && sink != &lv {
|
||||||
|
// Our own stream drifted to the speakers.
|
||||||
|
let _ = Command::new("pactl")
|
||||||
|
.args(["move-sink-input", id, "vois.rs"])
|
||||||
|
.output();
|
||||||
|
} else if !ours && sink == &lv {
|
||||||
|
// Other app leaked into the virtual mic.
|
||||||
let _ = Command::new("pactl")
|
let _ = Command::new("pactl")
|
||||||
.args(["move-sink-input", id, &pv])
|
.args(["move-sink-input", id, &pv])
|
||||||
.output();
|
.output();
|
||||||
|
|
|
||||||
|
|
@ -816,7 +816,26 @@ pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: Session
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
enable_raw_mode().expect("failed to enable raw mode");
|
if enable_raw_mode().is_err() {
|
||||||
|
// No real terminal: keep the audio running headless.
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let done = control
|
||||||
|
.lock()
|
||||||
|
.map(|c| {
|
||||||
|
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if done {
|
||||||
|
if let Ok(mut c) = control.lock() {
|
||||||
|
c.quit = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
|
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
|
||||||
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue