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..eb0a3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,12 @@ license = "AGPL-3.0" categories = ["command-line-utilities", "development-tools"] readme = "README.md" rust-version = "1.76" - + [features] default = [] clipboard = ["arboard"] sound = ["rodio"] +default-sounds = ["sound"] [dependencies] anyhow = "1.0" @@ -32,10 +33,12 @@ glob = "0.3" iq = { version = "0.2", features = ["template"] } lazy-regex = "3.4.1" notify = "7.0" -rodio = { version = "0.20", optional = true, default-features = false, features = ["mp3"] } +once_cell = "1.20.3" +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" +shellexpand = "3.1.0" termimad = "0.31.1" toml = "0.8" unicode-width = "0.2" @@ -48,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/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/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/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/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 6588f86..68834a5 100644 --- a/src/sound/play_sound.rs +++ b/src/sound/play_sound.rs @@ -1,102 +1,153 @@ use { super::*, - rodio::OutputStream, + rodio::{OutputStreamBuilder, Decoder, Source}, 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(); + #[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"), + 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, +/// and the default-sounds feature is enabled. /// +/// 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 { + // 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"); - 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(); + + 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(()) } #[derive(Debug)] 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) @@ -115,6 +166,8 @@ 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::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), } @@ -129,15 +182,23 @@ pub fn play_sound( interrupt: Receiver<()>, ) -> Result<(), SoundError> { debug!("play sound: {:#?}", psc); - let Sound { bytes, duration } = get_sound(psc.name.as_deref())?; - let (_stream, stream_handle) = OutputStream::try_default()?; + 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 = OutputStreamBuilder::from_default_device()?.open_stream()?; let sound = Cursor::new(bytes); - 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 interrupt.recv_timeout(duration).is_ok() { info!("sound interrupted"); - Err(SoundError::Interrupted) - } else { - Ok(()) + return Err(SoundError::Interrupted) } + Ok(()) } 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) 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)" +```