Compare commits

28 Commits
latest ... main

Author SHA1 Message Date
csd4ni3l
ff947b57a8 Fix std::os::unix included on Windows where it doesnt exist, and remove set permission in yt-dlp for anything other than unix altogether. 2026-03-05 22:36:47 +01:00
csd4ni3l
8d21c268c2 Fix yt-dlp binary not having the right permissions after download 2026-03-05 21:41:08 +01:00
csd4ni3l
dff8d5aeff Fix warnings, remove ffmpeg output from cmd 2026-03-05 21:29:12 +01:00
csd4ni3l
4128566048 Make bottom panel fixed size so content doesn't go up and down when playing new sound effects, add more apps to Linux excludes, add multiple virtual output support by using buttons(on/off) on Linux so you can select the virtual mic for multiple apps at the same time 2026-03-05 21:24:55 +01:00
csd4ni3l
8d036bca08 Fix Windows build crashing because virtual_mic is Option Device not just Device (not inside let Some) 2026-02-17 19:30:02 +01:00
csd4ni3l
9d3d027e45 Add exit after cable input error messagebox is closed. 2026-02-17 19:03:45 +01:00
csd4ni3l
4a21f60ee6 Fix Windows cable input error handling 2026-02-17 19:01:39 +01:00
csd4ni3l
b241117813 Add messagedialog when VB Cable is not installed. 2026-02-17 18:55:27 +01:00
csd4ni3l
dcf121c045 Improve REAMDE with feature table, add youtube downloader support with yt-dlp running in a thread, add yt-dlp auto-downnload and ffmpeg auto-download for windows/warn for linux, add back button for youtube downloader 2026-02-17 18:01:33 +01:00
csd4ni3l
e7fbf8f4d8 Fix a bunch of issues with the windows standard to virtual mic routing, fix all warnings for youtube_downloader and others 2026-02-15 18:12:10 +01:00
csd4ni3l
815663b039 Fix windows not liking linux_lib import when creating dropdown/label for virtual mic 2026-02-15 17:36:14 +01:00
csd4ni3l
0ae204ed0f 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 2026-02-15 17:24:21 +01:00
csd4ni3l
d78c3c22c9 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. 2026-02-15 15:09:02 +01:00
csd4ni3l
5a4ebe3467 Improve README by updating it with the newest info (new Linux stuff and Windows limitations) 2026-02-05 21:43:15 +01:00
csd4ni3l
07c0457701 Fix Windows Rust still bugging me about variables by using a scope to create the normal_sink 2026-02-05 21:02:51 +01:00
csd4ni3l
58deb34135 Add sink twice to second PlayingSound push cuz rust is dumb shit and doesnt understand im already returning 2026-02-05 20:46:10 +01:00
csd4ni3l
ff71f6f21a Fix currently_playing push not working on Windows because normal_sink is in a different scope 2026-02-05 20:24:50 +01:00
csd4ni3l
ca6fc96f99 Fix some windows compilation issues 2026-02-05 20:04:32 +01:00
csd4ni3l
6480c80cb4 Linux support is done! Now they can hear you & you can speak, and you will not hear yourself. 2026-02-05 18:53:53 +01:00
csd4ni3l
320337567d Fix reload sound system button not replacing sound system but output stream of current sound system 2026-02-05 18:31:06 +01:00
csd4ni3l
83f53f8711 Add normal mic support on Linux by using loopback modules, and remove all unnecessary double stream code which wouldnt work anyway, add Windows normal mic support as well, by using target_os windows blocks and running 2 separate streams. 2026-02-05 18:27:41 +01:00
csd4ni3l
21d9f6c08b Code formatted by Zed automatically, added black fill color for
directory button that is currently selected, removed the current
directory text
2026-02-05 16:26:44 +01:00
csd4ni3l
abb7704e21 Fix typo 2026-01-25 15:55:09 +01:00
csd4ni3l
4d065525ff Fix Windows trying to use linux move_index_to_virtualmic func 2026-01-25 15:53:26 +01:00
csd4ni3l
73efdbd8b3 Add DeviceTrait to fix Windows not being able to access device name 2026-01-25 15:51:19 +01:00
csd4ni3l
caa4f7d2d0 Fix windows missing let keywords 2026-01-25 15:47:49 +01:00
csd4ni3l
a42c69c845 Separate Linux and Windows stuff (I have no idea if Windows even works), add automatic virtual mic support for apps on Linux (impossible on Windows) 2026-01-25 15:42:29 +01:00
csd4ni3l
d5a7dd624b Remove unused bevy features to remove bloat & reduce executable size by 32% (debug) 2026-01-23 21:13:22 +01:00
9 changed files with 1503 additions and 936 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
data.json
/bin

