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"
|
||||
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.
|
||||
if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then
|
||||
pactl load-module module-null-sink \
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@ fn pick_config(
|
|||
pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||
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 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 (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 output_stream = playback::start(&output_device, &out_supported, out_cons)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ use std::time::Duration;
|
|||
|
||||
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 {
|
||||
pub fn setup() -> Result<Option<String>> {
|
||||
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()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn get_default_sink() -> Option<String> {
|
||||
let out = Command::new("pactl")
|
||||
|
|
@ -95,13 +154,26 @@ impl VirtualMic {
|
|||
.output();
|
||||
}
|
||||
|
||||
/// Keep every sink-input that is NOT this app's (client name "vois") away
|
||||
/// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`.
|
||||
/// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber
|
||||
/// 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
|
||||
/// runs with `vois.rs` as default, other apps' audio would leak into the
|
||||
/// virtual mic. Moving their streams explicitly sticks, so this thread
|
||||
/// parks them on the real output.
|
||||
/// WirePlumber routes ALSA streams to the default sink, so if our output
|
||||
/// node is created before the default is switched, it can end up on the
|
||||
/// speakers; this thread pulls it back onto the virtual mic. Moving
|
||||
/// streams explicitly sticks, so other apps' audio never leaks into it.
|
||||
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
|
|
@ -111,7 +183,13 @@ impl VirtualMic {
|
|||
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
|
||||
for (id, sink, client) in &inputs {
|
||||
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")
|
||||
.args(["move-sink-input", id, &pv])
|
||||
.output();
|
||||
|
|
|
|||
|
|
@ -816,7 +816,26 @@ pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: Session
|
|||
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 mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
|
||||
Ok(t) => t,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue