use bevy::{ log::{Level, LogPlugin}, prelude::*, }; use std::{collections::HashMap, fs::File, io::BufReader, path::Path, process::Command}; use serde::{Deserialize, Serialize}; use serde_json::Value; use bevy_egui::{ EguiContextSettings, EguiContexts, EguiPlugin, EguiPrimaryContextPass, EguiStartupSet, egui, }; use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source, cpal::{self, Device, Host, traits::HostTrait}}; #[derive(Serialize, Deserialize)] struct JSONData { tabs: Vec, } #[allow(dead_code)] struct PlayingSound { file_path: String, length: f32, virtual_sink: Sink, // normal_sink: Sink } struct SoundSystem { virtual_mic_stream: OutputStream, // normal_output_stream: OutputStream, paused: bool } #[derive(Resource)] struct AppState { loaded_files: HashMap>, json_data: JSONData, current_directory: String, currently_playing: Vec, sound_system: SoundSystem } const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; fn move_playback_to_sink() { let command_output = Command::new("pactl") .args(&["-f", "json", "list", "sink-inputs"]) .output() .expect("Failed to execute process"); if command_output.status.success() { let sink_json: Value = serde_json::from_str(str::from_utf8(&command_output.stdout).expect("Failed to convert to string")).expect("Failed to parse sink JSON output"); for device in sink_json.as_array().unwrap_or(&vec![]) { if device["properties"]["node.name"] == "alsa_playback.soundboard" { let index = device["index"].as_u64().expect("Device index is not a number").to_string(); Command::new("pactl") .args(&["move-sink-input", index.as_str(), "VirtualMic"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...) .output() .expect("Failed to execute process"); } } } } fn create_virtual_mic() -> OutputStream { let host: Host; // let original_host: Host; // let normal_output: Device; let virtual_mic: Device; #[cfg(target_os = "windows")] { host = cpal::host_from_id(cpal::HostId::Wasapi).expect("Could not initialize audio routing using WasAPI"); 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"); // 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")); } #[cfg(target_os = "linux")] { // original_host = cpal::host_from_id(cpal::HostId::Alsa).expect("Could not initialize audio routing using ALSA"); // normal_output = original_host.default_output_device().expect("Could not get default output device"); Command::new("pactl") .args(&["load-module", "module-null-sink", "sink_name=VirtualMic", "sink_properties=device.description=\"Virtual_Microphone\""]) .output() .expect("Failed to execute process"); Command::new("pactl") .args(&["load-module", "module-remap-source", "master=VirtualMic.monitor", "source_name=VirtualMicSource", "source_properties=device.description=\"Virtual_Mic_Source\""]) .output() .expect("Failed to execute process"); host = cpal::host_from_id(cpal::HostId::Alsa).expect("Could not initialize audio routing using ALSA"); // Alsa needed so pulse default works virtual_mic = host.default_output_device().expect("Could not get default output device"); let virtual_mic_stream = OutputStreamBuilder::from_device(virtual_mic).expect("Unable to open default audio device").open_stream().expect("Failed to open stream"); move_playback_to_sink(); return virtual_mic_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")); } #[allow(unreachable_code)] { println!("Unknown/unsupported OS. Audio support may not work or may route to default output (headset, headphones, etc)."); host = cpal::default_host(); virtual_mic = host.default_output_device().expect("Could not get default output device"); return OutputStreamBuilder::from_device(virtual_mic).expect("Unable to open default audio device").open_stream().expect("Failed to open stream") // 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")); } } fn reload_sound() -> OutputStream { if cfg!(target_os = "linux"){ let script = r#" pactl list modules short | grep "Virtual_Microphone" | cut -f1 | xargs -L1 pactl unload-module pactl list modules short | grep "Virtual_Mic_Source" | cut -f1 | xargs -L1 pactl unload-module "#; let output = Command::new("sh") .arg("-c") .arg(script) .output() .expect("Failed to execute process"); if output.status.success() { println!("Modules unloaded successfully."); } else { println!("Error: {}", String::from_utf8_lossy(&output.stderr)); } } return create_virtual_mic(); } fn main() { let virtual_mic_stream = create_virtual_mic(); // let (normal_output_stream, virtual_mic_stream) = create_virtual_mic(); App::new() .insert_resource(ClearColor(Color::BLACK)) .add_plugins( DefaultPlugins .set(LogPlugin { filter: "warn,ui=info".to_string(), level: Level::INFO, ..Default::default() }) .set(WindowPlugin { primary_window: Some(Window { // You may want this set to `true` if you need virtual keyboard work in mobile browsers. prevent_default_event_handling: false, ..default() }), ..default() }), ) .add_plugins(EguiPlugin::default()) .insert_resource(AppState { loaded_files: HashMap::new(), json_data: JSONData { tabs: Vec::new() }, current_directory: String::new(), currently_playing: Vec::new(), sound_system: SoundSystem { virtual_mic_stream, // normal_output_stream, paused: false } }) .add_systems( PreStartup, setup_camera_system.before(EguiStartupSet::InitContexts), ) .add_systems(Startup, load_system) .add_systems( EguiPrimaryContextPass, (ui_system, update_ui_scale_factor_system), ) .run(); } fn load_system(mut app_state: ResMut) { load_data(&mut app_state); } fn load_data(app_state: &mut AppState) { if std::fs::exists("data.json").expect("Failed to check existence of JSON file") { let data = std::fs::read_to_string("data.json").expect("Failed to read JSON"); app_state.json_data = serde_json::from_str(&data).expect("Failed to load JSON"); let tabs = app_state.json_data.tabs.clone(); app_state.loaded_files.clear(); if tabs.len() > 0 { app_state.current_directory = tabs[0].clone(); } for tab in tabs { app_state.loaded_files.insert(tab.clone(), Vec::new()); if std::fs::exists(tab.clone()).expect("Failed to check existence of tab directory.") { app_state.loaded_files.insert( tab.clone(), std::fs::read_dir(tab) .expect("Failed to read directory") .filter_map(|entry| { entry.ok().and_then(|e| { let path = e.path(); if path.is_file() && ALLOWED_FILE_EXTENSIONS.contains(&path.extension().expect("Could not find extension").to_str().expect("Could not convert extension to string")) { path.to_str().map(|s| s.to_string()) } else { None } }) }) .collect(), ); } } } } fn setup_camera_system(mut commands: Commands) { commands.spawn(Camera2d); } fn update_ui_scale_factor_system( egui_context: Single<(&mut EguiContextSettings, &Camera)>, ) { let (mut egui_settings, camera) = egui_context.into_inner(); egui_settings.scale_factor = 1.5 / camera.target_scaling_factor().unwrap_or(1.5); } fn play_sound(file_path: String, app_state: &mut AppState) { let virtual_file = File::open(&file_path).unwrap(); let virtual_src = Decoder::new(BufReader::new(virtual_file)).unwrap(); let virtual_sink = Sink::connect_new(&app_state.sound_system.virtual_mic_stream.mixer()); let length = virtual_src.total_duration().expect("Could not get source duration").as_secs_f32(); virtual_sink.append(virtual_src); virtual_sink.play(); // let normal_file = File::open(&file_path).unwrap(); // let normal_src = Decoder::new(BufReader::new(normal_file)).unwrap(); // let normal_sink = Sink::connect_new(&app_state.sound_system.normal_output_stream.mixer()); // normal_sink.append(normal_src); // normal_sink.play(); app_state.currently_playing.push(PlayingSound { file_path: file_path.clone(), length, virtual_sink, // normal_sink }) } fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { let ctx = contexts.ctx_mut()?; egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { ui.heading("csd4ni3l Soundboard"); }); egui::SidePanel::right("tools").show(ctx, |ui| { ui.heading("Tools"); ui.separator(); let available_height = ui.available_height(); if ui .add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new("Add folder"), ) .clicked() { if let Some(folder) = rfd::FileDialog::new().pick_folder() { if let Some(path_str) = folder.to_str() { println!("Selected: {}", path_str); app_state.json_data.tabs.push(path_str.to_string()); std::fs::write( "data.json", serde_json::to_string(&app_state.json_data) .expect("Could not convert JSON to string"), ) .expect("Could not write to JSON file"); load_data(&mut app_state); } else { println!("Invalid path encoding!"); } } } if ui .add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new("Reload content"), ) .clicked() { load_data(&mut app_state); println!("Reloaded content"); } if ui .add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new("Youtube downloader"), ) .clicked() { println!("Youtube downloader!"); } if ui .add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new("Reload sound system"), ) .clicked() { app_state.currently_playing.clear(); app_state.sound_system.virtual_mic_stream = reload_sound(); // (app_state.sound_system.normal_output_stream, app_state.sound_system.virtual_mic_stream) = reload_sound(); println!("Sucessfully reloaded sound system!"); } }); 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.virtual_sink.get_pos().as_secs_f32(), playing_sound.length)); } }) }); }); egui::CentralPanel::default().show(ctx, |ui| { let available_height = ui.available_height(); ui.horizontal(|ui| { let available_width = ui.available_width(); let current_directories = app_state.loaded_files.keys().cloned().collect::>(); for directory in current_directories.clone() { if ui .add_sized( [available_width / current_directories.len() as f32, available_height / 15.0], egui::Button::new(&directory), ) .clicked() { app_state.current_directory = directory; }; } }); ui.add_space(available_height / 50.0); ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { ui.label(egui::RichText::new(format!("The current directory is {}", app_state.current_directory)).font(egui::FontId::proportional(20.0))); }); ui.add_space(available_height / 50.0); if app_state.current_directory.chars().count() > 0 { let files = app_state .loaded_files .get(&app_state.current_directory) .cloned() .unwrap_or_default(); egui::ScrollArea::vertical().show(ui, |ui| { for element in files { if let Some(filename) = element.split("/").collect::>().last() { if ui.add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new(*filename), ).clicked() { let path = Path::new(&app_state.current_directory) .join(filename) .to_string_lossy() .to_string(); play_sound(path, &mut app_state); } } } }); } }); app_state.currently_playing.retain(|playing_sound| { playing_sound.virtual_sink.get_pos().as_secs_f32() <= (playing_sound.length - 0.01) // 0.01 offset needed here because of floating point errors and so its not exact }); Ok(()) }