1380
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,15 +6,28 @@ edition = "2024"
[dependencies]
bevy_egui = "0.38.1"
rand = "0.9.2"
reqwest = { version = "0.13.2", features = ["blocking"] }
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"
[dependencies.bevy]
version = "0.17.3"
default-features = false
features = [
"bevy_log",
"bevy_window",
"bevy_winit",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_text",
"bevy_ui",
"bevy_asset",
"bevy_picking",
"multi_threaded",
]
[profile.dev.package."*"]
@@ -22,5 +35,20 @@ opt-level = 2
debug = false
[target.'cfg(target_os = "linux")'.dependencies.bevy]
default-features = false
version = "0.17.3"
features = ["wayland", "x11", "bevy_winit"]
features = [
"bevy_log",
"bevy_window",
"bevy_winit",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_text",
"bevy_ui",
"multi_threaded",
"bevy_asset",
"bevy_picking",
"wayland",
"x11"
]

View File

@@ -1,15 +1,18 @@
Soundboard made in Rust & Bevy. My first Rust project.
# csd4ni3l Soundboard
# Support & Requirements
- On all OSes, you need to still select the device inside the app you want to use it in.
Cross-platform soundboard made in Rust & Bevy. My first Rust project.
You might ask, why? And my answer is why not? Also because i wanted to learn Rust and this was a good way.
## Linux
- Needs the `mold` linker and `clang` to compile fast
- ALSA & PulseAudio/Pipewire-pulse is a requirement
## Features & Requirements
## Windows
- Needs the VB-Cable driver (https://vb-audio.com/Cable/)
## MacOS & Other
- Might work as a music player with the default output device.
- Not supported and not planned.
| Topic | Linux | Windows | MacOS & Other
| -------- | ------- | ------- | ------- |
| Requirements | ALSA & PulseAudio/Pipewire-pulse, optionally FFmpeg for youtube downloader | Needs the [VB-Cable driver](https://vb-audio.com/Cable), optionally FFmpeg for youtube downloader | Unknown (optionally FFmpeg for youtube downloader)|
| Build Requirements | Rust, the `mold` linker and `clang` to compile fast | Rust, any C compiler | Unknown |
| FFmpeg | Optionally for youtube downloader | Optional, Automatic install on Windows 11 (winget) | Optionally for youtube downloader |
| Virtual Mic | Pulseaudio/Pipewire | VB-Cable | No |
| App Selection | Yes | No | No |
| Youtube Downloader support | Yes (ffmpeg required) | Yes (ffmpeg required) | Unknown (ffmpeg required) |
| Can others hear you? | Yes | Experimental | Unknown |
| Support | Best | Medium | None/Unknown |
| Download | [Download for Linux](https://github.com/csd4ni3l/soundboard/releases/download/latest/soundboard) | [Download for Windows](https://github.com/csd4ni3l/soundboard/releases/download/latest/soundboard.exe) | Build it yourself. |

View File

@@ -1,4 +1,6 @@
cargo run
pactl list modules short | grep "module-loopback" | cut -f1 | xargs -L1 pactl unload-module
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
pactl list modules short | grep "Soundboard_Audio" | cut -f1 | xargs -L1 pactl unload-module

226
src/linux_lib.rs Normal file
View File

@@ -0,0 +1,226 @@
use rodio::{
OutputStream, OutputStreamBuilder,
cpal::{self, traits::HostTrait},
};
use serde_json::Value;
use std::process::Command;
const APPS_TO_EXCLUDE: [&str; 7] = ["plasmashell", "pavucontrol", "pipewire", "wireplumber", "kwin_wayland", "kwin_x11", "obs"];
const NODE_NAMES_TO_EXCLUDE: [&str; 2] = ["VirtualMicSource", "SoundboardSink"];
fn pactl_list(sink_type: &str) -> Value {
let command_output = Command::new("pactl")
.args(&["-f", "json", "list", sink_type])
.output()
.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 {}
}
}
pub fn get_soundboard_sink_index() -> String {
let source_outputs = pactl_list("sinks");
source_outputs
.as_array()
.unwrap_or(&vec![])
.iter()
.find(|sink| sink["name"] == "SoundboardSink")
.and_then(|sink| {
Some(sink["index"].to_string())
})
.unwrap()
}
pub fn get_default_source() -> String {
let sources = pactl_list("sources");
let command = Command::new("pactl")
.args(&["get-default-source"])
.output()
.unwrap();
let default_source_name = String::from_utf8_lossy(&command.stdout).trim().to_string();
sources.as_array()
.unwrap()
.iter()
.find(|sink|{ sink["name"].as_str() == Some(&default_source_name) })
.and_then(|s|{ Some(s["index"].to_string()) })
.unwrap()
}
fn find_soundboard_sinks() -> Vec<Value> {
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()
}
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();
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");
}
}
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 node_name = sink["properties"]["node.name"].as_str()?;
let binary = sink["properties"]["application.process.binary"]
.as_str()
.unwrap_or("Unknown");
if APPS_TO_EXCLUDE.contains(&binary) || NODE_NAMES_TO_EXCLUDE.contains(&node_name) {
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_output_to_sink(output_index: String, sink_index: String) {
let _ = Command::new("pactl")
.args(&["move-source-output", output_index.as_str(), sink_index.as_str()]) // 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\"",
])
.output()
.expect("Failed to create SoundboardSink");
Command::new("pactl")
.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\"",
])
.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",
])
.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",
])
.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",
])
.output()
.expect("Failed to create microphone loopback");
Command::new("pactl")
.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 stream = OutputStreamBuilder::from_device(device)
.expect("Unable to open VirtualMic")
.open_stream()
.expect("Failed to open stream");
move_playback_to_sink();
return stream;
}
pub fn reload_sound() {
let script = r#"
pactl list modules short | grep "module-loopback" | cut -f1 | xargs -L1 pactl unload-module
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
pactl list modules short | grep "Soundboard_Audio" | 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));
}
}

