diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4460eb5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + + +## Version 0.1 (2021-10-09) + +- First public release diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2bbb613 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "scanner-soundboard" +version = "0.1.0" +authors = ["Jochen Kupperschmidt"] +edition = "2018" +description = "Trigger sounds via RFID tags or barcodes" +readme = "README.md" +homepage = "https://homework.nwsnet.de/releases/9b23/#scanner-soundboard" +repository = "https://github.com/homeworkprod/scanner-soundboard" +license = "MIT" +keywords = ["audio", "barcode", "rfid"] +categories = ["command-line-utilities", "multimedia::audio"] + +[dependencies] +anyhow = "1.0" +clap = { version = "2.33.3", default-features = false } +evdev = { version = "0.11.1" } +rodio = { version = "0.14", default-features = false, features = ["mp3", "vorbis"] } +serde = { version = "1.0", features = ["derive"] } +toml = "0.5.8" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f62e074 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2021, Jochen Kupperschmidt + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1dec497 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Scanner Soundboard + +Reads codes via RFID or 1D/2D barcode USB scanners and plays soundfiles +mapped to them. + +The input device is grabbed exlusively so that scanned codes will be +passed to the program regardless of what program/window currently has +focus. + +I originally developed this to play insider jokes as custom sounds +(generated via text-to-speech engines) during regular internal evenings +of [Among Us](https://www.innersloth.com/games/among-us/) games. The +sounds are triggered by placing 3D-printed Among Us figurines (glued to +credit card-size RFID tags) on a cheap (~12 €) USB RFID reader, itself +covered by a 3D-printed plan of a map from the game. + + +## Usage + +1. Have a bunch of sound files. + +2. Have a bunch of codes to trigger the sounds. Those codes can come + from RFID tags (10-digit strings seem to be common) or whatever you + can fit in a 1D barcode or matrix/2D barcode (Aztec Code, Data + Matrix, QR code, etc.). Anything your scanner supports. + +3. Specify the path of the sound files and map the codes to sound + filenames in a configuration file (see `config-example.toml` for an + example). + +4. Find out where your scanner is available as a device. `sudo lsinput` + and `sudo dmesg | tail` can help you here. Note that the path can + change over time, depending on the order devices are connected. + +5. Run the program, pointing to the configuration file and input device: + + ```sh + $ scanner-soundboard -c config.toml -i /dev/input/event23 + ``` + + +## Sound Formats + +Ogg Vorbis and MP3 are supported out of the box. However, the employed +audio playback library ([rodio](https://github.com/RustAudio/rodio)) +also supports FLAC, WAV, MP4 and AAC, but those have to be enabled as +features in `Cargo.toml` and require recompilation of the program. + + +## License + +Scanner Soundboard is licensed under the MIT license. diff --git a/config-example.toml b/config-example.toml new file mode 100644 index 0000000..41a1854 --- /dev/null +++ b/config-example.toml @@ -0,0 +1,6 @@ +sounds_path = "sounds" + +[inputs_to_filenames] +"0000000001" = "sound001.ogg" +"0000000002" = "sound002.ogg" +"0000000003" = "sound003.ogg" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b306900 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,151 @@ +/* + * Copyright 2021 Jochen Kupperschmidt + * License: MIT (see file `LICENSE` for details) + */ + +use anyhow::Result; +use clap::{crate_authors, crate_version, App, Arg, ArgMatches}; +use evdev::{Device, EventType, InputEventKind, Key}; +use rodio::{Decoder, OutputStream, Sink}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs::{read_to_string, File}; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::process::exit; + +#[derive(Deserialize)] +struct Config { + sounds_path: PathBuf, + inputs_to_filenames: HashMap, +} + +fn parse_args() -> ArgMatches<'static> { + App::new("RFID Soundboard") + .author(crate_authors!()) + .version(crate_version!()) + .arg( + Arg::with_name("config") + .short("c") + .long("config") + .help("Specify configuration file (e.g. `config.toml`)") + .required(true) + .takes_value(true) + .value_name("FILE"), + ) + .arg( + Arg::with_name("input_device") + .short("i") + .long("input-device") + .help("Specify input device (e.g. `/dev/input/event23`)") + .required(true) + .takes_value(true) + .value_name("DEVICE"), + ) + .get_matches() +} + +fn load_config(path: &Path) -> Result { + let text = read_to_string(path)?; + let config: Config = toml::from_str(&text)?; + Ok(config) +} + +fn get_char(key: Key) -> Option { + match key { + Key::KEY_1 => Some('1'), + Key::KEY_2 => Some('2'), + Key::KEY_3 => Some('3'), + Key::KEY_4 => Some('4'), + Key::KEY_5 => Some('5'), + Key::KEY_6 => Some('6'), + Key::KEY_7 => Some('7'), + Key::KEY_8 => Some('8'), + Key::KEY_9 => Some('9'), + Key::KEY_0 => Some('0'), + _ => None, + } +} + +fn play_sound( + inputs_to_filenames: &HashMap, + input: &str, + dir: &Path, + sink: &Sink, +) -> Result<()> { + match inputs_to_filenames.get(input.trim()) { + Some(filename) => { + let path = dir.join(filename); + if !&path.exists() { + eprintln!("Sound file {} does not exist.", path.display()); + return Ok(()); + } + let source = load_source(&path)?; + sink.append(source); + } + _ => (), + } + Ok(()) +} + +fn load_source(path: &Path) -> Result>> { + let file = BufReader::new(File::open(path)?); + Ok(Decoder::new(file)?) +} + +fn main() -> Result<()> { + let args = parse_args(); + + let config_filename = args.value_of("config").map(Path::new).unwrap(); + let config = load_config(config_filename)?; + + let (_stream, stream_handle) = OutputStream::try_default().unwrap(); + let sink = Sink::try_new(&stream_handle).unwrap(); + + sink.sleep_until_end(); + + let input_device_path = args.value_of("input_device").unwrap(); + let mut input_device = Device::open(input_device_path)?; + println!( + "Opened input device \"{}\".", + input_device.name().unwrap_or("unnamed device") + ); + + match input_device.grab() { + Ok(_) => println!("Successfully obtained exclusive access to input device."), + Err(error) => { + eprintln!("Could not get exclusive access to input device: {}", error); + exit(1); + } + } + + let mut read_chars = String::new(); + loop { + for event in input_device.fetch_events()? { + // Only handle pressed key events. + if event.event_type() != EventType::KEY || event.value() == 1 { + continue; + } + + match event.kind() { + InputEventKind::Key(Key::KEY_ENTER) => { + let input = read_chars.as_str(); + play_sound( + &config.inputs_to_filenames, + input, + config.sounds_path.as_path(), + &sink, + )?; + read_chars.clear(); + } + InputEventKind::Key(key) => { + match get_char(key) { + Some(ch) => read_chars.push(ch), + None => (), + }; + } + _ => (), + } + } + } +}