diff --git a/src/linux_lib.rs b/src/linux_lib.rs index adf6bf8..bc65d82 100644 --- a/src/linux_lib.rs +++ b/src/linux_lib.rs @@ -1,7 +1,11 @@ - -use std::process::Command; +use rodio::{ + OutputStream, OutputStreamBuilder, + cpal::{self, traits::HostTrait}, +}; 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 { let command_output = Command::new("pactl") @@ -10,10 +14,12 @@ fn pactl_list(sink_type: &str) -> Value { .expect("Failed to execute process"); 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") - } - else { - Value::Null{} + serde_json::from_str( + str::from_utf8(&command_output.stdout).expect("Failed to convert to string"), + ) + .expect("Failed to parse sink JSON output") + } else { + Value::Null {} } } @@ -21,83 +27,139 @@ pub fn get_sink_by_index(sink_type: &str, index: String) -> Value { let sinks = pactl_list(sink_type); 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 Value::Null{}; + return Value::Null {}; } fn find_soundboard_sinks() -> Vec { let sink_inputs = pactl_list("sink-inputs"); - sink_inputs.as_array() - .unwrap_or(&vec![]) - .iter() - .filter(|sink| {sink["properties"]["node.name"] == "alsa_playback.soundboard"}) - .cloned() - .collect() -} + sink_inputs + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter(|sink| sink["properties"]["node.name"] == "alsa_playback.soundboard") + .cloned() + .collect() +} pub fn move_playback_to_sink() { let soundboard_sinks = find_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") - .args(&["move-sink-input", index.as_str(), "SoundboardSink"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) - .output() - .expect("Failed to execute process"); + .args(&["move-sink-input", index.as_str(), "SoundboardSink"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) + .output() + .expect("Failed to execute process"); } } pub fn list_outputs() -> Vec<(String, String)> { let source_outputs = pactl_list("source-outputs"); - return source_outputs.as_array().unwrap_or(&vec![]).iter().filter_map(|sink| { - let app_name = sink["properties"]["application.name"].as_str()?; - let binary = sink["properties"]["application.process.binary"].as_str().unwrap_or("Unknown"); - let index = sink["index"].as_u64().expect("sink index is not a number").to_string(); - Some((format!("{} ({})", app_name, binary), index)) - }).collect(); + return source_outputs + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|sink| { + let app_name = sink["properties"]["application.name"].as_str()?; + let binary = sink["properties"]["application.process.binary"] + .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)) + }) + .collect(); } pub fn move_index_to_virtualmic(index: String) { Command::new("pactl") - .args(&["move-source-output", index.as_str(), "VirtualMicSource"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) - .output() - .expect("Failed to execute process"); + .args(&["move-source-output", index.as_str(), "VirtualMicSource"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) + .output() + .expect("Failed to execute process"); } pub fn create_virtual_mic_linux() -> OutputStream { 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() .expect("Failed to create SoundboardSink"); - + 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() .expect("Failed to create VirtualMic"); - + 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() .expect("Failed to create VirtualMicSource"); // Soundboard audio -> speakers 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() .expect("Failed to create soundboard to speakers loopback"); - + // Soundboard audio -> VirtualMic 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() .expect("Failed to create soundboard to VirtualMic loopback"); - + // Microphone -> VirtualMic ONLY 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() .expect("Failed to create microphone loopback"); @@ -105,14 +167,16 @@ pub fn create_virtual_mic_linux() -> OutputStream { .args(&["set-sink-volume", "VirtualMic", "100%"]) .output() .expect("Failed to set volume"); - + Command::new("pactl") .args(&["set-sink-volume", "SoundboardSink", "100%"]) .output() .expect("Failed to set soundboard volume"); - + 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) .expect("Unable to open VirtualMic") @@ -143,4 +207,4 @@ pub fn reload_sound() { } else { println!("Error: {}", String::from_utf8_lossy(&output.stderr)); } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 7851ea6..0743c86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ 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}; @@ -15,7 +15,8 @@ mod linux_lib; mod windows_lib; use rodio::{ - Decoder, OutputStream, Sink, Source, cpal::{self, traits::HostTrait}, OutputStreamBuilder + Decoder, OutputStream, OutputStreamBuilder, Sink, Source, + cpal::{self, traits::HostTrait}, }; #[derive(Serialize, Deserialize)] @@ -29,7 +30,7 @@ struct PlayingSound { length: f32, sink: Sink, #[cfg(target_os = "windows")] - normal_sink: Sink + normal_sink: Sink, } struct SoundSystem { @@ -49,6 +50,7 @@ struct AppState { virt_outputs: Vec<(String, String)>, virt_output_index_switch: String, virt_output_index: String, + last_virt_output_update: Instant } const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; @@ -75,7 +77,9 @@ fn create_virtual_mic() -> SoundSystem { #[allow(unreachable_code)] { 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 { output_stream: OutputStreamBuilder::from_device(device) .expect("Unable to open device") @@ -101,7 +105,7 @@ fn reload_sound() -> SoundSystem { fn list_outputs() -> Vec<(String, String)> { #[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")] return linux_lib::list_outputs(); @@ -139,6 +143,7 @@ fn main() { virt_outputs: Vec::new(), virt_output_index_switch: String::from("0"), virt_output_index: String::from("999"), + last_virt_output_update: Instant::now() }) .add_systems( PreStartup, @@ -147,16 +152,25 @@ fn main() { .add_systems(Startup, load_system) .add_systems( EguiPrimaryContextPass, - (ui_system, update_ui_scale_factor_system, update_virtualmic), + (draw, update_ui_scale_factor_system, update), ) .run(); } -fn update_virtualmic(mut app_state: ResMut) { +fn update(mut app_state: ResMut) { + 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() { 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 { app_state.virt_output_index = app_state.virt_output_index_switch.clone(); #[cfg(target_os = "linux")] @@ -164,8 +178,7 @@ fn update_virtualmic(mut app_state: ResMut) { } } -fn load_system(mut app_state: ResMut) { - app_state.virt_outputs = list_outputs(); +fn load_system(mut app_state: ResMut) { if !app_state.virt_outputs.is_empty() { app_state.virt_output_index_switch = app_state.virt_outputs[0].1.clone(); } @@ -232,7 +245,7 @@ fn play_sound(file_path: String, app_state: &mut AppState) { .total_duration() .expect("Could not get source duration") .as_secs_f32(); - + let sink = Sink::connect_new(&app_state.sound_system.output_stream.mixer()); sink.append(src); sink.play(); @@ -245,17 +258,18 @@ fn play_sound(file_path: String, app_state: &mut AppState) { normal_sink: { let file2 = File::open(&file_path).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.play(); normal_sink - } + }, }; app_state.currently_playing.push(playing_sound); } -fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { +fn draw(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { let ctx = contexts.ctx_mut()?; egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { @@ -270,34 +284,32 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Res let available_width = ui.available_width(); let available_height = ui.available_height(); let outputs = app_state.virt_outputs.clone(); - - #[allow(unused_mut)] - let mut mic_name = "Select inside apps".to_string(); - - #[cfg(target_os = "linux")] - { + ui.label("Virtual Mic Output"); + if cfg!(target_os = "linux") { let output_index = app_state.virt_output_index.clone(); 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() { - mic_name = app_name.to_string(); + egui::ComboBox::from_id_salt("Virtual Mic Output") + .selected_text(app_name.to_string()) + .width(available_width) + .height(available_height / 15.0) + .show_ui(ui, |ui| { + for output in &outputs { + ui.selectable_value( + &mut app_state.virt_output_index_switch, + output.1.clone(), + output.0.clone(), + ); + } + }); + } + else { + ui.add(egui::Button::new("No apps found to use.".to_string())); } } - - ui.label("Virtual Mic Output"); - - egui::ComboBox::from_id_salt("Virtual Mic Output") - .selected_text(mic_name) - .width(available_width) - .height(available_height / 15.0) - .show_ui(ui, |ui| { - for output in &outputs { - ui.selectable_value( - &mut app_state.virt_output_index_switch, - output.1.clone(), - output.0.clone(), - ); - } - }); + else { + ui.add(egui::Button::new("Unsupported. Select inside apps.".to_string())); + } if ui .add_sized( @@ -358,24 +370,48 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Res }); 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| { + for playing_sound in &app_state.currently_playing { + ui.label(format!( + "{} - {:.2} / {:.2}", + playing_sound.file_path, + playing_sound.sink.get_pos().as_secs_f32(), + playing_sound.length + )); } - - ui.vertical(|ui| { - for playing_sound in &app_state.currently_playing { - ui.label(format!( - "{} - {:.2} / {:.2}", - playing_sound.file_path, - playing_sound.sink.get_pos().as_secs_f32(), - 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| { diff --git a/src/windows_lib.rs b/src/windows_lib.rs index 3087870..5ee2ca2 100644 --- a/src/windows_lib.rs +++ b/src/windows_lib.rs @@ -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) { - let host = cpal::host_from_id(cpal::HostId::Wasapi).expect("Could not initialize audio routing using WasAPI"); - let virtual_mic = host.output_devices().expect("Could not list Output devices").find(|device| { - device.name().ok().map(|name|{ - name.contains("CABLE Input") || name.contains("VB-Audio") - }).unwrap_or(false) - }).expect("Could not get default output device"); - - let normal_output = host.default_output_device().expect("Could not get default output device"); +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 virtual_mic = host + .output_devices() + .expect("Could not list Output devices") + .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?"); - 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")); -} \ No newline at end of file + 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"), + ); +}