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

This commit is contained in:
csd4ni3l
2026-02-15 17:24:21 +01:00
parent d78c3c22c9
commit 0ae204ed0f
5 changed files with 155 additions and 65 deletions

12
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"
]
]

View File

@@ -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.
- Not supported and not planned.

View File

@@ -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<AppState>) -> 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<AppState>) {
egui::SidePanel::right("tools").show(ctx, |ui| {
ui.heading("Tools");
@@ -353,7 +347,7 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Coming Soon!");
});
}
fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> 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(())
}

View File

@@ -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::<i32>::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");