This commit is contained in:
Gabor Körber 2023-07-17 22:43:55 +02:00
commit 12ea20f270
11 changed files with 2009 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1638
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "soundboard"
version = "0.1.0"
edition = "2021"
default-run = "soundboard"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.6.18"
axum-template = { version = "0.19.0", features = ["minijinja"] }
cpal = "0.15.2"
minijinja = { version = "1.0.3", features = ["loader"] }
minimp3 = "0.5.1"
regex = "1.9.0"
rodio = "0.17.1"
tokio = { version = "1.29.1", features = ["full"] }
xxhash-rust = { version = "0.8.6", features = ["xxh3", "const_xxh3"] }

Binary file not shown.

21
src/bin/script_files.rs Normal file
View File

@ -0,0 +1,21 @@
use soundboard::soundclips::*;
fn main() {
println!("Starting...");
if let Some(sound_clips) = scan_directory_for_clips(
"D:/work/workspace/rust_explore/soundboard/sounds/example",
&["mp3", "wav"],
) {
println!("Sound clips found: {}", sound_clips.len());
println!(
"{}",
sound_clips
.iter()
.map(|clip| format!("{}", clip))
.collect::<Vec<String>>()
.join("\n")
);
} else {
println!("No clips found.");
}
}

70
src/bin/test_play.rs Normal file
View File

@ -0,0 +1,70 @@
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{Device, Host};
use minimp3::{Decoder, Error};
use regex::Regex;
use std::fs::File;
use std::io::Cursor;
use std::io::Read;
fn list_devices(host: &Host) -> Vec<String> {
let devices = host.output_devices().unwrap();
devices.map(|device| device.name().unwrap()).collect()
}
fn find_device_index_regex(devices: Vec<String>, pattern: &str) -> Option<usize> {
let re = Regex::new(pattern).unwrap();
devices.iter().position(|device| re.is_match(device))
}
fn select_device(host: &Host, i: usize) -> Option<Device> {
host.output_devices().unwrap().nth(i)
}
fn find_virtual_audio_cable() -> Option<Device> {
let host = cpal::default_host();
let devices = list_devices(&host);
println!("Devices: {:?}", devices);
let pattern = ".*VB-Audio Virtual Cable.*";
if let Some(index) = find_device_index_regex(devices, pattern) {
println!("Selecting Device: \"{}\" at index {:?}", pattern, index);
return select_device(&host, index);
}
return None;
}
fn load_and_play_mp3(device: &Device, file_name: &str) {
let mut mp3_file = File::open(file_name).unwrap();
let mut mp3_data = Vec::new();
mp3_file.read_to_end(&mut mp3_data).unwrap();
// Create a rodio Sink connected to our device
let (_stream, stream_handle) = rodio::OutputStream::try_from_device(&device).unwrap();
let sink = rodio::Sink::try_new(&stream_handle).unwrap();
// Iterate over the MP3 frames and play them
let mut decoder = Decoder::new(Cursor::new(mp3_data));
loop {
match decoder.next_frame() {
Ok(frame) => {
let source = rodio::buffer::SamplesBuffer::new(
2,
frame.sample_rate.try_into().unwrap(),
&*frame.data,
);
sink.append(source);
}
Err(Error::Eof) => break,
Err(e) => panic!("{:?}", e),
}
}
// Wait for the sound to finish
sink.sleep_until_end();
}
fn main() {
let file_name = "sounds/example/liv_aaaaaaaa.mp3";
let device = find_virtual_audio_cable().unwrap();
load_and_play_mp3(&device, file_name);
}

2
src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod soundclips;
pub mod vbplay;

75
src/main.rs Normal file
View File

@ -0,0 +1,75 @@
use axum::{routing, Router};
use axum_template::{engine::Engine, Key, RenderHtml};
use minijinja::{path_loader, Environment};
use std::net::SocketAddr;
use std::thread;
use std::time::Duration;
mod soundclips;
mod vbplay;
type AppEngine = Engine<Environment<'static>>;
fn play_liv() {
let file_name = "sounds/example/liv_aaaaaaaa.mp3";
let tx = vbplay::audio_thread();
tx.send(vbplay::Command::Play(file_name.into())).unwrap();
thread::sleep(Duration::from_secs(5));
tx.send(vbplay::Command::Stop).unwrap();
}
#[tokio::main]
async fn main() {
let mut jinja = Environment::new();
jinja.set_loader(path_loader("templates"));
// Set the address to run our application on
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
// Build our application with a route
let app = Router::new()
.route("/", routing::get(home))
.route("/play/:x", routing::get(play_handler))
.with_state(Engine::from(jinja));
println!("listening on {}", addr);
// Run it with hyper
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
async fn home(engine: AppEngine) -> impl axum::response::IntoResponse {
RenderHtml(Key("index.html".to_owned()), engine, ())
}
async fn root_handler() -> axum::response::Html<&'static str> {
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Play</title>
<script>
function play() {
fetch("/play/1");
}
</script>
</head>
<body>
<button onclick="play()">Play</button>
</body>
</html>
"#;
html.into()
}
async fn play_handler(axum::extract::Path(x): axum::extract::Path<u32>) -> String {
play_liv();
format!("You sent: {}", x)
}

