Initial release: real-time voice changer in pure Rust
Phase-vocoder pitch/formant shifting, 12 presets, effect chain, noise gate, WAV recording, full-screen TUI (arrow/mouse control) and a built-in PipeWire/PulseAudio virtual mic (vois.rs). GPL-3.0-or-later.
This commit is contained in:
commit
b0d685a110
25 changed files with 6243 additions and 0 deletions
69
src/audio/capture.rs
Normal file
69
src/audio/capture.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use anyhow::{Context, Result};
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use cpal::{
|
||||
Device, FromSample, Host, Sample, SampleFormat, SizedSample, Stream, StreamConfig,
|
||||
SupportedStreamConfig,
|
||||
};
|
||||
use ringbuf::traits::Producer;
|
||||
|
||||
pub fn find_input(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||
match name {
|
||||
Some(name) => host
|
||||
.input_devices()
|
||||
.context("failed to enumerate input devices")?
|
||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||
.with_context(|| format!("input device {name:?} not found")),
|
||||
None => host
|
||||
.default_input_device()
|
||||
.context("no default input device found"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the input stream. Captured samples are converted to `f32` (interleaved
|
||||
/// frames) and pushed into `tx`.
|
||||
pub fn start<P>(device: &Device, supported: &SupportedStreamConfig, tx: P) -> Result<Stream>
|
||||
where
|
||||
P: Producer<Item = f32> + Send + 'static,
|
||||
{
|
||||
let config: StreamConfig = supported.config();
|
||||
match supported.sample_format() {
|
||||
SampleFormat::F32 => build::<f32, P>(device, &config, tx),
|
||||
SampleFormat::F64 => build::<f64, P>(device, &config, tx),
|
||||
SampleFormat::I8 => build::<i8, P>(device, &config, tx),
|
||||
SampleFormat::I16 => build::<i16, P>(device, &config, tx),
|
||||
SampleFormat::I32 => build::<i32, P>(device, &config, tx),
|
||||
SampleFormat::I64 => build::<i64, P>(device, &config, tx),
|
||||
SampleFormat::U8 => build::<u8, P>(device, &config, tx),
|
||||
SampleFormat::U16 => build::<u16, P>(device, &config, tx),
|
||||
SampleFormat::U32 => build::<u32, P>(device, &config, tx),
|
||||
SampleFormat::U64 => build::<u64, P>(device, &config, tx),
|
||||
format => anyhow::bail!("unsupported input sample format: {format:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build<T, P>(device: &Device, config: &StreamConfig, mut tx: P) -> Result<Stream>
|
||||
where
|
||||
T: SizedSample,
|
||||
f32: FromSample<T>,
|
||||
P: Producer<Item = f32> + Send + 'static,
|
||||
{
|
||||
let channels = config.channels as usize;
|
||||
let mut frame = vec![0f32; channels];
|
||||
let err_fn = |err| eprintln!("capture stream error: {err}");
|
||||
|
||||
device
|
||||
.build_input_stream(
|
||||
config,
|
||||
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||
for chunk in data.chunks(channels) {
|
||||
for (slot, sample) in frame.iter_mut().zip(chunk) {
|
||||
*slot = f32::from_sample(*sample);
|
||||
}
|
||||
let _ = tx.push_slice(&frame);
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("failed to build input stream: {e}"))
|
||||
}
|
||||
18
src/audio/devices.rs
Normal file
18
src/audio/devices.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use anyhow::Result;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
pub fn print_all() -> Result<()> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
println!("Input devices:");
|
||||
for device in host.input_devices()? {
|
||||
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||
}
|
||||
|
||||
println!("Output devices:");
|
||||
for device in host.output_devices()? {
|
||||
println!(" {}", device.name().unwrap_or_else(|_| "<unnamed>".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
191
src/audio/mod.rs
Normal file
191
src/audio/mod.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
pub mod capture;
|
||||
pub mod devices;
|
||||
pub mod playback;
|
||||
pub mod record;
|
||||
pub mod virtual_mic;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use cpal::{SampleRate, StreamConfig, SupportedStreamConfig, SupportedStreamConfigRange};
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::HeapRb;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::dsp;
|
||||
use crate::ui;
|
||||
|
||||
/// Higher = better quality for our purposes.
|
||||
fn format_rank(format: cpal::SampleFormat) -> u8 {
|
||||
match format {
|
||||
cpal::SampleFormat::F32 => 10,
|
||||
cpal::SampleFormat::I16 => 9,
|
||||
cpal::SampleFormat::I32 => 8,
|
||||
cpal::SampleFormat::F64 => 7,
|
||||
cpal::SampleFormat::U16 => 6,
|
||||
cpal::SampleFormat::I8 => 5,
|
||||
cpal::SampleFormat::U8 => 4,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the best supported config with the exact sample rate (same channel
|
||||
/// count as `channels`). This is how we force both devices onto one sample
|
||||
/// rate while preferring a high-quality sample format.
|
||||
fn pick_config(
|
||||
ranges: impl Iterator<Item = SupportedStreamConfigRange>,
|
||||
channels: u16,
|
||||
rate: SampleRate,
|
||||
) -> Option<SupportedStreamConfig> {
|
||||
ranges
|
||||
.filter(|r| r.channels() == channels && r.try_with_sample_rate(rate).is_some())
|
||||
.max_by_key(|r| format_rank(r.sample_format()))
|
||||
.map(|r| r.with_sample_rate(rate))
|
||||
}
|
||||
|
||||
pub fn run(cfg: &AppConfig) -> Result<()> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
let input_device = capture::find_input(&host, cfg.input.as_deref())?;
|
||||
let output_device = playback::find_output(&host, cfg.output.as_deref())?;
|
||||
|
||||
let in_supported = input_device
|
||||
.default_input_config()
|
||||
.context("failed to read default input config")?;
|
||||
|
||||
let rate = cfg
|
||||
.sample_rate
|
||||
.map(SampleRate)
|
||||
.unwrap_or_else(|| in_supported.sample_rate());
|
||||
|
||||
let in_supported = if cfg.sample_rate.is_some() {
|
||||
pick_config(
|
||||
input_device.supported_input_configs()?,
|
||||
in_supported.channels(),
|
||||
rate,
|
||||
)
|
||||
.with_context(|| format!("input device does not support sample rate {} Hz", rate.0))?
|
||||
} else {
|
||||
in_supported
|
||||
};
|
||||
|
||||
let out_default = output_device
|
||||
.default_output_config()
|
||||
.context("failed to read default output config")?;
|
||||
let out_supported = pick_config(
|
||||
output_device.supported_output_configs()?,
|
||||
out_default.channels(),
|
||||
rate,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"output device does not support sample rate {} Hz; try `--sample-rate`",
|
||||
rate.0
|
||||
)
|
||||
})?;
|
||||
|
||||
let in_cfg: StreamConfig = in_supported.config();
|
||||
let out_cfg: StreamConfig = out_supported.config();
|
||||
|
||||
println!(
|
||||
"input : {} @ {} Hz, {:?}, {} ch",
|
||||
input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||
in_cfg.sample_rate.0,
|
||||
in_supported.sample_format(),
|
||||
in_cfg.channels,
|
||||
);
|
||||
println!(
|
||||
"output : {} @ {} Hz, {:?}, {} ch",
|
||||
output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||
out_cfg.sample_rate.0,
|
||||
out_supported.sample_format(),
|
||||
out_cfg.channels,
|
||||
);
|
||||
|
||||
let ring_cap = (rate.0 as usize * 2).max(8192);
|
||||
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)?;
|
||||
|
||||
let in_ch = in_cfg.channels as usize;
|
||||
let out_ch = out_cfg.channels as usize;
|
||||
|
||||
let control = Arc::new(Mutex::new(cfg.to_control()));
|
||||
let stats = Arc::new(Mutex::new(dsp::stats::Stats::default()));
|
||||
let worker_control = Arc::clone(&control);
|
||||
let worker_stats = Arc::clone(&stats);
|
||||
let worker = thread::spawn(move || {
|
||||
dsp::chain::run_worker(
|
||||
in_cons,
|
||||
out_prod,
|
||||
in_ch,
|
||||
out_ch,
|
||||
rate.0,
|
||||
worker_control,
|
||||
worker_stats,
|
||||
)
|
||||
});
|
||||
|
||||
input_stream
|
||||
.play()
|
||||
.context("failed to start input stream")?;
|
||||
output_stream
|
||||
.play()
|
||||
.context("failed to start output stream")?;
|
||||
let cleanup = if let Some(prev) = prev_sink {
|
||||
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let handle = virtual_mic::VirtualMic::keep_others_off(Arc::clone(&stop), prev.clone());
|
||||
let _sig = virtual_mic::VirtualMic::restore_on_signal(prev.clone());
|
||||
Some((stop, handle, prev))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let info = ui::SessionInfo {
|
||||
input: input_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||
output: output_device.name().unwrap_or_else(|_| "<unnamed>".into()),
|
||||
sample_rate: rate.0,
|
||||
in_ch,
|
||||
out_ch,
|
||||
format_in: format!("{:?}", in_supported.sample_format()),
|
||||
format_out: format!("{:?}", out_supported.sample_format()),
|
||||
};
|
||||
|
||||
// Keep the streams alive while the UI blocks.
|
||||
let _streams = (input_stream, output_stream);
|
||||
|
||||
ui::tui::run(control, stats, info);
|
||||
|
||||
if let Some((stop, handle, prev)) = cleanup {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = handle.join();
|
||||
virtual_mic::VirtualMic::set_default_sink(&prev);
|
||||
}
|
||||
|
||||
println!("waiting for DSP worker to finish...");
|
||||
let _ = worker.join();
|
||||
|
||||
// cpal's ALSA teardown can panic on PipeWire when the stream drop races
|
||||
// with the worker thread closing its self-pipe. The process is about to
|
||||
// exit anyway, so leak the streams instead of dropping them.
|
||||
std::mem::forget(_streams);
|
||||
println!("done.");
|
||||
Ok(())
|
||||
}
|
||||
75
src/audio/playback.rs
Normal file
75
src/audio/playback.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use anyhow::{Context, Result};
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
use cpal::{
|
||||
Device, FromSample, Host, SampleFormat, SizedSample, Stream, StreamConfig,
|
||||
SupportedStreamConfig,
|
||||
};
|
||||
use ringbuf::traits::Consumer;
|
||||
|
||||
pub fn find_output(host: &Host, name: Option<&str>) -> Result<Device> {
|
||||
match name {
|
||||
Some(name) => host
|
||||
.output_devices()
|
||||
.context("failed to enumerate output devices")?
|
||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||
.with_context(|| format!("output device {name:?} not found")),
|
||||
None => host
|
||||
.default_output_device()
|
||||
.context("no default output device found"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the output stream. Pulls interleaved `f32` frames from `rx`; fills
|
||||
/// with silence when the DSP worker has nothing ready yet.
|
||||
pub fn start<C>(device: &Device, supported: &SupportedStreamConfig, rx: C) -> Result<Stream>
|
||||
where
|
||||
C: Consumer<Item = f32> + Send + 'static,
|
||||
{
|
||||
let config: StreamConfig = supported.config();
|
||||
match supported.sample_format() {
|
||||
SampleFormat::F32 => build::<f32, C>(device, &config, rx),
|
||||
SampleFormat::F64 => build::<f64, C>(device, &config, rx),
|
||||
SampleFormat::I8 => build::<i8, C>(device, &config, rx),
|
||||
SampleFormat::I16 => build::<i16, C>(device, &config, rx),
|
||||
SampleFormat::I32 => build::<i32, C>(device, &config, rx),
|
||||
SampleFormat::I64 => build::<i64, C>(device, &config, rx),
|
||||
SampleFormat::U8 => build::<u8, C>(device, &config, rx),
|
||||
SampleFormat::U16 => build::<u16, C>(device, &config, rx),
|
||||
SampleFormat::U32 => build::<u32, C>(device, &config, rx),
|
||||
SampleFormat::U64 => build::<u64, C>(device, &config, rx),
|
||||
format => anyhow::bail!("unsupported output sample format: {format:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build<T, C>(device: &Device, config: &StreamConfig, mut rx: C) -> Result<Stream>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
C: Consumer<Item = f32> + Send + 'static,
|
||||
{
|
||||
let mut scratch = vec![0f32; 2048];
|
||||
let err_fn = |err| eprintln!("playback stream error: {err}");
|
||||
|
||||
device
|
||||
.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
let mut written = 0usize;
|
||||
while written < data.len() {
|
||||
let want = (data.len() - written).min(scratch.len());
|
||||
let n = rx.pop_slice(&mut scratch[..want]);
|
||||
if n == 0 {
|
||||
data[written..].fill(T::from_sample(0.0f32));
|
||||
break;
|
||||
}
|
||||
let out = &mut data[written..written + n];
|
||||
for (out_sample, in_sample) in out.iter_mut().zip(&scratch[..n]) {
|
||||
*out_sample = T::from_sample(*in_sample);
|
||||
}
|
||||
written += n;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("failed to build output stream: {e}"))
|
||||
}
|
||||
40
src/audio/record.rs
Normal file
40
src/audio/record.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! WAV recording of the processed mono stream via a dedicated writer thread.
|
||||
|
||||
use hound::{SampleFormat, WavSpec, WavWriter};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::thread;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
pub fn spawn_writer(path: String, sample_rate: u32) -> (Sender<Vec<f32>>, JoinHandle<()>) {
|
||||
let (tx, rx) = mpsc::channel::<Vec<f32>>();
|
||||
let handle = thread::spawn(move || {
|
||||
let spec = WavSpec {
|
||||
channels: 1,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: SampleFormat::Int,
|
||||
};
|
||||
let mut writer = match WavWriter::create(&path, spec) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("record: cannot create {path}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(block) = rx.recv() {
|
||||
for &s in &block {
|
||||
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
if writer.write_sample(v).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = writer.finalize() {
|
||||
eprintln!("record: {e}");
|
||||
} else {
|
||||
println!("recorded to {path}");
|
||||
}
|
||||
});
|
||||
(tx, handle)
|
||||
}
|
||||
197
src/audio/virtual_mic.rs
Normal file
197
src/audio/virtual_mic.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// Virtual microphone setup for PipeWire/PulseAudio.
|
||||
//
|
||||
// Creates a "vois.rs" null sink and remaps its monitor source to "vois.rs".
|
||||
// The app does NOT hijack the default sink permanently: it briefly points the
|
||||
// default sink at `vois.rs` while its own output stream is created (so the
|
||||
// processed voice lands in the virtual mic) and restores it right after.
|
||||
// Other apps' streams are not moved, so their audio never leaks into the mic.
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct VirtualMic;
|
||||
|
||||
impl VirtualMic {
|
||||
pub fn setup() -> Result<Option<String>> {
|
||||
let pactl = "pactl";
|
||||
|
||||
let sinks = Command::new(pactl)
|
||||
.args(["list", "short", "sinks"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short sinks`")?;
|
||||
if !sinks.status.success() {
|
||||
bail!("`pactl list short sinks` failed");
|
||||
}
|
||||
let sinks_out = String::from_utf8_lossy(&sinks.stdout);
|
||||
|
||||
if !has_entry(&sinks_out, "vois.rs") {
|
||||
let out = Command::new(pactl)
|
||||
.args([
|
||||
"load-module",
|
||||
"module-null-sink",
|
||||
"sink_name=vois.rs",
|
||||
"sink_properties=device.description=vois virtual mic",
|
||||
])
|
||||
.output()
|
||||
.context("failed to create the vois.rs null sink")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"`pactl load-module module-null-sink sink_name=vois.rs` failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let sources = Command::new(pactl)
|
||||
.args(["list", "short", "sources"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short sources`")?;
|
||||
if !sources.status.success() {
|
||||
bail!("`pactl list short sources` failed");
|
||||
}
|
||||
let sources_out = String::from_utf8_lossy(&sources.stdout);
|
||||
|
||||
if !has_entry(&sources_out, "vois.rs") {
|
||||
let out = Command::new(pactl)
|
||||
.args([
|
||||
"load-module",
|
||||
"module-remap-source",
|
||||
"source_name=vois.rs",
|
||||
"master=vois.rs.monitor",
|
||||
])
|
||||
.output()
|
||||
.context("failed to create the vois.rs remap source")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"`pactl load-module module-remap-source source_name=vois.rs` failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some("vois.rs".to_string()))
|
||||
}
|
||||
|
||||
/// Current default sink name, if any.
|
||||
pub fn get_default_sink() -> Option<String> {
|
||||
let out = Command::new("pactl")
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.ok()?;
|
||||
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_default_sink(name: &str) {
|
||||
let _ = Command::new("pactl")
|
||||
.args(["set-default-sink", name])
|
||||
.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`.
|
||||
///
|
||||
/// 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.
|
||||
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let target_sink_id = sink_id_named("vois.rs");
|
||||
let prev_id = sink_id_named(&prev_sink);
|
||||
if let (Some(lv), Some(pv)) = (target_sink_id, prev_id) {
|
||||
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 {
|
||||
let _ = Command::new("pactl")
|
||||
.args(["move-sink-input", id, &pv])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore the default sink if the process is killed (SIGINT/SIGTERM),
|
||||
/// so the user's audio isn't left pointing at the silent null sink.
|
||||
pub fn restore_on_signal(prev: String) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
let mut signals = match signal_hook::iterator::Signals::new([
|
||||
signal_hook::consts::signal::SIGINT,
|
||||
signal_hook::consts::signal::SIGTERM,
|
||||
]) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(_sig) = signals.forever().next() {
|
||||
Self::set_default_sink(&prev);
|
||||
std::process::exit(130);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sink_id_named(name: &str) -> Option<String> {
|
||||
let out = Command::new("pactl")
|
||||
.args(["list", "short", "sinks"])
|
||||
.output()
|
||||
.ok()?;
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 2 && fields[1] == name {
|
||||
return Some(fields[0].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn client_names() -> Result<std::collections::HashMap<String, String>> {
|
||||
let out = Command::new("pactl")
|
||||
.args(["list", "short", "clients"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short clients`")?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 3 {
|
||||
map.insert(fields[0].to_string(), fields[2].to_string());
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn sink_inputs() -> Result<Vec<(String, String, String)>> {
|
||||
let out = Command::new("pactl")
|
||||
.args(["list", "short", "sink-inputs"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short sink-inputs`")?;
|
||||
let mut inputs = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
if fields.len() >= 3 {
|
||||
inputs.push((
|
||||
fields[0].to_string(),
|
||||
fields[1].to_string(),
|
||||
fields[2].to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(inputs)
|
||||
}
|
||||
|
||||
fn has_entry(list: &str, name: &str) -> bool {
|
||||
list.lines()
|
||||
.any(|l| l.split_whitespace().nth(1).is_some_and(|n| n == name))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue