From b5393276725bc10d3e254b62bc4679d5460a79d8 Mon Sep 17 00:00:00 2001 From: csd4ni3l Date: Fri, 23 Jan 2026 18:18:38 +0100 Subject: [PATCH] Improve README, make bevy wayland & x11 a linux-only dependency, add rust building, fix dead code warnings, try adding normal playing with virtual (didnt work, comments), fix pulse not working sometimes by adding a manual playback move to the virtual mic, make windows, linux and other virtual mic code per-system --- .github/workflows/main.yaml | 81 ++++++++++++++++++++++ Cargo.toml | 6 +- README.md | 15 +++- src/main.rs | 135 ++++++++++++++++++++++++------------ 4 files changed, 188 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/main.yaml diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..5126eaf --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,81 @@ +name: Build and Release + +on: push + +jobs: + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-22.04 + platform: linux + - os: windows-latest + platform: windows + + steps: + - name: Check-out repository + uses: actions/checkout@v4 + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --verbose + + - name: Verify executable (Linux) + if: matrix.os == 'ubuntu-22.04' + run: test target/release/soundboard + shell: bash + + - name: Locate and rename executable (Windows) + if: matrix.os == 'windows-latest' + run: Test-Path target\release\soundboard.exe + shell: pwsh + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform }} + path: | + target/release/soundboard + target/release/soundboard.exe + + release: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download All Build Artifacts + uses: actions/download-artifact@v4 + with: + path: downloads + + - name: Create release (if missing) and upload artifacts to tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="latest" + echo "Target release tag: $TAG" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; will upload assets with --clobber" + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes "Automated build for $TAG" + fi + # Upload the executables directly (no zip files) + gh release upload "$TAG" downloads/linux/soundboard --clobber + gh release upload "$TAG" downloads/windows/soundboard.exe --clobber \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index b29ef12..5114ae4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ serde_json = "1.0.146" [dependencies.bevy] version = "0.17.3" features = [ - "wayland", - "x11", "bevy_winit", ] + +[target.'cfg(target_os = "linux")'.dependencies.bevy] +version = "0.17.3" +features = ["wayland", "x11", "bevy_winit"] \ No newline at end of file diff --git a/README.md b/README.md index 77290bc..714e7fc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,15 @@ Soundboard made in Rust & Bevy. My first Rust project. -For compilation on Linux, you will need the mold linker and clang to speed things up. -On an arch machine for example, do `sudo pacman -S mold` \ No newline at end of file +# Support & Requirements +- On all OSes, you need to still select the device inside the app you want to use it in. + +## Linux +- Needs the `mold` linker and `clang` to compile fast +- ALSA & PulseAudio/Pipewire-pulse is a requirement + +## 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. \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 390c3fa..8ac992d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,26 +6,30 @@ use bevy::{ 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, traits::HostTrait}}; +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, - sink: Sink + virtual_sink: Sink, + // normal_sink: Sink } struct SoundSystem { - stream_handle: OutputStream, + virtual_mic_stream: OutputStream, + // normal_output_stream: OutputStream, paused: bool } @@ -40,14 +44,48 @@ struct AppState { const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; -fn create_virtual_mic() -> OutputStream { - if cfg!(target_os = "windows") { - panic!("Windows is currently unsupported."); +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"); + } + } } - else if cfg!(target_os = "macos") { - panic!("MacOS is and will most likely stay unsupported."); - } - else if cfg!(target_os = "linux") { +} + +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() @@ -56,29 +94,27 @@ fn create_virtual_mic() -> OutputStream { .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")); } - else { - panic!("I have no idea what OS you are on but it's not mainstream enough."); - } - - unsafe { - std::env::set_var("PULSE_SINK", "VirtualMic"); + #[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")); } - let host = cpal::default_host(); - let 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"); } -fn recreate_virtual_mic() -> OutputStream { - if cfg!(target_os = "windows") { - panic!("Windows is currently unsupported."); - } - else if cfg!(target_os = "macos") { - panic!("MacOS is and will most likely stay unsupported."); - } - else if cfg!(target_os = "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 @@ -96,15 +132,13 @@ fn recreate_virtual_mic() -> OutputStream { println!("Error: {}", String::from_utf8_lossy(&output.stderr)); } } - else { - panic!("I have no idea what OS you are on but it's not mainstream enough."); - } return create_virtual_mic(); } fn main() { - let stream_handle = create_virtual_mic(); + let virtual_mic_stream = create_virtual_mic(); + // let (normal_output_stream, virtual_mic_stream) = create_virtual_mic(); App::new() .insert_resource(ClearColor(Color::BLACK)) @@ -131,7 +165,8 @@ fn main() { current_directory: String::new(), currently_playing: Vec::new(), sound_system: SoundSystem { - stream_handle, + virtual_mic_stream, + // normal_output_stream, paused: false } }) @@ -199,16 +234,25 @@ fn update_ui_scale_factor_system( } fn play_sound(file_path: String, app_state: &mut AppState) { - let file = BufReader::new(File::open(&file_path).unwrap()); - let src = Decoder::new(file).unwrap(); - let length = src.total_duration().expect("Could not get source duration").as_secs_f32(); - let sink = Sink::connect_new(&app_state.sound_system.stream_handle.mixer()); - sink.append(src); + 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: length, - sink: sink + length, + virtual_sink, + // normal_sink }) } @@ -274,13 +318,14 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Res if ui .add_sized( [ui.available_width(), available_height / 15.0], - egui::Button::new("Recreate Virtual Mic"), + egui::Button::new("Reload sound system"), ) .clicked() { app_state.currently_playing.clear(); - app_state.sound_system.stream_handle = recreate_virtual_mic(); - println!("Recreated Virtual microphone!"); + 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!"); } }); @@ -295,7 +340,7 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Res 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.label(format!("{} - {:.2} / {:.2}", playing_sound.file_path, playing_sound.virtual_sink.get_pos().as_secs_f32(), playing_sound.length)); } }) }); @@ -351,7 +396,7 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Res }); 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 + 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(())