View File

@@ -1,18 +1,24 @@
use bevy::{
log::{Level, LogPlugin},
prelude::*,
};
use bevy::{log::Level, prelude::*};
use bevy_egui::{EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet, egui::{self, Context, TextBuffer, Ui, ecolor::Color32}};
use std::{collections::HashMap, fs::File, io::BufReader, path::Path, process::Command};
use std::{collections::HashMap, fs::{File, create_dir, exists, rename}, io::{BufReader, Read, Seek}, path::Path, process::{Command, Stdio}, sync::{Arc, Mutex}, thread, time::Instant};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use bevy_egui::{
EguiContextSettings, EguiContexts, EguiPlugin, EguiPrimaryContextPass, EguiStartupSet, egui,
mod yt_dlp;
#[cfg(target_os = "linux")]
mod linux_lib;
#[cfg(target_os = "windows")]
mod windows_lib;
use rodio::{
Decoder, OutputStream, OutputStreamBuilder, Sink, Source,
cpal::{self, traits::HostTrait},
};
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source, cpal::{self, Device, Host, traits::HostTrait, traits::DeviceTrait}};
use crate::yt_dlp::*;
#[derive(Serialize, Deserialize)]
struct JSONData {
@@ -23,14 +29,24 @@ struct JSONData {
struct PlayingSound {
file_path: String,
length: f32,
virtual_sink: Sink,
// normal_sink: Sink
sink: Sink,
to_remove: bool,
#[cfg(target_os = "windows")]
normal_sink: Sink,
}
struct SoundSystem {
virtual_mic_stream: OutputStream,
// normal_output_stream: OutputStream,
paused: bool
#[cfg(target_os = "windows")]
normal_output_stream: OutputStream,
output_stream: OutputStream,
}
struct YoutubeDownloaderState {
current_url: String,
current_filename: String,
download_directory: String,
yt_dlp_running: bool,
yt_dlp_stdout_text: Arc<Mutex<String>>
}
#[derive(Resource)]
@@ -39,113 +55,85 @@ struct AppState {
json_data: JSONData,
current_directory: String,
currently_playing: Vec<PlayingSound>,
sound_system: SoundSystem
sound_system: SoundSystem,
virt_outputs: Vec<(String, String)>,
is_virt_output_used: HashMap<String, bool>,
last_virt_output_update: Instant,
current_view: String,
youtube_downloader_state: YoutubeDownloaderState
}
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;
fn create_virtual_mic() -> SoundSystem {
#[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(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"));
let (normal, virtual_mic) = windows_lib::create_virtual_mic_windows();
return SoundSystem {
output_stream: virtual_mic,
normal_output_stream: normal,
};
}
#[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"));
return SoundSystem {
output_stream: linux_lib::create_virtual_mic_linux(),
};
}
}
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));
#[allow(unreachable_code)]
{
let host = cpal::default_host();
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")
.open_stream()
.expect("Failed to open stream"),
// this is actually not needed here, since windows would exit by far. But, cargo doesnt like SoundSystem not getting the normal_output stream so...
#[cfg(target_os = "windows")]
normal_output_stream: OutputStreamBuilder::from_device(device)
.expect("Unable to open device")
.open_stream()
.expect("Failed to open stream"),
}
}
}
fn reload_sound() -> SoundSystem {
#[cfg(target_os = "linux")]
linux_lib::reload_sound();
return create_virtual_mic();
}
fn list_outputs() -> Vec<(String, String)> {
#[cfg(target_os = "windows")]
return Vec::from([("Select inside apps".to_string(), String::from("9999999"))]);
#[cfg(target_os = "linux")]
return linux_lib::list_outputs();
#[allow(unreachable_code)]
return Vec::new();
}
fn main() {
let virtual_mic_stream = create_virtual_mic();
// let (normal_output_stream, virtual_mic_stream) = create_virtual_mic();
if !exists("bin").expect("Could not check existence of bin folder") {
let _ = create_dir("bin");
}
check_and_download_ffmpeg();
check_and_download_yt_dlp();
App::new()
.insert_resource(ClearColor(Color::BLACK))
.add_plugins(
DefaultPlugins
.set(LogPlugin {
.set(bevy::log::LogPlugin {
filter: "warn,ui=info".to_string(),
level: Level::INFO,
..Default::default()
@@ -159,16 +147,23 @@ fn main() {
..default()
}),
)
.add_plugins(EguiPlugin::default())
.add_plugins(bevy_egui::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
sound_system: create_virtual_mic(),
virt_outputs: Vec::new(),
is_virt_output_used: HashMap::new(),
current_view: "main".to_string(),
last_virt_output_update: Instant::now(),
youtube_downloader_state: YoutubeDownloaderState {
current_url: String::new(),
current_filename: String::new(),
download_directory: String::new(),
yt_dlp_running: false,
yt_dlp_stdout_text: Arc::new(Mutex::new(String::new()))
}
})
.add_systems(
@@ -178,11 +173,34 @@ fn main() {
.add_systems(Startup, load_system)
.add_systems(
EguiPrimaryContextPass,
(ui_system, update_ui_scale_factor_system),
(draw, update_ui_scale_factor_system, update),
)
.run();
}
fn update(mut app_state: ResMut<AppState>) {
#[cfg(target_os = "linux")] {
if app_state.last_virt_output_update.elapsed().as_secs_f32() >= 1.5 {
app_state.last_virt_output_update = Instant::now();
app_state.virt_outputs = list_outputs();
let is_virt_output_used = app_state.is_virt_output_used.clone();
for virt_output in &app_state.virt_outputs.clone() {
if !is_virt_output_used.contains_key(&virt_output.1) {
app_state.is_virt_output_used.insert(virt_output.1.clone(), false);
}
if app_state.is_virt_output_used[&virt_output.1] {
linux_lib::move_output_to_sink(virt_output.1.clone(), linux_lib::get_soundboard_sink_index());
}
else {
linux_lib::move_output_to_sink(virt_output.1.clone(), linux_lib::get_default_source());
}
}
}
}
}
fn load_system(mut app_state: ResMut<AppState>) {
load_data(&mut app_state);
}
@@ -209,7 +227,15 @@ fn load_data(app_state: &mut AppState) {
.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")) {
if path.is_file()
&& ALLOWED_FILE_EXTENSIONS.contains(
&path
.extension()
.unwrap_or_default()
.to_str()
.expect("Could not convert extension to string"),
)
{
path.to_str().map(|s| s.to_string())
} else {
None
@@ -227,60 +253,108 @@ fn setup_camera_system(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn update_ui_scale_factor_system(
egui_context: Single<(&mut EguiContextSettings, &Camera)>,
) {
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();
fn get_duration<R>(decoder: &mut rodio::Decoder<R>) -> f32 // get_duration is needed cause some MP3 files dont provide duration metadata so we need to count
where
R: Read + Seek,
{
let mut total_samples: u32 = 0;
// 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
})
for _ in decoder.by_ref() {
total_samples += 1;
}
fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result {
let ctx = contexts.ctx_mut()?;
let sample_rate = decoder.sample_rate() as u32;
let channels = decoder.channels() as u32;
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
ui.heading("csd4ni3l Soundboard");
});
total_samples as f32 / (sample_rate * channels) as f32
}
fn play_sound(file_path: String, app_state: &mut AppState) {
let file = File::open(&file_path).unwrap();
let mut src = Decoder::new(BufReader::new(file)).unwrap();
let length = get_duration(&mut src);
// need to recreate since get_duration seeks to the end and nothing is left
let file = File::open(&file_path).unwrap();
let src = Decoder::new(BufReader::new(file)).unwrap();
let sink = Sink::connect_new(&app_state.sound_system.output_stream.mixer());
sink.append(src);
sink.play();
let playing_sound = PlayingSound {
file_path: file_path.clone(),
length,
sink,
to_remove: false,
#[cfg(target_os = "windows")]
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());
normal_sink.append(src2);
normal_sink.play();
normal_sink
},
};
app_state.currently_playing.push(playing_sound);
}
fn create_virtual_mic_ui(ui: &mut Ui, app_state: &mut ResMut<AppState>, available_width: f32, available_height: f32) {
#[cfg(target_os = "linux")] {
if app_state.is_virt_output_used.len() != 0 {
let outputs = app_state.virt_outputs.clone();
for output in &outputs {
let current_value = *app_state.is_virt_output_used.get(&output.1).unwrap_or(&false);
if ui
.add_sized(
[available_width, available_height / 30.0],
egui::Button::new(format!("{} - {}", output.0.clone(), current_value)),
)
.clicked()
{
*app_state.is_virt_output_used.entry(output.1.clone()).or_insert(false) = !current_value;
}
}
}
else {
ui.add(egui::Button::new("No apps found to use.".to_string()));
}
return;
}
#[allow(unreachable_code)]
{
ui.add(egui::Button::new("Unsupported. Select inside apps.".to_string()));
}
}
fn main_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
egui::SidePanel::right("tools").show(ctx, |ui| {
ui.heading("Tools");
ui.separator();
let available_width = ui.available_width();
let available_height = ui.available_height();
ui.label("Virtual Mic Output");
create_virtual_mic_ui(ui, &mut app_state, available_width, available_height);
if ui
.add_sized(
[ui.available_width(), available_height / 15.0],
[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",
@@ -297,7 +371,7 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
if ui
.add_sized(
[ui.available_width(), available_height / 15.0],
[available_width, available_height / 15.0],
egui::Button::new("Reload content"),
)
.clicked()
@@ -308,56 +382,45 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
if ui
.add_sized(
[ui.available_width(), available_height / 15.0],
[available_width, available_height / 15.0],
egui::Button::new("Youtube downloader"),
)
.clicked()
{
println!("Youtube downloader!");
app_state.current_view = "youtube_downloader".to_string();
}
if ui
.add_sized(
[ui.available_width(), available_height / 15.0],
[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();
app_state.sound_system = 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::<Vec<_>>();
for directory in current_directories.clone() {
let mut button = egui::Button::new(&directory);
if directory == app_state.current_directory {
button = button.fill(Color32::BLACK);
}
if ui
.add_sized(
[available_width / current_directories.len() as f32, available_height / 15.0],
egui::Button::new(&directory),
[
available_width / current_directories.len() as f32,
available_height / 15.0,
],
button,
)
.clicked()
{
@@ -366,10 +429,6 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
}
});
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
@@ -380,10 +439,13 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
egui::ScrollArea::vertical().show(ui, |ui| {
for element in files {
if let Some(filename) = element.split("/").collect::<Vec<_>>().last() {
if ui.add_sized(
if ui
.add_sized(
[ui.available_width(), available_height / 15.0],
egui::Button::new(*filename),
).clicked() {
)
.clicked()
{
let path = Path::new(&app_state.current_directory)
.join(filename)
.to_string_lossy()
@@ -395,10 +457,196 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
});
}
});
}
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
fn download_youtube_sound(app_state: &mut ResMut<AppState>) {
let filename = app_state.youtube_downloader_state.current_filename.clone();
let download_directory = app_state.youtube_downloader_state.download_directory.clone();
let current_url = app_state.youtube_downloader_state.current_url.clone();
let stdout_text = Arc::clone(&app_state.youtube_downloader_state.yt_dlp_stdout_text);
app_state.youtube_downloader_state.yt_dlp_running = true;
thread::spawn(move || {
let mut command = Command::new(get_yt_dlp_path())
.args(&["-x", "--audio-format", "mp3", "-o", "sound.mp3", current_url.as_str()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to execute process");
if let Some(mut stdout) = command.stdout.take() {
let mut buffer = String::new();
loop {
let mut chunk = vec![0u8; 1024];
match stdout.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if let Ok(text) = String::from_utf8(chunk[..n].to_vec()) {
buffer.push_str(&text);
if let Ok(mut locked) = stdout_text.lock() {
*locked = buffer.clone();
}
}
}
Err(_) => break,
}
}
}
let _ = command.wait();
let path = Path::new(&download_directory).join(filename);
let _ = rename("sound.mp3", path.to_string_lossy().as_str());
});
}
fn youtube_downloader_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
egui::CentralPanel::default().show(ctx, |ui| {
let available_width = ui.available_width();
let available_height = ui.available_height();
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.heading("Directory");
egui::ComboBox::from_id_salt("Download Directory Selector")
.selected_text(app_state.youtube_downloader_state.download_directory.clone())
.width(available_width)
.height(available_height / 15.0)
.show_ui(ui, |ui| {
for directory in &app_state.loaded_files.keys().cloned().collect::<Vec<_>>() {
ui.selectable_value(
&mut app_state.youtube_downloader_state.download_directory,
directory.clone(),
directory,
);
}
});
ui.heading("Filename");
ui.add_sized([available_width, available_height / 20.0], egui::TextEdit::singleline(&mut app_state.youtube_downloader_state.current_filename));
ui.heading("Youtube URL");
ui.add_sized([available_width, available_height / 20.0], egui::TextEdit::singleline(&mut app_state.youtube_downloader_state.current_url));
});
if let Ok(text) = app_state.youtube_downloader_state.yt_dlp_stdout_text.lock() {
ui.colored_label(Color32::GREEN, text.clone());
};
if ui
.add_sized(
[
available_width as f32,
available_height / 15.0,
],
egui::Button::new("Download Sound"),
)
.clicked()
{
download_youtube_sound(&mut app_state);
};
});
}
fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result {
let ctx = contexts.ctx_mut()?;
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
if app_state.current_view != "main" {
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
let available_width = ui.available_width();
let available_height = ui.available_height();
if ui
.add_sized(
[available_width / 25.0, available_height],
egui::Button::new("<--"),
)
.clicked()
{
app_state.current_view = "main".to_string();
}
ui.heading("csd4ni3l Soundboard");
});
}
else {
ui.heading("csd4ni3l Soundboard");
}
});
let window_height = ctx.content_rect().height();
egui::TopBottomPanel::bottom("currently_playing")
.exact_height(window_height * 0.1)
.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(())
}

