From 2e839398b292ad304e25d87f2c704d3667ee9c75 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 02:34:43 +0300 Subject: [PATCH] feat: combo shutter sound --- nix/package.ulu.nix | 7 +++- src/config.rs | 47 +++++++++++++++++++++++ src/main.rs | 24 +++++++++++- src/sound/mod.rs | 89 ++++++++++++++++++++++++++++++++++++++++++- src/toolbar/draw.rs | 2 + src/toolbar/layout.rs | 7 +++- src/toolbar/mod.rs | 3 ++ src/toolbar/state.rs | 9 ++++- 8 files changed, 181 insertions(+), 7 deletions(-) diff --git a/nix/package.ulu.nix b/nix/package.ulu.nix index bc70a7d..c5cd557 100644 --- a/nix/package.ulu.nix +++ b/nix/package.ulu.nix @@ -38,7 +38,12 @@ # Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first. # The editor embeds DejaVu Sans Bold; its licence wants the notice shipped. postInstall = '' - wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} + wrapProgram $out/bin/valw --suffix PATH : ${ + lib.makeBinPath [ + pkgs.satty + pkgs.pipewire + ] + } install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu ''; diff --git a/src/config.rs b/src/config.rs index 112a547..b6c37bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ pub struct Config { pub preview: Preview, pub zoom: Zoom, pub editor: Editor, + pub sound: Sound, } #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -69,6 +70,26 @@ pub enum Backend { Satty, } +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(default)] +pub struct Sound { + pub enabled: bool, + /// 0.0–1.0. + pub volume: f32, + /// A pause this long starts the combo over. + pub combo_reset_secs: u64, +} + +impl Default for Sound { + fn default() -> Self { + Self { + enabled: true, + volume: 0.6, + combo_reset_secs: 5, + } + } +} + impl Default for Preview { fn default() -> Self { Self { @@ -139,6 +160,12 @@ pub fn parse(text: &str) -> Result<(Config, Vec)> { if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) { bail!("zoom.flashlight_radius must be between 20 and 2000"); } + if !(0.0..=1.0).contains(&config.sound.volume) { + bail!("sound.volume must be between 0 and 1"); + } + if !(1..=60).contains(&config.sound.combo_reset_secs) { + bail!("sound.combo_reset_secs must be between 1 and 60"); + } Ok((config, unknown)) } @@ -247,6 +274,26 @@ mod tests { assert!(parse("[editor]\nbackend = \"gimp\"\n").is_err()); } + #[test] + fn sound_defaults_and_bounds() { + let s = Config::default().sound; + assert!(s.enabled); + assert_eq!((s.volume, s.combo_reset_secs), (0.6, 5)); + for bad in ["volume = 1.5", "volume = nan", "volume = -0.1"] { + let err = parse(&format!("[sound]\n{bad}\n")).unwrap_err(); + assert_eq!( + err.to_string(), + "sound.volume must be between 0 and 1", + "{bad}" + ); + } + let err = parse("[sound]\ncombo_reset_secs = 0\n").unwrap_err(); + assert_eq!( + err.to_string(), + "sound.combo_reset_secs must be between 1 and 60" + ); + } + #[test] fn missing_file_gives_defaults() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 9c7b2a4..2050342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,6 @@ mod region; mod render; mod selection; mod shadow; -// The sound lands in two tasks; Task 5 of the polish plan removes this. -#[allow(dead_code)] mod sound; mod stack; mod thumbnail; @@ -86,6 +84,9 @@ enum Command { /// Internal: the process that shows preview thumbnails. #[command(name = "__preview-host", hide = true)] PreviewHost, + /// Internal: play the whole combo, for tuning the sounds by ear. + #[command(name = "__combo-demo", hide = true)] + ComboDemo, /// Internal: serve a PNG from stdin as the clipboard (the editor's copy). #[command(name = "__clipboard", hide = true)] Clipboard, @@ -114,6 +115,8 @@ struct Common { toolbar_cursor: Option, #[arg(skip)] toolbar_preview: Option, + #[arg(skip)] + toolbar_sound: Option, } /// Whether the cursor goes into the shot. @@ -123,6 +126,11 @@ fn wants_cursor(common: &Common, config: &Config) -> bool { .unwrap_or(common.cursor || config.capture.show_cursor) } +/// Whether the shutter sound plays. +fn wants_sound(common: &Common, config: &Config) -> bool { + common.toolbar_sound.unwrap_or(config.sound.enabled) +} + /// Whether the preview thumbnail is shown. fn wants_preview(common: &Common, config: &Config) -> bool { common @@ -180,6 +188,7 @@ fn run(cli: Cli) -> Result<()> { Command::Window { common } => capture(Mode::Window, common), Command::Zoom { common } => capture(Mode::Zoom, common), Command::Edit { file } => editor::run(&file), + Command::ComboDemo => sound::demo(&config::load(&config::default_path())?.sound), Command::Toolbar => { let config = config::load(&config::default_path())?; let (mode, common) = from_toolbar(toolbar::run(&config)?); @@ -202,6 +211,7 @@ fn capture(mode: Mode, common: Common) -> Result<()> { } let cursor = wants_cursor(&common, &config); let preview = wants_preview(&common, &config); + let sound = wants_sound(&common, &config); let mut wl = Wayland::connect()?; if let Ok(version) = niri::version() { tracing::info!("niri {version}"); @@ -256,6 +266,7 @@ fn capture(mode: Mode, common: Common) -> Result<()> { } } }; + sound::play(&config.sound, sound); drop(wl); let target = Target::from_args(common.output, common.clipboard_only); @@ -305,6 +316,7 @@ fn from_toolbar(pick: toolbar::Picked) -> (Mode, Common) { no_preview: false, toolbar_cursor: Some(pick.cursor), toolbar_preview: Some(pick.preview), + toolbar_sound: Some(pick.sound), }; (mode, common) } @@ -406,6 +418,7 @@ mod tests { assert!(!parses(&["edit"])); assert!(parses(&["__clipboard"])); assert!(parses(&["toolbar"])); + assert!(parses(&["__combo-demo"])); } #[test] @@ -415,6 +428,7 @@ mod tests { mode, cursor, preview, + sound: true, }; let (mode, common) = from_toolbar(pick(T::Screen, true, false)); assert!(matches!(mode, Mode::Screen { all: false })); @@ -453,9 +467,14 @@ mod tests { mode: T::Screen, cursor, preview, + sound: preview, }) .1 }; + on.sound.enabled = true; + off.sound.enabled = false; + assert!(!wants_sound(&pick(false, false), &on)); + assert!(wants_sound(&pick(true, true), &off)); assert!( !wants_cursor(&pick(false, false), &on), "unticked beats the config" @@ -500,6 +519,7 @@ mod tests { let help = Cli::command().render_help().to_string(); assert!(!help.contains("preview-host"), "{help}"); assert!(!help.contains("__clipboard"), "{help}"); + assert!(!help.contains("combo-demo"), "{help}"); } #[test] diff --git a/src/sound/mod.rs b/src/sound/mod.rs index bbbf6d2..da5e552 100644 --- a/src/sound/mod.rs +++ b/src/sound/mod.rs @@ -1,4 +1,91 @@ -//! The combo shutter sound. +//! The combo shutter sound, played with PipeWire's `pw-play`. pub mod combo; pub mod synth; + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; + +use crate::config; + +fn runtime_dir() -> PathBuf { + crate::lock::default_path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(std::env::temp_dir) +} + +/// The WAV for shot `n`, written into `dir` the first time. +fn cached(dir: &Path, n: u8) -> Result { + let path = dir.join(format!("{n}.wav")); + if !path.exists() { + std::fs::create_dir_all(dir) + .with_context(|| format!("could not create {}", dir.display()))?; + crate::output::write_atomic(&path, &synth::wav(&synth::sound(n)))?; + } + Ok(path) +} + +/// Plays the next sound of the combo, unless sounds are off. Never fails the +/// capture: problems are logged. +pub fn play(config: &config::Sound, enabled: bool) { + if !enabled { + return; + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let state = runtime_dir().join("valw-combo"); + let n = combo::next(combo::load(&state), now, config.combo_reset_secs * 1000); + if let Err(e) = combo::save(&state, n, now) { + tracing::warn!("could not remember the combo: {e:#}"); + } + if let Err(e) = spawn(n, config.volume) { + tracing::warn!("no shutter sound: {e:#}"); + } +} + +fn spawn(n: u8, volume: f32) -> Result<()> { + use std::os::unix::process::CommandExt; + let path = cached(&runtime_dir().join("valw-sound"), n)?; + let mut command = std::process::Command::new("pw-play"); + command + .arg(format!("--volume={volume}")) + .arg(&path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // SAFETY: setsid is async-signal-safe. + unsafe { + command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); + } + command.spawn().context("could not start pw-play")?; + Ok(()) +} + +/// `valw __combo-demo`: the whole combo, for tuning by ear. +pub fn demo(config: &config::Sound) -> Result<()> { + for n in [1, 2, 3, 4, 5, 6, 7, 6, 7] { + spawn(n, config.volume)?; + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_cache_holds_seven_wavs() { + let dir = tempfile::tempdir().unwrap(); + for n in 1..=7 { + let path = cached(dir.path(), n).unwrap(); + assert!(path.ends_with(format!("{n}.wav"))); + assert_eq!(&std::fs::read(&path).unwrap()[0..4], b"RIFF"); + } + } +} diff --git a/src/toolbar/draw.rs b/src/toolbar/draw.rs index 3db1792..1dd1d69 100644 --- a/src/toolbar/draw.rs +++ b/src/toolbar/draw.rs @@ -113,6 +113,7 @@ pub fn bar( MenuItem::Timer(t) => state.timer == *t, MenuItem::Cursor => state.cursor, MenuItem::Preview => state.preview, + MenuItem::Sound => state.sound, }; if on { label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, "✓", scale); @@ -162,6 +163,7 @@ mod tests { timer: 0, cursor: false, preview: true, + sound: true, }; fn alpha(p: &Pixmap, x: f32, y: f32, scale: f32) -> u8 { diff --git a/src/toolbar/layout.rs b/src/toolbar/layout.rs index 1f8670c..9f3a82e 100644 --- a/src/toolbar/layout.rs +++ b/src/toolbar/layout.rs @@ -32,6 +32,7 @@ pub enum MenuItem { Timer(u32), Cursor, Preview, + Sound, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -61,12 +62,13 @@ const BAR: [Option<(Target, &str)>; 7] = [ Some((Target::Options, "Options ▾")), ]; -const MENU: [(MenuItem, &str); 5] = [ +const MENU: [(MenuItem, &str); 6] = [ (MenuItem::Timer(0), "No timer"), (MenuItem::Timer(5), "5 second timer"), (MenuItem::Timer(10), "10 second timer"), (MenuItem::Cursor, "Show cursor"), (MenuItem::Preview, "Show preview"), + (MenuItem::Sound, "Play sound"), ]; /// Lays the toolbar out on an output of `output` logical px; `measure` @@ -240,7 +242,8 @@ mod tests { MenuItem::Timer(5), MenuItem::Timer(10), MenuItem::Cursor, - MenuItem::Preview + MenuItem::Preview, + MenuItem::Sound ] ); let cursor = centre(l.rows[3].1); diff --git a/src/toolbar/mod.rs b/src/toolbar/mod.rs index 3b1f7c9..c2d732c 100644 --- a/src/toolbar/mod.rs +++ b/src/toolbar/mod.rs @@ -41,6 +41,7 @@ pub struct Picked { pub mode: Mode, pub cursor: bool, pub preview: bool, + pub sound: bool, } /// Shows the bar, waits for a pick, saves it, runs the countdown. @@ -62,6 +63,7 @@ pub fn run(config: &Config) -> Result { mode: picked.1, cursor: s.cursor, preview: s.preview, + sound: s.sound, }) } @@ -227,6 +229,7 @@ impl Toolbar { MenuItem::Timer(t) => self.state.timer = t, MenuItem::Cursor => self.state.cursor = !self.state.cursor, MenuItem::Preview => self.state.preview = !self.state.preview, + MenuItem::Sound => self.state.sound = !self.state.sound, } self.draw(); } diff --git a/src/toolbar/state.rs b/src/toolbar/state.rs index ada0f60..5d11a29 100644 --- a/src/toolbar/state.rs +++ b/src/toolbar/state.rs @@ -24,6 +24,7 @@ pub struct ToolbarState { pub timer: u32, pub cursor: bool, pub preview: bool, + pub sound: bool, } /// The file, every key optional. @@ -33,6 +34,7 @@ struct File { timer: Option, cursor: Option, preview: Option, + sound: Option, } pub const TIMERS: [u32; 3] = [0, 5, 10]; @@ -51,6 +53,7 @@ pub fn defaults(config: &Config) -> ToolbarState { timer: 0, cursor: config.capture.show_cursor, preview: config.preview.enabled, + sound: config.sound.enabled, } } @@ -85,6 +88,7 @@ fn parse(text: &str, fallback: ToolbarState) -> Result { timer, cursor: file.cursor.unwrap_or(fallback.cursor), preview: file.preview.unwrap_or(fallback.preview), + sound: file.sound.unwrap_or(fallback.sound), }) } @@ -106,13 +110,15 @@ mod tests { let mut config = Config::default(); config.capture.show_cursor = true; config.preview.enabled = false; + config.sound.enabled = false; assert_eq!( defaults(&config), ToolbarState { mode: Mode::Region, timer: 0, cursor: true, - preview: false + preview: false, + sound: false, } ); } @@ -126,6 +132,7 @@ mod tests { timer: 10, cursor: true, preview: true, + sound: false, }; save(&path, &state).unwrap(); assert_eq!(load(&path, &Config::default()), state);