From 0ae204ed0fe6a30247ad68e5d465f25b26a689b1 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Sun, 15 Feb 2026 17:24:21 +0100 Subject: [PATCH] Improve README, add untested Windows mic to virtual mic support, remove global pause and add per-sound pause & stop. Add youtube downloader UI and per-view rendering which doesnt do anything yet --- Cargo.lock | 12 ++++ Cargo.toml | 3 +- README.md | 11 +++- src/main.rs | 151 +++++++++++++++++++++++++++------------------ src/windows_lib.rs | 43 +++++++++++++ 5 files changed, 155 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dfe606..604f47a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3905,6 +3905,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + [[package]] name = "rodio" version = "0.21.1" @@ -4224,6 +4235,7 @@ dependencies = [ "bevy_egui", "rand", "rfd", + "ringbuf", "rodio", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e86bc0c..b6767af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" bevy_egui = "0.38.1" rand = "0.9.2" rfd = "0.16.0" +ringbuf = "0.4.8" rodio = { version = "0.21.1", features = ["mp3", "wav", "flac", "vorbis"] } serde = "1.0.228" serde_json = "1.0.146" @@ -49,4 +50,4 @@ features = [ "bevy_picking", "wayland", "x11" -] \ No newline at end of file +] diff --git a/README.md b/README.md index aa4bfc2..50faa77 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ +# csd4ni3l Soundboard + Soundboard made in Rust & Bevy. My first Rust project. -# Support & Requirements +## Support & Requirements ## Linux + - Needs the `mold` linker and `clang` to compile fast - ALSA & PulseAudio/Pipewire-pulse is a requirement - Can use auto-selection of app to use the virtual mic in. - Auto-routes mic to virtual mic by default, so others can also hear you. ## Windows -- Needs the VB-Cable driver (https://vb-audio.com/Cable/) + +- Needs the [VB-Cable driver](https://vb-audio.com/Cable/) - You need to still select the device inside the app you want to use it in. - They only hear the soundboard as of right now, not your actual mic. ## MacOS & Other + - Might work as a music player with the default output device. -- Not supported and not planned. \ No newline at end of file +- Not supported and not planned. diff --git a/src/main.rs b/src/main.rs index 0743c86..41b588b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, fs::File, io::BufReader, path::Path, time::Insta use serde::{Deserialize, Serialize}; -use bevy_egui::{EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet, egui}; +use bevy_egui::{EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet, egui::{self, Context}}; use egui::ecolor::Color32; @@ -29,6 +29,7 @@ struct PlayingSound { file_path: String, length: f32, sink: Sink, + to_remove: bool, #[cfg(target_os = "windows")] normal_sink: Sink, } @@ -37,7 +38,6 @@ struct SoundSystem { #[cfg(target_os = "windows")] normal_output_stream: OutputStream, output_stream: OutputStream, - paused: bool, } #[derive(Resource)] @@ -50,7 +50,8 @@ struct AppState { virt_outputs: Vec<(String, String)>, virt_output_index_switch: String, virt_output_index: String, - last_virt_output_update: Instant + last_virt_output_update: Instant, + current_view: String } const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; @@ -62,7 +63,6 @@ fn create_virtual_mic() -> SoundSystem { return SoundSystem { output_stream: virtual_mic, normal_output_stream: normal, - paused: false, }; } @@ -70,7 +70,6 @@ fn create_virtual_mic() -> SoundSystem { { return SoundSystem { output_stream: linux_lib::create_virtual_mic_linux(), - paused: false, }; } @@ -91,7 +90,6 @@ fn create_virtual_mic() -> SoundSystem { .expect("Unable to open device") .open_stream() .expect("Failed to open stream"), - paused: false, } } } @@ -143,6 +141,7 @@ fn main() { virt_outputs: Vec::new(), virt_output_index_switch: String::from("0"), virt_output_index: String::from("999"), + current_view: "main".to_string(), last_virt_output_update: Instant::now() }) .add_systems( @@ -254,6 +253,7 @@ fn play_sound(file_path: String, app_state: &mut AppState) { file_path: file_path.clone(), length, sink, + to_remove: false, #[cfg(target_os = "windows")] normal_sink: { let file2 = File::open(&file_path).unwrap(); @@ -269,13 +269,7 @@ fn play_sound(file_path: String, app_state: &mut AppState) { app_state.currently_playing.push(playing_sound); } -fn draw(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"); - }); - +fn main_ui(mut ctx: &Context, mut app_state: ResMut) { egui::SidePanel::right("tools").show(ctx, |ui| { ui.heading("Tools"); @@ -353,7 +347,7 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { ) .clicked() { - println!("Youtube downloader!"); + app_state.current_view = "youtube_downloader".to_string(); } if ui @@ -369,51 +363,6 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { } }); - egui::TopBottomPanel::bottom("currently_playing").show(ctx, |ui| { - 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| { let available_height = ui.available_height(); ui.horizontal(|ui| { @@ -468,10 +417,90 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { }); } }); +} - app_state.currently_playing.retain(|playing_sound| { - playing_sound.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 +fn youtube_downloader_ui(mut ctx: &Context, mut app_state: ResMut) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Coming Soon!"); }); +} + +fn draw(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::TopBottomPanel::bottom("currently_playing").show(ctx, |ui| { + ui.vertical(|ui| { + for playing_sound in &mut app_state.currently_playing { + ui.horizontal(|ui| { + 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 / 2 as f32, + available_height, + ], + egui::Button::new("Stop"), + ) + .clicked() + { + playing_sound.to_remove = true; + }; + if ui + .add_sized( + [ + available_width / 2 as f32, + available_height, + ], + egui::Button::new(if playing_sound.sink.is_paused() {"Resume"} else {"Pause"}), + ) + .clicked() + { + if playing_sound.sink.is_paused() { + playing_sound.sink.play(); + } + else { + playing_sound.sink.pause(); + } + }; + }); + } + }); + + 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(); + } + }); + + app_state.currently_playing.retain(|playing_sound| { // retains happen the next cycle, not in the current one because of borrowing and im lazy to fix + playing_sound.sink.get_pos().as_secs_f32() <= (playing_sound.length - 0.01) && !playing_sound.to_remove // 0.01 offset needed here because of floating point errors and so its not exact + }); + + if app_state.current_view == "main".to_string() { + main_ui(ctx, app_state); + } + else if app_state.current_view == "youtube_downloader".to_string() { + youtube_downloader_ui(ctx, app_state); + } Ok(()) } diff --git a/src/windows_lib.rs b/src/windows_lib.rs index 5ee2ca2..815b08b 100644 --- a/src/windows_lib.rs +++ b/src/windows_lib.rs @@ -3,9 +3,50 @@ use rodio::{ cpal::{self, traits::DeviceTrait, traits::HostTrait}, }; +use ringbuf::{traits::*, HeapRb}; + +fn route_standard_to_virtual(virtual_mic: cpal::Device) { + let standard_mic = host.default_output_device(); + + let config = StreamConfig { + channels: 2, + sample_rate: SampleRate(48_000), + buffer_size: cpal::BufferSize::Default, + }; + let rb = HeapRb::::new(48_000 * 2); + let (mut producer, mut consumer) = rb.split(); + + let input_stream = standard_mic.build_input_stream( + &config, + move |data: &[f32], _| { + for &sample in data { + let _ = producer.push(sample); + let _ = producer.push(sample); + } + }, + move |err| eprintln!("Input stream error: {err}"), + None, + )?; + + let output_stream = virtual_mic.build_output_stream( + &config, + move |data: &mut [f32], _| { + for sample in data { + *sample = consumer.pop().unwrap_or(0.0); + } + }, + move |err| eprintln!("Output stream error: {err}"), + None, + )?; + + input_stream.play()?; + output_stream.play()?; +} + 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") @@ -18,6 +59,8 @@ pub fn create_virtual_mic_windows() -> (OutputStream, OutputStream) { }) .expect("Could not get VB Cable output device. Is VB Cable Driver installed?"); + route_standard_to_virtual(virtual_mic); + let normal_output = host .default_output_device() .expect("Could not get default output device");