89
src/windows_lib.rs Normal file
View File

@@ -0,0 +1,89 @@
use rodio::{
OutputStream, OutputStreamBuilder,
cpal::{self, traits::{DeviceTrait, StreamTrait, HostTrait}, StreamConfig, SampleRate},
};
use rfd::{MessageButtons, MessageDialog, MessageDialogResult};
use ringbuf::{traits::*, HeapRb};
use std::process;
fn route_standard_to_virtual(host: &cpal::Host, virtual_mic: &cpal::Device) {
let standard_mic = host.default_input_device().expect("Could not get default input device.");
let config = StreamConfig {
channels: 2,
sample_rate: SampleRate(48_000),
buffer_size: cpal::BufferSize::Default,
};
let rb = HeapRb::<f32>::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.try_push(sample);
let _ = producer.try_push(sample);
}
},
move |err| eprintln!("Input stream error: {err}"),
None,
).expect("Could not build input stream for standard to virtual mic routing");
let output_stream = virtual_mic.build_output_stream(
&config,
move |data: &mut [f32], _| {
for sample in data {
*sample = consumer.try_pop().unwrap_or(0.0);
}
},
move |err| eprintln!("Output stream error: {err}"),
None,
).expect("Could not build output stream for standard to virtual mic routing");
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")
.find(|device| {
device
.name()
.ok()
.map(|name| name.contains("CABLE Input") || name.contains("VB-Audio"))
.unwrap_or(false)
});
if let Some(virtual_mic) = virtual_mic {
route_standard_to_virtual(&host, &virtual_mic);
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"),
);
}
else {
MessageDialog::new()
.set_title("VB Cable Driver not installed.")
.set_description("Could not access VB Cable output device. Is VB Cable Driver installed?")
.set_buttons(MessageButtons::Ok)
.show();
std::process::exit(1);
}
}

