Format the code, improve vb cable warning for windows, Update available apps to output to automatically, merge the update functions, Use a label when no apps are available or unsupported. Add a stop all and pause all button, exclude apps that are not useful.

This commit is contained in:
csd4ni3l
2026-02-15 15:09:02 +01:00
parent 5a4ebe3467
commit d78c3c22c9
3 changed files with 228 additions and 107 deletions

View File

@@ -1,7 +1,11 @@
use rodio::{
use std::process::Command; OutputStream, OutputStreamBuilder,
cpal::{self, traits::HostTrait},
};
use serde_json::Value; use serde_json::Value;
use rodio::{OutputStream, OutputStreamBuilder, cpal::{self, traits::HostTrait}}; use std::process::Command;
const APPS_TO_EXCLUDE: [&str; 1] = ["plasmashell"];
fn pactl_list(sink_type: &str) -> Value { fn pactl_list(sink_type: &str) -> Value {
let command_output = Command::new("pactl") let command_output = Command::new("pactl")
@@ -10,9 +14,11 @@ fn pactl_list(sink_type: &str) -> Value {
.expect("Failed to execute process"); .expect("Failed to execute process");
if command_output.status.success() { if command_output.status.success() {
serde_json::from_str(str::from_utf8(&command_output.stdout).expect("Failed to convert to string")).expect("Failed to parse sink JSON output") serde_json::from_str(
} str::from_utf8(&command_output.stdout).expect("Failed to convert to string"),
else { )
.expect("Failed to parse sink JSON output")
} else {
Value::Null {} Value::Null {}
} }
} }
@@ -21,7 +27,12 @@ pub fn get_sink_by_index(sink_type: &str, index: String) -> Value {
let sinks = pactl_list(sink_type); let sinks = pactl_list(sink_type);
for sink in sinks.as_array().unwrap_or(&vec![]) { for sink in sinks.as_array().unwrap_or(&vec![]) {
if sink["index"].as_u64().expect("sink index is not a number").to_string() == index { if sink["index"]
.as_u64()
.expect("sink index is not a number")
.to_string()
== index
{
return sink.clone(); return sink.clone();
} }
} }
@@ -31,10 +42,11 @@ pub fn get_sink_by_index(sink_type: &str, index: String) -> Value {
fn find_soundboard_sinks() -> Vec<Value> { fn find_soundboard_sinks() -> Vec<Value> {
let sink_inputs = pactl_list("sink-inputs"); let sink_inputs = pactl_list("sink-inputs");
sink_inputs.as_array() sink_inputs
.as_array()
.unwrap_or(&vec![]) .unwrap_or(&vec![])
.iter() .iter()
.filter(|sink| {sink["properties"]["node.name"] == "alsa_playback.soundboard"}) .filter(|sink| sink["properties"]["node.name"] == "alsa_playback.soundboard")
.cloned() .cloned()
.collect() .collect()
} }
@@ -42,7 +54,10 @@ fn find_soundboard_sinks() -> Vec<Value> {
pub fn move_playback_to_sink() { pub fn move_playback_to_sink() {
let soundboard_sinks = find_soundboard_sinks(); let soundboard_sinks = find_soundboard_sinks();
for sink in soundboard_sinks { for sink in soundboard_sinks {
let index = sink["index"].as_u64().expect("sink index is not a number").to_string(); let index = sink["index"]
.as_u64()
.expect("sink index is not a number")
.to_string();
Command::new("pactl") Command::new("pactl")
.args(&["move-sink-input", index.as_str(), "SoundboardSink"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) .args(&["move-sink-input", index.as_str(), "SoundboardSink"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...)
.output() .output()
@@ -52,12 +67,25 @@ pub fn move_playback_to_sink() {
pub fn list_outputs() -> Vec<(String, String)> { pub fn list_outputs() -> Vec<(String, String)> {
let source_outputs = pactl_list("source-outputs"); let source_outputs = pactl_list("source-outputs");
return source_outputs.as_array().unwrap_or(&vec![]).iter().filter_map(|sink| { return source_outputs
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|sink| {
let app_name = sink["properties"]["application.name"].as_str()?; let app_name = sink["properties"]["application.name"].as_str()?;
let binary = sink["properties"]["application.process.binary"].as_str().unwrap_or("Unknown"); let binary = sink["properties"]["application.process.binary"]
let index = sink["index"].as_u64().expect("sink index is not a number").to_string(); .as_str()
.unwrap_or("Unknown");
if APPS_TO_EXCLUDE.contains(&binary) {
return None;
}
let index = sink["index"]
.as_u64()
.expect("sink index is not a number")
.to_string();
Some((format!("{} ({})", app_name, binary), index)) Some((format!("{} ({})", app_name, binary), index))
}).collect(); })
.collect();
} }
pub fn move_index_to_virtualmic(index: String) { pub fn move_index_to_virtualmic(index: String) {
@@ -69,35 +97,69 @@ pub fn move_index_to_virtualmic(index: String) {
pub fn create_virtual_mic_linux() -> OutputStream { pub fn create_virtual_mic_linux() -> OutputStream {
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-null-sink", "sink_name=SoundboardSink", "sink_properties=device.description=\"Soundboard_Audio\""]) .args(&[
"load-module",
"module-null-sink",
"sink_name=SoundboardSink",
"sink_properties=device.description=\"Soundboard_Audio\"",
])
.output() .output()
.expect("Failed to create SoundboardSink"); .expect("Failed to create SoundboardSink");
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-null-sink", "sink_name=VirtualMic", "sink_properties=device.description=\"Virtual_Microphone\""]) .args(&[
"load-module",
"module-null-sink",
"sink_name=VirtualMic",
"sink_properties=device.description=\"Virtual_Microphone\"",
])
.output() .output()
.expect("Failed to create VirtualMic"); .expect("Failed to create VirtualMic");
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-remap-source", "master=VirtualMic.monitor", "source_name=VirtualMicSource", "source_properties=device.description=\"Virtual_Mic_Source\""]) .args(&[
"load-module",
"module-remap-source",
"master=VirtualMic.monitor",
"source_name=VirtualMicSource",
"source_properties=device.description=\"Virtual_Mic_Source\"",
])
.output() .output()
.expect("Failed to create VirtualMicSource"); .expect("Failed to create VirtualMicSource");
// Soundboard audio -> speakers // Soundboard audio -> speakers
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-loopback", "source=SoundboardSink.monitor", "sink=@DEFAULT_SINK@", "latency_msec=1"]) .args(&[
"load-module",
"module-loopback",
"source=SoundboardSink.monitor",
"sink=@DEFAULT_SINK@",
"latency_msec=1",
])
.output() .output()
.expect("Failed to create soundboard to speakers loopback"); .expect("Failed to create soundboard to speakers loopback");
// Soundboard audio -> VirtualMic // Soundboard audio -> VirtualMic
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-loopback", "source=SoundboardSink.monitor", "sink=VirtualMic", "latency_msec=1"]) .args(&[
"load-module",
"module-loopback",
"source=SoundboardSink.monitor",
"sink=VirtualMic",
"latency_msec=1",
])
.output() .output()
.expect("Failed to create soundboard to VirtualMic loopback"); .expect("Failed to create soundboard to VirtualMic loopback");
// Microphone -> VirtualMic ONLY // Microphone -> VirtualMic ONLY
Command::new("pactl") Command::new("pactl")
.args(&["load-module", "module-loopback", "source=@DEFAULT_SOURCE@", "sink=VirtualMic", "latency_msec=1"]) .args(&[
"load-module",
"module-loopback",
"source=@DEFAULT_SOURCE@",
"sink=VirtualMic",
"latency_msec=1",
])
.output() .output()
.expect("Failed to create microphone loopback"); .expect("Failed to create microphone loopback");
@@ -112,7 +174,9 @@ pub fn create_virtual_mic_linux() -> OutputStream {
.expect("Failed to set soundboard volume"); .expect("Failed to set soundboard volume");
let host = cpal::host_from_id(cpal::HostId::Alsa).expect("Could not initialize ALSA"); let host = cpal::host_from_id(cpal::HostId::Alsa).expect("Could not initialize ALSA");
let device = host.default_output_device().expect("Could not get default output device"); let device = host
.default_output_device()
.expect("Could not get default output device");
let stream = OutputStreamBuilder::from_device(device) let stream = OutputStreamBuilder::from_device(device)
.expect("Unable to open VirtualMic") .expect("Unable to open VirtualMic")

View File

@@ -1,6 +1,6 @@
use bevy::{log::Level, prelude::*}; use bevy::{log::Level, prelude::*};
use std::{collections::HashMap, fs::File, io::BufReader, path::Path}; use std::{collections::HashMap, fs::File, io::BufReader, path::Path, time::Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -15,7 +15,8 @@ mod linux_lib;
mod windows_lib; mod windows_lib;
use rodio::{ use rodio::{
Decoder, OutputStream, Sink, Source, cpal::{self, traits::HostTrait}, OutputStreamBuilder Decoder, OutputStream, OutputStreamBuilder, Sink, Source,
cpal::{self, traits::HostTrait},
}; };
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -29,7 +30,7 @@ struct PlayingSound {
length: f32, length: f32,
sink: Sink, sink: Sink,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
normal_sink: Sink normal_sink: Sink,
} }
struct SoundSystem { struct SoundSystem {
@@ -49,6 +50,7 @@ struct AppState {
virt_outputs: Vec<(String, String)>, virt_outputs: Vec<(String, String)>,
virt_output_index_switch: String, virt_output_index_switch: String,
virt_output_index: String, virt_output_index: String,
last_virt_output_update: Instant
} }
const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"];
@@ -75,7 +77,9 @@ fn create_virtual_mic() -> SoundSystem {
#[allow(unreachable_code)] #[allow(unreachable_code)]
{ {
let host = cpal::default_host(); let host = cpal::default_host();
let device = host.default_output_device().expect("Could not get default output device"); let device = host
.default_output_device()
.expect("Could not get default output device");
SoundSystem { SoundSystem {
output_stream: OutputStreamBuilder::from_device(device) output_stream: OutputStreamBuilder::from_device(device)
.expect("Unable to open device") .expect("Unable to open device")
@@ -101,7 +105,7 @@ fn reload_sound() -> SoundSystem {
fn list_outputs() -> Vec<(String, String)> { fn list_outputs() -> Vec<(String, String)> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return Vec::from([("Select in apps".to_string(), String::from("9999999"))]); return Vec::from([("Select inside apps".to_string(), String::from("9999999"))]);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
return linux_lib::list_outputs(); return linux_lib::list_outputs();
@@ -139,6 +143,7 @@ fn main() {
virt_outputs: Vec::new(), virt_outputs: Vec::new(),
virt_output_index_switch: String::from("0"), virt_output_index_switch: String::from("0"),
virt_output_index: String::from("999"), virt_output_index: String::from("999"),
last_virt_output_update: Instant::now()
}) })
.add_systems( .add_systems(
PreStartup, PreStartup,
@@ -147,16 +152,25 @@ fn main() {
.add_systems(Startup, load_system) .add_systems(Startup, load_system)
.add_systems( .add_systems(
EguiPrimaryContextPass, EguiPrimaryContextPass,
(ui_system, update_ui_scale_factor_system, update_virtualmic), (draw, update_ui_scale_factor_system, update),
) )
.run(); .run();
} }
fn update_virtualmic(mut app_state: ResMut<AppState>) { fn update(mut app_state: ResMut<AppState>) {
if app_state.last_virt_output_update.elapsed().as_secs_f32() >= 3.0 {
app_state.last_virt_output_update = Instant::now();
app_state.virt_outputs = list_outputs();
}
if app_state.virt_outputs.is_empty() { if app_state.virt_outputs.is_empty() {
return; return;
} }
if !(app_state.virt_output_index == "999".to_string()) {
app_state.virt_output_index_switch = app_state.virt_outputs[0].1.clone();
}
if app_state.virt_output_index != app_state.virt_output_index_switch { if app_state.virt_output_index != app_state.virt_output_index_switch {
app_state.virt_output_index = app_state.virt_output_index_switch.clone(); app_state.virt_output_index = app_state.virt_output_index_switch.clone();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -165,7 +179,6 @@ fn update_virtualmic(mut app_state: ResMut<AppState>) {
} }
fn load_system(mut app_state: ResMut<AppState>) { fn load_system(mut app_state: ResMut<AppState>) {
app_state.virt_outputs = list_outputs();
if !app_state.virt_outputs.is_empty() { if !app_state.virt_outputs.is_empty() {
app_state.virt_output_index_switch = app_state.virt_outputs[0].1.clone(); app_state.virt_output_index_switch = app_state.virt_outputs[0].1.clone();
} }
@@ -245,17 +258,18 @@ fn play_sound(file_path: String, app_state: &mut AppState) {
normal_sink: { normal_sink: {
let file2 = File::open(&file_path).unwrap(); let file2 = File::open(&file_path).unwrap();
let src2 = Decoder::new(BufReader::new(file2)).unwrap(); let src2 = Decoder::new(BufReader::new(file2)).unwrap();
let normal_sink = Sink::connect_new(&app_state.sound_system.normal_output_stream.mixer()); let normal_sink =
Sink::connect_new(&app_state.sound_system.normal_output_stream.mixer());
normal_sink.append(src2); normal_sink.append(src2);
normal_sink.play(); normal_sink.play();
normal_sink normal_sink
} },
}; };
app_state.currently_playing.push(playing_sound); app_state.currently_playing.push(playing_sound);
} }
fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result { fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result {
let ctx = contexts.ctx_mut()?; let ctx = contexts.ctx_mut()?;
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
@@ -270,23 +284,13 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
let available_width = ui.available_width(); let available_width = ui.available_width();
let available_height = ui.available_height(); let available_height = ui.available_height();
let outputs = app_state.virt_outputs.clone(); let outputs = app_state.virt_outputs.clone();
ui.label("Virtual Mic Output");
#[allow(unused_mut)] if cfg!(target_os = "linux") {
let mut mic_name = "Select inside apps".to_string();
#[cfg(target_os = "linux")]
{
let output_index = app_state.virt_output_index.clone(); let output_index = app_state.virt_output_index.clone();
let output_sink = linux_lib::get_sink_by_index("source-outputs", output_index); let output_sink = linux_lib::get_sink_by_index("source-outputs", output_index);
if let Some(app_name) = output_sink["properties"]["application.name"].as_str() { if let Some(app_name) = output_sink["properties"]["application.name"].as_str() {
mic_name = app_name.to_string();
}
}
ui.label("Virtual Mic Output");
egui::ComboBox::from_id_salt("Virtual Mic Output") egui::ComboBox::from_id_salt("Virtual Mic Output")
.selected_text(mic_name) .selected_text(app_name.to_string())
.width(available_width) .width(available_width)
.height(available_height / 15.0) .height(available_height / 15.0)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
@@ -298,6 +302,14 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
); );
} }
}); });
}
else {
ui.add(egui::Button::new("No apps found to use.".to_string()));
}
}
else {
ui.add(egui::Button::new("Unsupported. Select inside apps.".to_string()));
}
if ui if ui
.add_sized( .add_sized(
@@ -358,13 +370,6 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
}); });
egui::TopBottomPanel::bottom("currently_playing").show(ctx, |ui| { egui::TopBottomPanel::bottom("currently_playing").show(ctx, |ui| {
ui.horizontal(|ui| {
if app_state.sound_system.paused {
ui.heading("Paused");
} else {
ui.heading("Playing");
}
ui.vertical(|ui| { ui.vertical(|ui| {
for playing_sound in &app_state.currently_playing { for playing_sound in &app_state.currently_playing {
ui.label(format!( ui.label(format!(
@@ -374,8 +379,39 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
playing_sound.length playing_sound.length
)); ));
} }
})
}); });
let available_width = ui.available_width();
let available_height = ui.available_height();
if ui
.add_sized(
[available_width, available_height / 15.0],
egui::Button::new("Stop all"),
)
.clicked()
{
app_state.currently_playing.clear();
}
if ui
.add_sized(
[available_width, available_height / 15.0],
egui::Button::new(if app_state.sound_system.paused {"Resume"} else {"Pause"}),
)
.clicked()
{
app_state.sound_system.paused = !app_state.sound_system.paused;
if app_state.sound_system.paused {
for sound in &app_state.currently_playing {
sound.sink.pause();
}
}
else {
for sound in &app_state.currently_playing {
sound.sink.play();
}
}
}
}); });
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {

View File

@@ -1,14 +1,35 @@
use rodio::{OutputStream, OutputStreamBuilder, cpal::{self, traits::HostTrait, traits::DeviceTrait}}; use rodio::{
OutputStream, OutputStreamBuilder,
cpal::{self, traits::DeviceTrait, traits::HostTrait},
};
pub fn create_virtual_mic_windows() -> (OutputStream, OutputStream) { pub fn create_virtual_mic_windows() -> (OutputStream, OutputStream) {
let host = cpal::host_from_id(cpal::HostId::Wasapi).expect("Could not initialize audio routing using WasAPI"); let host = cpal::host_from_id(cpal::HostId::Wasapi)
let virtual_mic = host.output_devices().expect("Could not list Output devices").find(|device| { .expect("Could not initialize audio routing using WasAPI");
device.name().ok().map(|name|{ let virtual_mic = host
name.contains("CABLE Input") || name.contains("VB-Audio") .output_devices()
}).unwrap_or(false) .expect("Could not list Output devices")
}).expect("Could not get default output device"); .find(|device| {
device
.name()
.ok()
.map(|name| name.contains("CABLE Input") || name.contains("VB-Audio"))
.unwrap_or(false)
})
.expect("Could not get VB Cable output device. Is VB Cable Driver installed?");
let normal_output = host.default_output_device().expect("Could not get default output device"); let normal_output = host
.default_output_device()
.expect("Could not get default output device");
return (OutputStreamBuilder::from_device(normal_output).expect("Unable to open default audio device").open_stream().expect("Failed to open stream"), OutputStreamBuilder::from_device(virtual_mic).expect("Unable to open default audio device").open_stream().expect("Failed to open stream")); return (
OutputStreamBuilder::from_device(normal_output)
.expect("Unable to open default audio device")
.open_stream()
.expect("Failed to open stream"),
OutputStreamBuilder::from_device(virtual_mic)
.expect("Unable to open default audio device")
.open_stream()
.expect("Failed to open stream"),
);
} }