79
src/soundclips.rs Normal file
View File

@ -0,0 +1,79 @@
use std::ffi::OsStr;
use std::fmt;
use std::fs;
#[derive(Debug, Clone)]
pub struct SoundClip {
hash: String,
file_name: String,
}
impl fmt::Display for SoundClip {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"SoundClip (hash: {}, file_name: {})",
self.hash, self.file_name
)
}
}
impl SoundClip {
pub fn new(file_name: String) -> Self {
Self {
hash: encode_filename(&file_name),
file_name: file_name,
}
}
pub fn hash(&self) -> &String {
&self.hash
}
pub fn file_name(&self) -> &String {
&self.file_name
}
}
fn encode_filename(file_name: &str) -> String {
let hash = xxhash_rust::xxh3::xxh3_64(file_name.as_bytes());
let hash_hex = format!("{:x}", hash);
hash_hex
}
pub fn scan_directory_for_clips(directory: &str, extensions: &[&str]) -> Option<Vec<SoundClip>> {
let mut sound_clips = Vec::new();
if let Ok(entries) = fs::read_dir(directory) {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if extensions.iter().any(|&e| ext == OsStr::new(e)) {
let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
/*
the calls to unwrap() are more or less "safe" because:
file_name() only returns None if the path ends in "..", which won't be the case for paths returned by read_dir.
to_str() only returns None if the file name isn't valid Unicode, which is quite rare on most modern file systems.
*/
let sound_clip = SoundClip::new(file_name);
sound_clips.push(sound_clip);
}
}
}
} else {
println!("Failed to read directory entry: {}", entry.unwrap_err());
}
}
} else {
println!(
"Failed to read directory: {}",
fs::read_dir(directory).unwrap_err()
);
return None;
}
Some(sound_clips)
}

91
src/vbplay.rs Normal file
View File

@ -0,0 +1,91 @@
use cpal::traits::{DeviceTrait, HostTrait};
use cpal::{Device, Host};
use minimp3::{Decoder, Error};
use regex::Regex;
use std::fs::File;
use std::io::Cursor;
use std::io::Read;
use std::sync::mpsc::{self, Sender};
use std::thread;
pub enum Command {
Play(String),
Stop,
}
fn list_devices(host: &Host) -> Vec<String> {
let devices = host.output_devices().unwrap();
devices.map(|device| device.name().unwrap()).collect()
}
fn find_device_index_regex(devices: Vec<String>, pattern: &str) -> Option<usize> {
let re = Regex::new(pattern).unwrap();
devices.iter().position(|device| re.is_match(device))
}
fn select_device(host: &Host, i: usize) -> Option<Device> {
host.output_devices().unwrap().nth(i)
}
fn find_virtual_audio_cable() -> Option<Device> {
let host = cpal::default_host();
let devices = list_devices(&host);
println!("Devices: {:?}", devices);
let pattern = ".*VB-Audio Virtual Cable.*";
if let Some(index) = find_device_index_regex(devices, pattern) {
println!("Selecting Device: \"{}\" at index {:?}", pattern, index);
return select_device(&host, index);
}
return None;
}
pub fn audio_thread() -> Sender<Command> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let device = find_virtual_audio_cable().unwrap();
// Create a rodio Sink connected to our device
let (_stream, stream_handle) = rodio::OutputStream::try_from_device(&device).unwrap();
let sink = rodio::Sink::try_new(&stream_handle).unwrap();
for command in rx {
match command {
Command::Play(file_name) => {
play_file(&sink, file_name);
}
Command::Stop => {
sink.stop();
}
}
}
});
tx
}
fn play_file(sink: &rodio::Sink, file_name: String) {
let mut mp3_file = File::open(file_name).unwrap();
let mut mp3_data = Vec::new();
mp3_file.read_to_end(&mut mp3_data).unwrap();
// Iterate over the MP3 frames and play them
let mut decoder = Decoder::new(Cursor::new(mp3_data));
loop {
match decoder.next_frame() {
Ok(frame) => {
let source = rodio::buffer::SamplesBuffer::new(
2,
frame.sample_rate.try_into().unwrap(),
&*frame.data,
);
sink.append(source);
}
Err(Error::Eof) => {
println!("EOF");
break;
}
Err(e) => panic!("{:?}", e),
}
}
}

14
templates/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Play</title>
<script>
function play() {
fetch("/play/1");
}
</script>
</head>
<body>
<button onclick="play()">Play</button>
</body>
</html>