82
src/yt_dlp.rs Normal file
View File

@@ -0,0 +1,82 @@
use std::{env::current_dir, fs::{File, exists}, io, process::Command};
use reqwest;
use rfd::{MessageButtons, MessageDialog, MessageDialogResult};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
pub fn get_yt_dlp_path() -> String {
if cfg!(target_os = "windows"){
current_dir().expect("Failed to get current working directory").join("bin").join("yt-dlp.exe").to_string_lossy().to_string()
}
else if cfg!(target_os = "macos"){
current_dir().expect("Failed to get current working directory").join("bin").join("yt-dlp_macos").to_string_lossy().to_string()
}
else if cfg!(target_os = "linux"){
current_dir().expect("Failed to get current working directory").join("bin").join("yt-dlp_linux").to_string_lossy().to_string()
}
else {
"".to_string()
}
}
pub fn check_and_download_yt_dlp() {
let url: &str;
if cfg!(target_os = "windows"){
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
}
else if cfg!(target_os = "macos"){
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos";
}
else if cfg!(target_os = "linux"){
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux";
}
else {
return;
}
if exists(get_yt_dlp_path()).expect("Could not check existence of yt dlp executable.") {
return;
}
let mut body = reqwest::blocking::get(url).expect("Could not download yt-dlp");
let mut out = File::create(get_yt_dlp_path()).expect("failed to create file");
io::copy(&mut body, &mut out).expect("failed to copy content");
#[cfg(unix)]
out.set_permissions(PermissionsExt::from_mode(0o755));
}
pub fn check_ffmpeg() -> bool{
return std::process::Command::new("ffmpeg").output().is_ok();
}
pub fn check_and_download_ffmpeg() {
if check_ffmpeg() {
return;
}
if cfg!(target_os = "windows"){
let confirmed = MessageDialog::new()
.set_title("FFmpeg Download Optional.")
.set_description("The youtube downloader depends on FFmpeg for mp3 conversion. This app can auto-install FFmpeg with winget. Do you want to install FFmpeg?")
.set_buttons(MessageButtons::YesNo)
.show();
if confirmed == MessageDialogResult::Ok {
Command::new("winget")
.args(&["install", "BtbN.FFmpeg.GPL.Shared.8.0", "--source winget", "--accept-source-agreements", "--accept-package-agreements"]) // as_str is needed here as you cannot instantly dereference a growing String (Rust...)
.output()
.expect("Failed to execute process");
}
}
else {
MessageDialog::new()
.set_title("FFmpeg Download Optional.")
.set_description("The youtube downloader depends on FFmpeg for mp3 conversion. You are on a Linux or Darwin based OS. If you want to use the Youtube Downloader, you need to install FFmpeg and libavcodec shared libraries from your package manager to make sure it is in PATH.")
.set_buttons(MessageButtons::Ok)
.show();
}
}