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

This commit is contained in:
csd4ni3l
2026-01-23 18:18:38 +01:00
parent 4fff1d4709
commit b539327672
4 changed files with 188 additions and 49 deletions

81
.github/workflows/main.yaml vendored Normal file
View File

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

View File

@@ -14,7 +14,9 @@ serde_json = "1.0.146"
[dependencies.bevy] [dependencies.bevy]
version = "0.17.3" version = "0.17.3"
features = [ features = [
"wayland",
"x11",
"bevy_winit", "bevy_winit",
] ]
[target.'cfg(target_os = "linux")'.dependencies.bevy]
version = "0.17.3"
features = ["wayland", "x11", "bevy_winit"]

View File

@@ -1,4 +1,15 @@
Soundboard made in Rust & Bevy. My first Rust project. 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. # Support & Requirements
On an arch machine for example, do `sudo pacman -S mold` - 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.

View File

@@ -6,26 +6,30 @@ use bevy::{
use std::{collections::HashMap, fs::File, io::BufReader, path::Path, process::Command}; use std::{collections::HashMap, fs::File, io::BufReader, path::Path, process::Command};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use bevy_egui::{ use bevy_egui::{
EguiContextSettings, EguiContexts, EguiPlugin, EguiPrimaryContextPass, EguiStartupSet, 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)] #[derive(Serialize, Deserialize)]
struct JSONData { struct JSONData {
tabs: Vec<String>, tabs: Vec<String>,
} }
#[allow(dead_code)]
struct PlayingSound { struct PlayingSound {
file_path: String, file_path: String,
length: f32, length: f32,
sink: Sink virtual_sink: Sink,
// normal_sink: Sink
} }
struct SoundSystem { struct SoundSystem {
stream_handle: OutputStream, virtual_mic_stream: OutputStream,
// normal_output_stream: OutputStream,
paused: bool paused: bool
} }
@@ -40,14 +44,48 @@ struct AppState {
const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; 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 { fn create_virtual_mic() -> OutputStream {
if cfg!(target_os = "windows") { let host: Host;
panic!("Windows is currently unsupported."); // 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"));
} }
else if cfg!(target_os = "macos") {
panic!("MacOS is and will most likely stay unsupported."); #[cfg(target_os = "linux")]
} {
else if 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") Command::new("pactl")
.args(&["load-module", "module-null-sink", "sink_name=VirtualMic", "sink_properties=device.description=\"Virtual_Microphone\""]) .args(&["load-module", "module-null-sink", "sink_name=VirtualMic", "sink_properties=device.description=\"Virtual_Microphone\""])
.output() .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\""]) .args(&["load-module", "module-remap-source", "master=VirtualMic.monitor", "source_name=VirtualMicSource", "source_properties=device.description=\"Virtual_Mic_Source\""])
.output() .output()
.expect("Failed to execute process"); .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 { #[allow(unreachable_code)] {
panic!("I have no idea what OS you are on but it's not mainstream enough."); 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"));
} }
unsafe {
std::env::set_var("PULSE_SINK", "VirtualMic");
}
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 { fn reload_sound() -> OutputStream {
if cfg!(target_os = "windows") { if cfg!(target_os = "linux"){
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"){
let script = r#" let script = r#"
pactl list modules short | grep "Virtual_Microphone" | 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 "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)); 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(); return create_virtual_mic();
} }
fn main() { 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() App::new()
.insert_resource(ClearColor(Color::BLACK)) .insert_resource(ClearColor(Color::BLACK))
@@ -131,7 +165,8 @@ fn main() {
current_directory: String::new(), current_directory: String::new(),
currently_playing: Vec::new(), currently_playing: Vec::new(),
sound_system: SoundSystem { sound_system: SoundSystem {
stream_handle, virtual_mic_stream,
// normal_output_stream,
paused: false paused: false
} }
}) })
@@ -199,16 +234,25 @@ fn update_ui_scale_factor_system(
} }
fn play_sound(file_path: String, app_state: &mut AppState) { fn play_sound(file_path: String, app_state: &mut AppState) {
let file = BufReader::new(File::open(&file_path).unwrap()); let virtual_file = File::open(&file_path).unwrap();
let src = Decoder::new(file).unwrap(); let virtual_src = Decoder::new(BufReader::new(virtual_file)).unwrap();
let length = src.total_duration().expect("Could not get source duration").as_secs_f32(); let virtual_sink = Sink::connect_new(&app_state.sound_system.virtual_mic_stream.mixer());
let sink = Sink::connect_new(&app_state.sound_system.stream_handle.mixer()); let length = virtual_src.total_duration().expect("Could not get source duration").as_secs_f32();
sink.append(src); 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 { app_state.currently_playing.push(PlayingSound {
file_path: file_path.clone(), file_path: file_path.clone(),
length: length, length,
sink: sink virtual_sink,
// normal_sink
}) })
} }
@@ -274,13 +318,14 @@ fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Res
if ui if ui
.add_sized( .add_sized(
[ui.available_width(), available_height / 15.0], [ui.available_width(), available_height / 15.0],
egui::Button::new("Recreate Virtual Mic"), egui::Button::new("Reload sound system"),
) )
.clicked() .clicked()
{ {
app_state.currently_playing.clear(); app_state.currently_playing.clear();
app_state.sound_system.stream_handle = recreate_virtual_mic(); app_state.sound_system.virtual_mic_stream = reload_sound();
println!("Recreated Virtual microphone!"); // (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<AppState>) -> Res
ui.vertical(|ui| { ui.vertical(|ui| {
for playing_sound in &app_state.currently_playing { 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<AppState>) -> Res
}); });
app_state.currently_playing.retain(|playing_sound| { 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(()) Ok(())