Introduce initial implementation of the vmic CLI with PipeWire integration
- Added `Cargo.toml` to define dependencies and project metadata. - Implemented core CLI functionality for managing virtual microphones (`create`, `edit`, `delete`, `list`, `route`, and `wipe` commands) using `clap`. - Integrated `rusqlite` for persistent state storage and `pipewire` to manage PipeWire nodes. - Ensured graceful handling of feedback loops and system defaults during virtual mic creation and deletion. - Added error handling via `thiserror` for cleaner error definitions.
This commit is contained in:
77
src/commands/create.rs
Normal file
77
src/commands/create.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use crate::cli::CreateArgs;
|
||||
use crate::error::{Result, VmicError};
|
||||
use crate::pw::{loopback, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run(args: CreateArgs) -> Result<()> {
|
||||
state::require_valid_name(&args.name)?;
|
||||
let name = state::normalize(&args.name);
|
||||
let conn = state::open_db()?;
|
||||
|
||||
if VmicState::exists(&conn, &name)? {
|
||||
return Err(VmicError::AlreadyExists(name));
|
||||
}
|
||||
|
||||
let names = loopback::NodeNames::for_vmic(&name);
|
||||
let graph = PwGraph::connect()?;
|
||||
|
||||
// pw-loopback may steal the default sink/source; restore them afterward.
|
||||
let orig_sink = default_output("get-default-sink");
|
||||
let orig_source = default_output("get-default-source");
|
||||
|
||||
let stages = loopback::create_stages(&graph, &names)?;
|
||||
|
||||
if let Some(sink) = &orig_sink {
|
||||
let _ = Command::new("pactl").args(["set-default-sink", sink]).status();
|
||||
}
|
||||
if let Some(source) = &orig_source {
|
||||
let _ = Command::new("pactl").args(["set-default-source", source]).status();
|
||||
}
|
||||
|
||||
// Insurance: a sink monitor feeding any vmic node is a feedback ring;
|
||||
// it should never exist, but tear it down if one appears anyway.
|
||||
let prefix = format!("vmic_{name}_");
|
||||
let removed = graph.break_feedback_rings(&prefix)?;
|
||||
if removed > 0 {
|
||||
ui::warn(&format!("removed {removed} unexpected monitor feedback link(s)."));
|
||||
}
|
||||
|
||||
let mut state = VmicState {
|
||||
name: name.clone(),
|
||||
sink_name: names.sink.clone(),
|
||||
mid_name: names.mid,
|
||||
mix_name: names.mix,
|
||||
source_name: names.source.clone(),
|
||||
sink_node_id: Some(stages.sink_node_id),
|
||||
mix_node_id: Some(stages.mix_node_id),
|
||||
sink_pid: Some(stages.sink_pid as i64),
|
||||
mix_pid: Some(stages.mix_pid as i64),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if args.loopback {
|
||||
let loop_id = loopback::enable_self_monitor(&graph, &names.sink)?;
|
||||
state.loopback_id = Some(loop_id);
|
||||
}
|
||||
|
||||
state.save(&conn)?;
|
||||
|
||||
ui::ok(&format!("Created virtual microphone '{name}'."));
|
||||
if let (Some(sink), Some(source)) = (&orig_sink, &orig_source) {
|
||||
ui::info(&format!(" Default Output/Input kept intact ('{sink}' / '{source}')."));
|
||||
}
|
||||
println!(" In the source app, set output device to:");
|
||||
println!(" '{}'", ui::blue(&names.sink));
|
||||
println!(" In the receiving app, set input/mic device to:");
|
||||
println!(" '{}'", ui::blue(&names.source));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_output(subcommand: &str) -> Option<String> {
|
||||
let out = Command::new("pactl").arg(subcommand).output().ok()?;
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
31
src/commands/delete.rs
Normal file
31
src/commands/delete.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::cli::DeleteArgs;
|
||||
use crate::error::Result;
|
||||
use crate::pw::{loopback, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run(args: DeleteArgs) -> Result<()> {
|
||||
let conn = state::open_db()?;
|
||||
let name = state::normalize(&args.name);
|
||||
let mut state = VmicState::load(&conn, &name)?;
|
||||
let graph = PwGraph::connect()?;
|
||||
|
||||
if let Some(loopback_id) = state.loopback_id {
|
||||
loopback::unload_pulse_module(loopback_id)?;
|
||||
}
|
||||
|
||||
// Unlink a mixed-in hardware source explicitly; the links would also
|
||||
// die with the nodes below.
|
||||
super::unsource(&graph, &mut state)?;
|
||||
|
||||
if let Some(pid) = state.mix_pid {
|
||||
loopback::terminate_stage(pid, &state.mix_name)?;
|
||||
}
|
||||
if let Some(pid) = state.sink_pid {
|
||||
loopback::terminate_stage(pid, &state.sink_name)?;
|
||||
}
|
||||
|
||||
VmicState::delete(&conn, &name)?;
|
||||
ui::ok(&format!("Deleted virtual mic '{name}'."));
|
||||
Ok(())
|
||||
}
|
||||
109
src/commands/edit.rs
Normal file
109
src/commands/edit.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use crate::cli::EditArgs;
|
||||
use crate::commands::to_pct;
|
||||
use crate::error::{Result, VmicError};
|
||||
use crate::pw::{loopback, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run(args: EditArgs) -> Result<()> {
|
||||
if args.loopback.is_none() && args.volume.is_none() && args.source_volume.is_none() {
|
||||
return Err(VmicError::NothingToDo(
|
||||
"pass --loopback, --volume and/or --source-volume.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let conn = state::open_db()?;
|
||||
let name = state::normalize(&args.name);
|
||||
let mut state = VmicState::load(&conn, &name)?;
|
||||
let graph = PwGraph::connect()?;
|
||||
|
||||
if let Some(enable) = args.loopback {
|
||||
edit_loopback(&graph, &mut state, enable)?;
|
||||
}
|
||||
if let Some(volume) = args.volume {
|
||||
edit_volume(&graph, &mut state, volume)?;
|
||||
}
|
||||
if let Some(source_volume) = args.source_volume {
|
||||
edit_source_volume(&mut state, source_volume)?;
|
||||
}
|
||||
|
||||
state.save(&conn)
|
||||
}
|
||||
|
||||
fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result<()> {
|
||||
match (enable, state.loopback_id) {
|
||||
(true, Some(id)) => {
|
||||
ui::warn(&format!(
|
||||
"self-monitor loopback already enabled for '{}' (module {id}).",
|
||||
state.name
|
||||
));
|
||||
}
|
||||
(true, None) => {
|
||||
let id = loopback::enable_self_monitor(graph, &state.sink_name)?;
|
||||
state.loopback_id = Some(id);
|
||||
ui::ok(&format!("Self-monitor loopback enabled (module {id})."));
|
||||
|
||||
// Re-apply a previously stored loopback volume, if any. A
|
||||
// failure here is a warning, not a hard error - the loopback
|
||||
// itself was still created successfully.
|
||||
if let Some(pct) = state.volume_pct {
|
||||
if loopback::set_loopback_volume(graph, id, pct).is_ok() {
|
||||
ui::info(&format!("Loopback volume restored to {pct}%."));
|
||||
} else {
|
||||
ui::warn(&format!("could not apply stored loopback volume ({pct}%)."));
|
||||
}
|
||||
}
|
||||
}
|
||||
(false, None) => {
|
||||
ui::warn(&format!("self-monitor loopback is not enabled for '{}'.", state.name));
|
||||
}
|
||||
(false, Some(id)) => {
|
||||
// A failed unload propagates as a real error rather than being
|
||||
// silently swallowed.
|
||||
loopback::unload_pulse_module(id)?;
|
||||
state.loopback_id = None;
|
||||
ui::ok(&format!("Self-monitor loopback disabled (module {id} unloaded)."));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> {
|
||||
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
|
||||
state.volume_pct = Some(pct);
|
||||
|
||||
let Some(id) = state.loopback_id else {
|
||||
ui::ok(&format!(
|
||||
"Loopback volume for '{}' saved ({pct}%); applies when the loopback is enabled.",
|
||||
state.name
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if loopback::set_loopback_volume(graph, id, pct).is_ok() {
|
||||
ui::ok(&format!("Loopback volume for '{}' set to {pct}%.", state.name));
|
||||
} else {
|
||||
ui::warn(&format!("could not find the loopback playback stream (module {id})."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_source_volume(state: &mut VmicState, value: f32) -> Result<()> {
|
||||
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
|
||||
state.mic_volume_pct = Some(pct);
|
||||
|
||||
let Some(mic) = state.mic_source.clone() else {
|
||||
ui::ok(&format!("Source volume for '{}' saved ({pct}%).", state.name));
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let status = std::process::Command::new("pactl")
|
||||
.args(["set-source-volume", &mic, &format!("{pct}%")])
|
||||
.status()?;
|
||||
if status.success() {
|
||||
ui::ok(&format!("Source volume for '{}' set to {pct}% ('{mic}').", state.name));
|
||||
} else {
|
||||
ui::warn(&format!("could not set volume on '{mic}'."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
62
src/commands/list.rs
Normal file
62
src/commands/list.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::error::Result;
|
||||
use crate::pw::PwGraph;
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let conn = state::open_db()?;
|
||||
let states = VmicState::list_all(&conn)?;
|
||||
if states.is_empty() {
|
||||
ui::info("No virtual mics created.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let graph = PwGraph::connect().ok();
|
||||
|
||||
for (i, state) in states.iter().enumerate() {
|
||||
let status = liveness(graph.as_ref(), state);
|
||||
let status_colored = if status == "active" { ui::green(&status) } else { ui::yellow(&status) };
|
||||
|
||||
println!("{}", ui::blue(&state.name));
|
||||
println!(" output device: {}", state.sink_name);
|
||||
println!(" mic device: {}", state.source_name);
|
||||
println!(" status: {status_colored}");
|
||||
println!(
|
||||
" self-monitor loopback: {}",
|
||||
state
|
||||
.loopback_id
|
||||
.map(|id| format!("yes (module {id})"))
|
||||
.unwrap_or_else(|| "no".into())
|
||||
);
|
||||
if let Some(pct) = state.volume_pct {
|
||||
println!(" loopback volume: {}", ui::blue(&format!("{pct}%")));
|
||||
}
|
||||
if let Some(mic) = &state.mic_source {
|
||||
println!(" mixed source: {mic}");
|
||||
if let Some(pct) = state.mic_volume_pct {
|
||||
println!(" source volume: {}", ui::blue(&format!("{pct}%")));
|
||||
}
|
||||
}
|
||||
if i + 1 < states.len() {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot.
|
||||
fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String {
|
||||
let Some(graph) = graph else {
|
||||
return "unknown (could not connect to PipeWire)".to_string();
|
||||
};
|
||||
let nodes = graph.nodes();
|
||||
let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
||||
let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
||||
match (sink_alive, mix_alive) {
|
||||
(true, true) => "active".to_string(),
|
||||
(true, false) => "degraded (mix loopback process not found)".to_string(),
|
||||
(false, true) => "degraded (sink loopback process not found)".to_string(),
|
||||
(false, false) => "stale (matching pw-loopback process not found)".to_string(),
|
||||
}
|
||||
}
|
||||
35
src/commands/mod.rs
Normal file
35
src/commands/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
pub mod create;
|
||||
pub mod delete;
|
||||
pub mod edit;
|
||||
pub mod list;
|
||||
pub mod route;
|
||||
pub mod wipe;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::pw::{loopback, PwGraph};
|
||||
use crate::state::VmicState;
|
||||
use crate::ui;
|
||||
|
||||
/// Normalizes a fraction (0.8) or percentage (80) to an integer percent,
|
||||
/// clamped to the storable range (0..=255 - `volume_pct`/`mic_volume_pct`
|
||||
/// are `u8`). Warns rather than silently dropping precision when clamped.
|
||||
pub fn to_pct(value: f32) -> Option<u8> {
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let pct = if value <= 1.0 { value * 100.0 } else { value };
|
||||
if pct > 255.0 {
|
||||
ui::warn(&format!("volume {}% exceeds the maximum of 255%; clamped.", pct.round()));
|
||||
}
|
||||
Some(pct.round().clamp(0.0, u8::MAX as f32) as u8)
|
||||
}
|
||||
|
||||
/// Unlinks a mixed-in hardware source and clears `state.mic_source`.
|
||||
/// Shared by `route -s off` and `delete`.
|
||||
pub(crate) fn unsource(graph: &PwGraph, state: &mut VmicState) -> Result<()> {
|
||||
let Some(mic) = state.mic_source.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
loopback::unlink_stage(graph, &mic, &state.mix_name)?;
|
||||
Ok(())
|
||||
}
|
||||
102
src/commands/route.rs
Normal file
102
src/commands/route.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use crate::cli::RouteArgs;
|
||||
use crate::error::{Result, VmicError};
|
||||
use crate::pw::{loopback, text, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Shorter than stage-creation's timeout since the source already exists -
|
||||
/// this is just retrying while its ports enumerate.
|
||||
const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub fn run(args: RouteArgs) -> Result<()> {
|
||||
let conn = state::open_db()?;
|
||||
let name = state::normalize(&args.name);
|
||||
let mut state = VmicState::load(&conn, &name)?;
|
||||
|
||||
if args.inputs.is_empty() && args.outputs.is_empty() && args.source.is_none() {
|
||||
return Err(VmicError::NothingToDo(
|
||||
"pass --input, --output and/or --source.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let graph = PwGraph::connect()?;
|
||||
|
||||
for filter in &args.inputs {
|
||||
move_stream("sink-inputs", "move-sink-input", &state.sink_name, filter)?;
|
||||
}
|
||||
for filter in &args.outputs {
|
||||
move_stream("source-outputs", "move-source-output", &state.source_name, filter)?;
|
||||
}
|
||||
if let Some(source) = &args.source {
|
||||
route_source(&graph, &mut state, source)?;
|
||||
state.save(&conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Matches streams via `pw::text` (name/description matching - see that
|
||||
/// module for why this stays text-based) then moves each with `pactl`.
|
||||
fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result<()> {
|
||||
let matches = text::match_streams(kind, filter)?;
|
||||
if matches.is_empty() {
|
||||
ui::warn(&format!("'{filter}': matched 0 streams, nothing moved."));
|
||||
return Ok(());
|
||||
}
|
||||
for m in &matches {
|
||||
std::process::Command::new("pactl")
|
||||
.args([move_cmd, &m.id, target])
|
||||
.status()?;
|
||||
}
|
||||
ui::info(&format!("'{filter}': matched {} stream(s) -> moved to '{target}'.", matches.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> {
|
||||
let lowered = value.to_lowercase();
|
||||
if matches!(lowered.as_str(), "off" | "none" | "disable") {
|
||||
let Some(mic) = state.mic_source.clone() else {
|
||||
ui::warn(&format!("no source is mixed into '{}'.", state.name));
|
||||
return Ok(());
|
||||
};
|
||||
super::unsource(graph, state)?;
|
||||
ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matches = text::match_source(value)?;
|
||||
match matches.len() {
|
||||
0 => return Err(VmicError::NoMatchingSource(value.to_string())),
|
||||
1 => {}
|
||||
_ => {
|
||||
let names: Vec<_> = matches.iter().map(|m| m.name.clone()).collect();
|
||||
return Err(VmicError::AmbiguousSource {
|
||||
value: value.to_string(),
|
||||
matches: names.join(", "),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mic = matches[0].name.clone();
|
||||
|
||||
// Replace a different currently-mixed source first.
|
||||
if state.mic_source.as_deref().is_some_and(|old| old != mic) {
|
||||
super::unsource(graph, state)?;
|
||||
}
|
||||
|
||||
let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?;
|
||||
state.mic_source = Some(mic.clone());
|
||||
|
||||
// Insurance: make sure the new links did not create a monitor ring.
|
||||
let prefix = format!("vmic_{}_", state.name.to_lowercase());
|
||||
let rings = graph.break_feedback_rings(&prefix)?;
|
||||
if rings > 0 {
|
||||
ui::warn(&format!("removed {rings} unexpected monitor feedback link(s)."));
|
||||
}
|
||||
|
||||
ui::ok(&format!(
|
||||
"Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).",
|
||||
state.name
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
103
src/commands/wipe.rs
Normal file
103
src/commands/wipe.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use crate::error::Result;
|
||||
use crate::pw::{loopback, text, PwGraph};
|
||||
use crate::state;
|
||||
use crate::ui;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let graph = PwGraph::connect()?;
|
||||
|
||||
let mods_removed = wipe_loopback_modules(&graph)?;
|
||||
let links_removed = graph.unlink_all_into_prefix("vmic_")?;
|
||||
let procs_killed = wipe_pw_loopback_processes();
|
||||
|
||||
let conn = state::open_db()?;
|
||||
let rows_removed = conn.execute("DELETE FROM vmics", [])? as u32;
|
||||
|
||||
restore_defaults();
|
||||
|
||||
if mods_removed + links_removed + procs_killed + rows_removed == 0 {
|
||||
ui::info("Nothing to wipe: no virtual mics or vmic loopbacks found.");
|
||||
} else {
|
||||
ui::ok("Wiped all virtual mics:");
|
||||
println!(" loopback modules unloaded: {mods_removed}");
|
||||
println!(" links removed: {links_removed}");
|
||||
println!(" pw-loopback processes killed: {procs_killed}");
|
||||
println!(" state rows removed: {rows_removed}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unloads every loopback module referencing a vmic_* node, tracked or not.
|
||||
fn wipe_loopback_modules(_graph: &PwGraph) -> Result<u32> {
|
||||
let ids = text::list_vmic_loopback_module_ids()?;
|
||||
let mut removed = 0;
|
||||
for id in ids {
|
||||
if loopback::unload_pulse_module(id).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Kills every pw-loopback process whose cmdline mentions a vmic_* node,
|
||||
/// tracked or not. SIGTERM only - no SIGKILL escalation, unlike
|
||||
/// `terminate_stage`.
|
||||
fn wipe_pw_loopback_processes() -> u32 {
|
||||
let mut killed = 0;
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return 0;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(pid) = entry.file_name().to_string_lossy().parse::<libc::pid_t>() else {
|
||||
continue;
|
||||
};
|
||||
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
|
||||
if comm.trim() != "pw-loopback" {
|
||||
continue;
|
||||
}
|
||||
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
|
||||
continue;
|
||||
};
|
||||
if String::from_utf8_lossy(&cmdline).contains("vmic_")
|
||||
&& unsafe { libc::kill(pid, libc::SIGTERM) } == 0
|
||||
{
|
||||
killed += 1;
|
||||
}
|
||||
}
|
||||
killed
|
||||
}
|
||||
|
||||
/// If the default sink/source pointed at a vmic node that just got wiped,
|
||||
/// point it back at the first non-vmic device instead of leaving it
|
||||
/// dangling.
|
||||
fn restore_defaults() {
|
||||
if default_output("get-default-sink").is_some_and(|s| s.starts_with("vmic_")) {
|
||||
if let Some(new_sink) = first_non_vmic("sinks", false) {
|
||||
let _ = Command::new("pactl").args(["set-default-sink", &new_sink]).status();
|
||||
ui::info(&format!("Default sink restored to '{new_sink}'."));
|
||||
}
|
||||
}
|
||||
if default_output("get-default-source").is_some_and(|s| s.starts_with("vmic_")) {
|
||||
if let Some(new_source) = first_non_vmic("sources", true) {
|
||||
let _ = Command::new("pactl").args(["set-default-source", &new_source]).status();
|
||||
ui::info(&format!("Default source restored to '{new_source}'."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output(subcommand: &str) -> Option<String> {
|
||||
let out = Command::new("pactl").arg(subcommand).output().ok()?;
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
fn first_non_vmic(kind: &str, exclude_monitors: bool) -> Option<String> {
|
||||
let out = Command::new("pactl").args(["list", "short", kind]).output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| l.split_whitespace().nth(1))
|
||||
.find(|name| !name.starts_with("vmic_") && !(exclude_monitors && name.ends_with(".monitor")))
|
||||
.map(str::to_string)
|
||||
}
|
||||
Reference in New Issue
Block a user