From 0c8d7d98e7fe3b689df68bd1a063fb664149f80b Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 13:56:19 +0000 Subject: [PATCH 1/8] make sound list appendable To allow custom sounds, we need a mutable sound list. This is achieved through an once_cell::Lazy>>. It's a global static because the sound list is basically global in each run. RwLock is suitable for our situation where writes are rare and reads are often. HashMap is to ease retrieving sounds by name. Names and bytes are leaked because they are basically static in each run. --- Cargo.lock | 47 +++++++++++ Cargo.toml | 4 +- src/sound/play_sound.rs | 167 ++++++++++++++++++++++++---------------- 3 files changed, 150 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 634fd42..f718e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,10 +180,12 @@ dependencies = [ "iq", "lazy-regex", "notify", + "once_cell", "rodio", "rustc-hash 2.1.1", "serde", "serde_json", + "shellexpand", "termimad", "toml", "unicode-width 0.2.0", @@ -659,6 +661,27 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -2116,6 +2139,12 @@ version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.3" @@ -2392,6 +2421,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "shellexpand" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2942,6 +2980,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 2c7e6e9..fff1a4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ license = "AGPL-3.0" categories = ["command-line-utilities", "development-tools"] readme = "README.md" rust-version = "1.76" - + [features] default = [] clipboard = ["arboard"] @@ -32,10 +32,12 @@ glob = "0.3" iq = { version = "0.2", features = ["template"] } lazy-regex = "3.4.1" notify = "7.0" +once_cell = "1.20.3" rodio = { version = "0.20", optional = true, default-features = false, features = ["mp3"] } rustc-hash = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +shellexpand = "3.1.0" termimad = "0.31.1" toml = "0.8" unicode-width = "0.2" diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 6588f86..3ad6fbe 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -3,97 +3,129 @@ use { rodio::OutputStream, std::{ fmt, + collections::HashMap, io::Cursor, + sync::RwLock, time::Duration, }, termimad::crossbeam::channel::Receiver, + once_cell::sync::Lazy as LazyCell, }; +#[derive(Clone, Copy)] struct Sound { bytes: &'static [u8], duration: Duration, } -/// Get a sound by name, or the default sound if name is None +static SOUNDS: LazyCell>> = LazyCell::new(|| { + let mut sounds = HashMap::new(); + sounds.extend(DEFAULT_SOUNDS); + RwLock::new(sounds) +}); + +const DEFAULT_SOUNDS: [(&str, Sound); 15] = [ + ("2", Sound { + bytes: include_bytes!("../../resources/2-100419.mp3"), + duration: Duration::from_millis(2000), + }), + ("90s-game-ui-6", Sound { + bytes: include_bytes!("../../resources/90s-game-ui-6-185099.mp3"), + duration: Duration::from_millis(1300), + }), + ("beep-6", Sound { + bytes: include_bytes!("../../resources/beep-6-96243.mp3"), + duration: Duration::from_millis(1000), + }), + ("beep-beep", Sound { + bytes: include_bytes!("../../resources/beep-beep-6151.mp3"), + duration: Duration::from_millis(1200), + }), + ("beep-warning", Sound { + bytes: include_bytes!("../../resources/beep-warning-6387.mp3"), + duration: Duration::from_millis(1200), + }), + ("bell-chord", Sound { + bytes: include_bytes!("../../resources/bell-chord1-83260.mp3"), + duration: Duration::from_millis(1900), + }), + ("car-horn", Sound { + bytes: include_bytes!("../../resources/car-horn-beepsmp3-14659.mp3"), + duration: Duration::from_millis(1700), + }), + ("convenience-store-ring", Sound { + bytes: include_bytes!("../../resources/conveniencestorering-96090.mp3"), + duration: Duration::from_millis(1700), + }), + ("cow-bells", Sound { + bytes: include_bytes!("../../resources/cow_bells_01-98236.mp3"), + duration: Duration::from_millis(1400), + }), + ("pickup", Sound { + bytes: include_bytes!("../../resources/pickup-sound-46472.mp3"), + duration: Duration::from_millis(500), + }), + ("positive-beeps", Sound { + bytes: include_bytes!("../../resources/positive_beeps-85504.mp3"), + duration: Duration::from_millis(600), + }), + ("short-beep-tone", Sound { + bytes: include_bytes!("../../resources/short-beep-tone-47916.mp3"), + duration: Duration::from_millis(400), + }), + ("slash", Sound { + bytes: include_bytes!("../../resources/slash1-94367.mp3"), + duration: Duration::from_millis(800), + }), + ("store-scanner", Sound { + bytes: include_bytes!("../../resources/store-scanner-beep-90395.mp3"), + duration: Duration::from_millis(250), + }), + ("success", Sound { + bytes: include_bytes!("../../resources/success-48018.mp3"), + duration: Duration::from_millis(2000), + }), +]; + +/// Get a sound by name; or the default sound if name is None. /// +/// There are too kinds of sounds: default and custom. +/// /// Names here are as near as possible from the file names in the /// reources directory but without the number, syntax unconsistency and /// redundancy. Resource file names are kept identical to their original /// names to ease retrival for attribution). fn get_sound(name: Option<&str>) -> Result { let name = name.unwrap_or("store-scanner"); - let sound = match name { - "2" => Sound { - bytes: include_bytes!("../../resources/2-100419.mp3"), - duration: Duration::from_millis(2000), - }, - "90s-game-ui-6" => Sound { - bytes: include_bytes!("../../resources/90s-game-ui-6-185099.mp3"), - duration: Duration::from_millis(1300), - }, - "beep-6" => Sound { - bytes: include_bytes!("../../resources/beep-6-96243.mp3"), - duration: Duration::from_millis(1000), - }, - "beep-beep" => Sound { - bytes: include_bytes!("../../resources/beep-beep-6151.mp3"), - duration: Duration::from_millis(1200), - }, - "beep-warning" => Sound { - bytes: include_bytes!("../../resources/beep-warning-6387.mp3"), - duration: Duration::from_millis(1200), - }, - "bell-chord" => Sound { - bytes: include_bytes!("../../resources/bell-chord1-83260.mp3"), - duration: Duration::from_millis(1900), - }, - "car-horn" => Sound { - bytes: include_bytes!("../../resources/car-horn-beepsmp3-14659.mp3"), - duration: Duration::from_millis(1700), - }, - "convenience-store-ring" => Sound { - bytes: include_bytes!("../../resources/conveniencestorering-96090.mp3"), - duration: Duration::from_millis(1700), - }, - "cow-bells" => Sound { - bytes: include_bytes!("../../resources/cow_bells_01-98236.mp3"), - duration: Duration::from_millis(1400), - }, - "pickup" => Sound { - bytes: include_bytes!("../../resources/pickup-sound-46472.mp3"), - duration: Duration::from_millis(500), - }, - "positive-beeps" => Sound { - bytes: include_bytes!("../../resources/positive_beeps-85504.mp3"), - duration: Duration::from_millis(600), - }, - "short-beep-tone" => Sound { - bytes: include_bytes!("../../resources/short-beep-tone-47916.mp3"), - duration: Duration::from_millis(400), - }, - "slash" => Sound { - bytes: include_bytes!("../../resources/slash1-94367.mp3"), - duration: Duration::from_millis(800), - }, - "store-scanner" => Sound { - bytes: include_bytes!("../../resources/store-scanner-beep-90395.mp3"), - duration: Duration::from_millis(250), - }, - "success" => Sound { - bytes: include_bytes!("../../resources/success-48018.mp3"), - duration: Duration::from_millis(2000), - }, - _ => { - return Err(SoundError::UnknownSoundName(name.to_string())); - } - }; - Ok(sound) + SOUNDS.read().unwrap().get(name).copied().ok_or_else(|| SoundError::UnknownSoundName(name.to_string())) +} + +/// Add a sound from a file (path). +/// +/// Bytes are leaked, as they are loaded once and kept throughout the program's +/// lifetime, just like the default sounds. +/// Likewise, the name is also leaked. +pub(crate) fn add_sound(name: &str, path: &str) -> Result<(), SoundError> { + // ideally we should accept AsRef, but `shellexpand::tilde` requires + // a AsRef. To ease things, allow only allow UTF-8 paths. + let path = shellexpand::tilde(path).to_string(); + let bytes: &'static [u8] = std::fs::read(&path) + .map_err(|_| SoundError::MissingSoundFile(path.to_string()))? + .leak(); + SOUNDS.write().unwrap() + .insert(name.to_string().leak(), Sound { + bytes, + duration: Duration::ZERO, + }); + info!("loaded sound {name:?} from {path:?}"); + Ok(()) } #[derive(Debug)] pub enum SoundError { Interrupted, UnknownSoundName(String), + MissingSoundFile(String), RodioStream(rodio::StreamError), RodioPlay(rodio::PlayError), } @@ -115,6 +147,7 @@ impl fmt::Display for SoundError { match self { SoundError::Interrupted => write!(f, "sound interrupted"), SoundError::UnknownSoundName(name) => write!(f, "unknown sound name: {}", name), + SoundError::MissingSoundFile(path) => write!(f, "missing sound file: {}", path), SoundError::RodioStream(e) => write!(f, "rodio stream error: {}", e), SoundError::RodioPlay(e) => write!(f, "rodio play error: {}", e), } From 0f53f561d4d7eb306747592423ae8d462d70bfac Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 14:08:25 +0000 Subject: [PATCH 2/8] add default-sounds feature With custom sounds, users might want to reduce executable size by removing the default sounds. Make them an optional feature. --- Cargo.toml | 1 + src/sound/play_sound.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fff1a4a..98ffd32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ rust-version = "1.76" default = [] clipboard = ["arboard"] sound = ["rodio"] +default-sounds = ["sound"] [dependencies] anyhow = "1.0" diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 3ad6fbe..944a0ee 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -20,10 +20,12 @@ struct Sound { static SOUNDS: LazyCell>> = LazyCell::new(|| { let mut sounds = HashMap::new(); + #[cfg(feature = "default-sounds")] sounds.extend(DEFAULT_SOUNDS); RwLock::new(sounds) }); +#[cfg(feature = "default-sounds")] const DEFAULT_SOUNDS: [(&str, Sound); 15] = [ ("2", Sound { bytes: include_bytes!("../../resources/2-100419.mp3"), @@ -87,7 +89,8 @@ const DEFAULT_SOUNDS: [(&str, Sound); 15] = [ }), ]; -/// Get a sound by name; or the default sound if name is None. +/// Get a sound by name; or the default sound if name is None, +/// and the default-sounds feature is enabled. /// /// There are too kinds of sounds: default and custom. /// @@ -96,6 +99,10 @@ const DEFAULT_SOUNDS: [(&str, Sound); 15] = [ /// redundancy. Resource file names are kept identical to their original /// names to ease retrival for attribution). fn get_sound(name: Option<&str>) -> Result { + // NOTE: This doesn't distinguish from whether the default-sound feature is + // enabled, and might confuse users. But then again, that only happens when + // a default name is requested while default-sound feature is disabled, + // which shouldn't happen anyway. let name = name.unwrap_or("store-scanner"); SOUNDS.read().unwrap().get(name).copied().ok_or_else(|| SoundError::UnknownSoundName(name.to_string())) } From 887161fd3e93658a90835f1d01a0b67fd83ca02e Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 14:10:58 +0000 Subject: [PATCH 3/8] read sound duration at runtime With custom sounds, there's no way to know their durations beforehand, like for default sounds. rodio has the `Source::total_duration()` trait method, which is implemented on its `Decoder`, so that is used. But it returns an `Option`, i.e. reading is not guaranteed. Thus we ignore the interrupt for such sounds, for now. --- src/sound/play_sound.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 944a0ee..7698f8e 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -1,6 +1,6 @@ use { super::*, - rodio::OutputStream, + rodio::{OutputStream, Decoder, Source}, std::{ fmt, collections::HashMap, @@ -119,6 +119,8 @@ pub(crate) fn add_sound(name: &str, path: &str) -> Result<(), SoundError> { let bytes: &'static [u8] = std::fs::read(&path) .map_err(|_| SoundError::MissingSoundFile(path.to_string()))? .leak(); + // TODO: check for duration right here, or use Option? + // If to check, might as well replace `bytes` with decoded struct SOUNDS.write().unwrap() .insert(name.to_string().leak(), Sound { bytes, @@ -133,9 +135,15 @@ pub enum SoundError { Interrupted, UnknownSoundName(String), MissingSoundFile(String), + RodioDecode(rodio::decoder::DecoderError), RodioStream(rodio::StreamError), RodioPlay(rodio::PlayError), } +impl From for SoundError { + fn from(e: rodio::decoder::DecoderError) -> Self { + SoundError::RodioDecode(e) + } +} impl From for SoundError { fn from(e: rodio::StreamError) -> Self { SoundError::RodioStream(e) @@ -155,6 +163,7 @@ impl fmt::Display for SoundError { SoundError::Interrupted => write!(f, "sound interrupted"), SoundError::UnknownSoundName(name) => write!(f, "unknown sound name: {}", name), SoundError::MissingSoundFile(path) => write!(f, "missing sound file: {}", path), + SoundError::RodioDecode(e) => write!(f, "rodio decode error: {}", e), SoundError::RodioStream(e) => write!(f, "rodio stream error: {}", e), SoundError::RodioPlay(e) => write!(f, "rodio play error: {}", e), } @@ -172,9 +181,15 @@ pub fn play_sound( let Sound { bytes, duration } = get_sound(psc.name.as_deref())?; let (_stream, stream_handle) = OutputStream::try_default()?; let sound = Cursor::new(bytes); + let decoder = Decoder::new(sound.clone())?; + let duration = if duration == Duration::ZERO { + decoder.total_duration() + } else { + Some(duration) + }; let sink = stream_handle.play_once(sound)?; sink.set_volume(psc.volume.as_part()); - if interrupt.recv_timeout(duration).is_ok() { + if duration.is_some() && interrupt.recv_timeout(duration.unwrap()).is_ok() { info!("sound interrupted"); Err(SoundError::Interrupted) } else { From b3acc3e6e2c4d2b6a76ec19c8bca2b7a7f68eba1 Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 15:37:12 +0000 Subject: [PATCH 4/8] support play-sound(path=...) --- src/internal.rs | 18 ++++++++++++++++-- src/sound/mod.rs | 1 + src/sound/play_sound.rs | 10 +++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/internal.rs b/src/internal.rs index c7a48f6..5a4ba7c 100644 --- a/src/internal.rs +++ b/src/internal.rs @@ -111,11 +111,14 @@ impl fmt::Display for Internal { Self::Validate => write!(f, "validate"), Self::NextMatch => write!(f, "next-match"), Self::PreviousMatch => write!(f, "previous-match"), - Self::PlaySound(PlaySoundCommand { name, volume }) => { + Self::PlaySound(PlaySoundCommand { name, path, volume }) => { write!(f, "play-sound(")?; if let Some(name) = name { write!(f, "name={},", name)?; } + if let Some(path) = path { + write!(f, "path={},", path)?; + } write!(f, "volume={})", volume) } } @@ -159,12 +162,16 @@ impl std::str::FromStr for Internal { let iter = regex_captures_iter!(r"([^=,]+)=([^=,]+)", props); let mut volume = Volume::default(); let mut name = None; + let mut path = None; for (_, [prop_name, prop_value]) in iter.map(|c| c.extract()) { let prop_value = prop_value.trim(); match prop_name.trim() { "name" => { name = Some(prop_value.to_string()); } + "path" => { + path = Some(prop_value.to_string()); + } "volume" => { volume = prop_value.parse()?; } @@ -173,7 +180,10 @@ impl std::str::FromStr for Internal { } } } - return Ok(Self::PlaySound(PlaySoundCommand { name, volume })); + if name.is_some() && path.is_some() { + return Err("invalid play-sound parameter: only one of name or path can be specified".to_string()); + } + return Ok(Self::PlaySound(PlaySoundCommand { name, path, volume })); } Err("invalid internal".to_string()) } @@ -231,14 +241,17 @@ fn test_internal_string_round_trip() { Internal::PlaySound(PlaySoundCommand::default()), Internal::PlaySound(PlaySoundCommand { name: None, + path: None, volume: Volume::new(50), }), Internal::PlaySound(PlaySoundCommand { name: Some("beep-beep".to_string()), + path: None, volume: Volume::new(100), }), Internal::PlaySound(PlaySoundCommand { name: None, + path: None, volume: Volume::new(0), }), ]; @@ -260,6 +273,7 @@ fn test_play_sound_parsing_with_space() { ]; let psc = PlaySoundCommand { name: Some("car-horn".to_string()), + path: None, volume: Volume::new(5), }; for string in &strings { diff --git a/src/sound/mod.rs b/src/sound/mod.rs index 0f8c759..9945f9f 100644 --- a/src/sound/mod.rs +++ b/src/sound/mod.rs @@ -23,5 +23,6 @@ pub use { #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub struct PlaySoundCommand { pub name: Option, + pub path: Option, pub volume: Volume, } diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 7698f8e..8ac3478 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -178,7 +178,15 @@ pub fn play_sound( interrupt: Receiver<()>, ) -> Result<(), SoundError> { debug!("play sound: {:#?}", psc); - let Sound { bytes, duration } = get_sound(psc.name.as_deref())?; + let name = if let Some(ref path) = psc.path { + // For one-off paths, there's no need to make up a name for it, but the + // HashMap needs a key. Just use its path. + add_sound(path, path).map_err(|_| SoundError::MissingSoundFile(path.to_string()))?; + Some(path.as_str()) + } else { + psc.name.as_deref() + }; + let Sound { bytes, duration } = get_sound(name)?; let (_stream, stream_handle) = OutputStream::try_default()?; let sound = Cursor::new(bytes); let decoder = Decoder::new(sound.clone())?; From fc4d90d8c095548bdc559770f0dbb5c34e0d9b25 Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 15:44:28 +0000 Subject: [PATCH 5/8] add `[sound.collection]` and load it on SoundConfig application --- src/jobs/job.rs | 1 + src/sound/sound_config.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/jobs/job.rs b/src/jobs/job.rs index 8772573..a141146 100644 --- a/src/jobs/job.rs +++ b/src/jobs/job.rs @@ -260,6 +260,7 @@ fn test_job_apply() { sound: SoundConfig { enabled: Some(true), base_volume: Some(Volume::from_str("50").unwrap()), + collection: None, }, }; base_job.apply(&job_to_apply); diff --git a/src/sound/sound_config.rs b/src/sound/sound_config.rs index 52fc5ba..0e5e428 100644 --- a/src/sound/sound_config.rs +++ b/src/sound/sound_config.rs @@ -1,5 +1,6 @@ use { crate::*, + std::collections::HashMap, serde::Deserialize, }; @@ -7,6 +8,7 @@ use { pub struct SoundConfig { pub enabled: Option, pub base_volume: Option, + pub collection: Option>, } impl SoundConfig { @@ -20,6 +22,22 @@ impl SoundConfig { if let Some(bv) = sc.base_volume { self.base_volume = Some(bv); } + #[cfg(feature = "sound")] + // Load sounds configured in `[sound.collection]`. + // This doesn't "apply" this item in the sense other items are applied; + // but rather, add them to `super::play_sound::SOUNDS` so they can be + // looked up later. + // Ideally, they should be loaded at some more suitable point, or even + // passed to `super::play_sound::play_sound()` to achieve some degree + // of on-demand loading. But current structure of `SoundPlayer` makes + // that difficult. + if let Some(ref collection) = sc.collection { + for (name, path) in collection { + // Silently ignore failures adding sounds. + // We might want to give the user some hints later. + crate::sound::play_sound::add_sound(name, path).ok(); + } + } } pub fn is_enabled(&self) -> bool { self.enabled.unwrap_or(false) From 4e3efa83747bb905c7c49daa1a5d2c3a5cc06fc1 Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Sun, 9 Mar 2025 16:22:37 +0000 Subject: [PATCH 6/8] update docs and default-prefs with custom sounds and default-sounds feature --- README.md | 3 ++- defaults/default-prefs.toml | 8 +++++++- website/docs/config.md | 25 ++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 501fe1a..3975562 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Run this command too if you want to update bacon. Configuration has always been Some features are disabled by default. You may enable them with - cargo install --features "clipboard sound" + cargo install --features "clipboard sound default-sounds" ## check the current project @@ -116,6 +116,7 @@ Some bacon features can be disabled or enabled at compilation: * `"clipboard"` - disabled by default : necessary for the `copy-unstyled-output` internal * `"sound"` - disabled by default : necessary for the `play-sound` internal +* `"default-sounds"` - disabled by default: embed some default sounds for the `play-sound` internal ## Licences diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml index d76fe5c..f3ce5e4 100644 --- a/defaults/default-prefs.toml +++ b/defaults/default-prefs.toml @@ -79,6 +79,13 @@ line_format = "{kind} {path}:{line}:{column} {message}" enabled = false # set true to allow sound base_volume = "100%" # global volume multiplier +# Specify your own sound files, e.g. +# bepop = "~/audio/bepop.mp3" +# +# Then use its name as usual in a job, e.g. +# on_success = "play-sound(name=bepop,volume=42)" +[sound.collection] + # Uncomment and change the key-bindings you want to define # (some of those ones are the defaults and are just here for illustration) [keybindings] @@ -110,4 +117,3 @@ base_volume = "100%" # global volume multiplier # r = "job:run" # ctrl-e = "export:analysis" # ctrl-c = "copy-unstyled-output" - diff --git a/website/docs/config.md b/website/docs/config.md index e2fee57..f3b6365 100644 --- a/website/docs/config.md +++ b/website/docs/config.md @@ -323,4 +323,27 @@ on_success = "play-sound(name=90s-game-ui-6,volume=50)" on_failure = "play-sound(name=beep-warning,volume=100)" ``` -Sound name can be omitted. Possible values are `2`, `90s-game-ui-6`, `beep-6`, `beep-beep`, `beep-warning`, `bell-chord`, `car-horn`, `convenience-store-ring`, `cow-bells`, `pickup`, `positive-beeps`, `short-beep-tone`, `slash`, `store-scanner`, `success`. +Sound name can be omitted. + +If the `default-sounds` feature is enabled, some pre-chosen sounds are available; their names are `2`, `90s-game-ui-6`, `beep-6`, `beep-beep`, `beep-warning`, `bell-chord`, `car-horn`, `convenience-store-ring`, `cow-bells`, `pickup`, `positive-beeps`, `short-beep-tone`, `slash`, `store-scanner`, `success`. + +Or, you can add your own sounds. + +Either use a direct path, eg + +```TOML +on_success = "play-sound(path=media/bepop.mp3,volume=42)" +``` + +or, add sounds to a root level collection, eg + +```TOML +[sound.collection] +bepop = "~/audio/bepop.mp3" +``` + +then use its name in a job as usual, eg + +```TOML +on_success = "play-sound(name=bepop,volume=42)" +``` From 7e3a3f278b7d8d5d054929c5bc40594e4158a3bd Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Tue, 1 Apr 2025 13:01:45 +0000 Subject: [PATCH 7/8] PREVIEW: fix reading duration with upcoming rodio fix, Duration::MAX --- Cargo.toml | 3 ++- src/sound/play_sound.rs | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 98ffd32..eb0a3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ iq = { version = "0.2", features = ["template"] } lazy-regex = "3.4.1" notify = "7.0" once_cell = "1.20.3" -rodio = { version = "0.20", optional = true, default-features = false, features = ["mp3"] } +rodio = { version = "0.20", optional = true, default-features = false, features = ["playback", "mp3"] } rustc-hash = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -51,6 +51,7 @@ strip = "symbols" codegen-units = 1 [patch.crates-io] +rodio = { git = "https://github.com/RustAudio/rodio", rev = "0d352f5f2678226e843aa9c0ddea080f1e6d80ae" } # clap-help = { path = "../clap-help" } # termimad = { path = "../termimad" } # crokey = { path = "../crokey" } diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 8ac3478..69bda70 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -1,6 +1,6 @@ use { super::*, - rodio::{OutputStream, Decoder, Source}, + rodio::{OutputStreamBuilder, Decoder, Source}, std::{ fmt, collections::HashMap, @@ -187,20 +187,28 @@ pub fn play_sound( psc.name.as_deref() }; let Sound { bytes, duration } = get_sound(name)?; - let (_stream, stream_handle) = OutputStream::try_default()?; + let stream = OutputStreamBuilder::from_default_device()?.open_stream()?; let sound = Cursor::new(bytes); - let decoder = Decoder::new(sound.clone())?; + let decoder = Decoder::builder() + .with_data(sound.clone()) + .with_byte_len(bytes.len() as u64) + .build()?; let duration = if duration == Duration::ZERO { - decoder.total_duration() + let duration = decoder.total_duration(); + info!("sound duration not predefined, decoder reports {duration:?}"); + duration } else { + info!("sound duration: {duration:?}"); Some(duration) }; - let sink = stream_handle.play_once(sound)?; + let mixer = stream.mixer(); + let sink = rodio::play(mixer, sound)?; sink.set_volume(psc.volume.as_part()); - if duration.is_some() && interrupt.recv_timeout(duration.unwrap()).is_ok() { - info!("sound interrupted"); - Err(SoundError::Interrupted) - } else { - Ok(()) + if interrupt.recv_timeout(duration.unwrap_or(Duration::MAX)).is_ok() { + if duration.is_some() { + info!("sound interrupted"); + return Err(SoundError::Interrupted) + } } + Ok(()) } From 57c809219d068f88c117c8429e2013a798ccc6f1 Mon Sep 17 00:00:00 2001 From: Blair Noctis Date: Thu, 8 May 2025 14:32:21 +0000 Subject: [PATCH 8/8] get sound duration at add time so that part of code is only run once, rather than each time a sound is played. --- src/sound/play_sound.rs | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs index 69bda70..68834a5 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -119,14 +119,18 @@ pub(crate) fn add_sound(name: &str, path: &str) -> Result<(), SoundError> { let bytes: &'static [u8] = std::fs::read(&path) .map_err(|_| SoundError::MissingSoundFile(path.to_string()))? .leak(); - // TODO: check for duration right here, or use Option? - // If to check, might as well replace `bytes` with decoded struct - SOUNDS.write().unwrap() - .insert(name.to_string().leak(), Sound { - bytes, - duration: Duration::ZERO, - }); - info!("loaded sound {name:?} from {path:?}"); + + let decoder = Decoder::builder() + .with_data(Cursor::new(bytes)) + .with_byte_len(bytes.len() as u64) + .build()?; + // Duration::MAX guarantees the sound is played. See discussion in + // https://github.com/Canop/bacon/pull/341 + let duration = decoder.total_duration().unwrap_or(Duration::MAX); + + SOUNDS.write().unwrap().insert(name.to_string().leak(), Sound { bytes, duration }); + + info!("loaded sound {name:?} from {path:?}, duration: {duration:?}"); Ok(()) } @@ -189,26 +193,12 @@ pub fn play_sound( let Sound { bytes, duration } = get_sound(name)?; let stream = OutputStreamBuilder::from_default_device()?.open_stream()?; let sound = Cursor::new(bytes); - let decoder = Decoder::builder() - .with_data(sound.clone()) - .with_byte_len(bytes.len() as u64) - .build()?; - let duration = if duration == Duration::ZERO { - let duration = decoder.total_duration(); - info!("sound duration not predefined, decoder reports {duration:?}"); - duration - } else { - info!("sound duration: {duration:?}"); - Some(duration) - }; let mixer = stream.mixer(); let sink = rodio::play(mixer, sound)?; sink.set_volume(psc.volume.as_part()); - if interrupt.recv_timeout(duration.unwrap_or(Duration::MAX)).is_ok() { - if duration.is_some() { - info!("sound interrupted"); - return Err(SoundError::Interrupted) - } + if interrupt.recv_timeout(duration).is_ok() { + info!("sound interrupted"); + return Err(SoundError::Interrupted) } Ok(()) }