mirror of
https://github.com/csd4ni3l/soundboard.git
synced 2026-03-10 09:09:24 +01:00
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:
81
.github/workflows/main.yaml
vendored
Normal file
81
.github/workflows/main.yaml
vendored
Normal 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
|
||||||
@@ -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"]
|
||||||
15
README.md
15
README.md
@@ -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.
|
||||||
135
src/main.rs
135
src/main.rs
@@ -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 create_virtual_mic() -> OutputStream {
|
fn move_playback_to_sink() {
|
||||||
if cfg!(target_os = "windows") {
|
let command_output = Command::new("pactl")
|
||||||
panic!("Windows is currently unsupported.");
|
.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.");
|
|
||||||
}
|
fn create_virtual_mic() -> OutputStream {
|
||||||
else if cfg!(target_os = "linux") {
|
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")
|
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");
|
||||||
unsafe {
|
return OutputStreamBuilder::from_device(virtual_mic).expect("Unable to open default audio device").open_stream().expect("Failed to open stream")
|
||||||
std::env::set_var("PULSE_SINK", "VirtualMic");
|
// 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 {
|
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(())
|
||||||
|
|||||||
Reference in New Issue
Block a user