feat: combo shutter sound

This commit is contained in:
elwrcl committed 2026-10-03 02:36:44 +03:00
1 parent 7068b7d28b
commit 2e839398b2
8 files changed
+181 -7

No files matched your search

+6 -1
View File
@@ -38,7 +38,12 @@
# Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first. # 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. # The editor embeds DejaVu Sans Bold; its licence wants the notice shipped.
postInstall = '' 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 install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu
''; '';
+47
View File
@@ -13,6 +13,7 @@ pub struct Config {
pub preview: Preview, pub preview: Preview,
pub zoom: Zoom, pub zoom: Zoom,
pub editor: Editor, pub editor: Editor,
pub sound: Sound,
} }
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -69,6 +70,26 @@ pub enum Backend {
Satty, 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 { impl Default for Preview {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -139,6 +160,12 @@ pub fn parse(text: &str) -> Result<(Config, Vec<String>)> {
if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) { if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) {
bail!("zoom.flashlight_radius must be between 20 and 2000"); 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)) Ok((config, unknown))
} }
@@ -247,6 +274,26 @@ mod tests {
assert!(parse("[editor]\nbackend = \"gimp\"\n").is_err()); 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] #[test]
fn missing_file_gives_defaults() { fn missing_file_gives_defaults() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+22 -2
View File
@@ -16,8 +16,6 @@ mod region;
mod render; mod render;
mod selection; mod selection;
mod shadow; mod shadow;
// The sound lands in two tasks; Task 5 of the polish plan removes this.
#[allow(dead_code)]
mod sound; mod sound;
mod stack; mod stack;
mod thumbnail; mod thumbnail;
@@ -86,6 +84,9 @@ enum Command {
/// Internal: the process that shows preview thumbnails. /// Internal: the process that shows preview thumbnails.
#[command(name = "__preview-host", hide = true)] #[command(name = "__preview-host", hide = true)]
PreviewHost, 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). /// Internal: serve a PNG from stdin as the clipboard (the editor's copy).
#[command(name = "__clipboard", hide = true)] #[command(name = "__clipboard", hide = true)]
Clipboard, Clipboard,
@@ -114,6 +115,8 @@ struct Common {
toolbar_cursor: Option<bool>, toolbar_cursor: Option<bool>,
#[arg(skip)] #[arg(skip)]
toolbar_preview: Option<bool>, toolbar_preview: Option<bool>,
#[arg(skip)]
toolbar_sound: Option<bool>,
} }
/// Whether the cursor goes into the shot. /// 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) .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. /// Whether the preview thumbnail is shown.
fn wants_preview(common: &Common, config: &Config) -> bool { fn wants_preview(common: &Common, config: &Config) -> bool {
common common
@@ -180,6 +188,7 @@ fn run(cli: Cli) -> Result<()> {
Command::Window { common } => capture(Mode::Window, common), Command::Window { common } => capture(Mode::Window, common),
Command::Zoom { common } => capture(Mode::Zoom, common), Command::Zoom { common } => capture(Mode::Zoom, common),
Command::Edit { file } => editor::run(&file), Command::Edit { file } => editor::run(&file),
Command::ComboDemo => sound::demo(&config::load(&config::default_path())?.sound),
Command::Toolbar => { Command::Toolbar => {
let config = config::load(&config::default_path())?; let config = config::load(&config::default_path())?;
let (mode, common) = from_toolbar(toolbar::run(&config)?); 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 cursor = wants_cursor(&common, &config);
let preview = wants_preview(&common, &config); let preview = wants_preview(&common, &config);
let sound = wants_sound(&common, &config);
let mut wl = Wayland::connect()?; let mut wl = Wayland::connect()?;
if let Ok(version) = niri::version() { if let Ok(version) = niri::version() {
tracing::info!("niri {version}"); tracing::info!("niri {version}");
@@ -256,6 +266,7 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
} }
} }
}; };
sound::play(&config.sound, sound);
drop(wl); drop(wl);
let target = Target::from_args(common.output, common.clipboard_only); 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, no_preview: false,
toolbar_cursor: Some(pick.cursor), toolbar_cursor: Some(pick.cursor),
toolbar_preview: Some(pick.preview), toolbar_preview: Some(pick.preview),
toolbar_sound: Some(pick.sound),
}; };
(mode, common) (mode, common)
} }
@@ -406,6 +418,7 @@ mod tests {
assert!(!parses(&["edit"])); assert!(!parses(&["edit"]));
assert!(parses(&["__clipboard"])); assert!(parses(&["__clipboard"]));
assert!(parses(&["toolbar"])); assert!(parses(&["toolbar"]));
assert!(parses(&["__combo-demo"]));
} }
#[test] #[test]
@@ -415,6 +428,7 @@ mod tests {
mode, mode,
cursor, cursor,
preview, preview,
sound: true,
}; };
let (mode, common) = from_toolbar(pick(T::Screen, true, false)); let (mode, common) = from_toolbar(pick(T::Screen, true, false));
assert!(matches!(mode, Mode::Screen { all: false })); assert!(matches!(mode, Mode::Screen { all: false }));
@@ -453,9 +467,14 @@ mod tests {
mode: T::Screen, mode: T::Screen,
cursor, cursor,
preview, preview,
sound: preview,
}) })
.1 .1
}; };
on.sound.enabled = true;
off.sound.enabled = false;
assert!(!wants_sound(&pick(false, false), &on));
assert!(wants_sound(&pick(true, true), &off));
assert!( assert!(
!wants_cursor(&pick(false, false), &on), !wants_cursor(&pick(false, false), &on),
"unticked beats the config" "unticked beats the config"
@@ -500,6 +519,7 @@ mod tests {
let help = Cli::command().render_help().to_string(); let help = Cli::command().render_help().to_string();
assert!(!help.contains("preview-host"), "{help}"); assert!(!help.contains("preview-host"), "{help}");
assert!(!help.contains("__clipboard"), "{help}"); assert!(!help.contains("__clipboard"), "{help}");
assert!(!help.contains("combo-demo"), "{help}");
} }
#[test] #[test]
+88 -1
View File
@@ -1,4 +1,91 @@
//! The combo shutter sound. //! The combo shutter sound, played with PipeWire's `pw-play`.
pub mod combo; pub mod combo;
pub mod synth; 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<PathBuf> {
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");
}
}
}
+2
View File
@@ -113,6 +113,7 @@ pub fn bar(
MenuItem::Timer(t) => state.timer == *t, MenuItem::Timer(t) => state.timer == *t,
MenuItem::Cursor => state.cursor, MenuItem::Cursor => state.cursor,
MenuItem::Preview => state.preview, MenuItem::Preview => state.preview,
MenuItem::Sound => state.sound,
}; };
if on { if on {
label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, "✓", scale); label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, "✓", scale);
@@ -162,6 +163,7 @@ mod tests {
timer: 0, timer: 0,
cursor: false, cursor: false,
preview: true, preview: true,
sound: true,
}; };
fn alpha(p: &Pixmap, x: f32, y: f32, scale: f32) -> u8 { fn alpha(p: &Pixmap, x: f32, y: f32, scale: f32) -> u8 {
+5 -2
View File
@@ -32,6 +32,7 @@ pub enum MenuItem {
Timer(u32), Timer(u32),
Cursor, Cursor,
Preview, Preview,
Sound,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -61,12 +62,13 @@ const BAR: [Option<(Target, &str)>; 7] = [
Some((Target::Options, "Options ▾")), Some((Target::Options, "Options ▾")),
]; ];
const MENU: [(MenuItem, &str); 5] = [ const MENU: [(MenuItem, &str); 6] = [
(MenuItem::Timer(0), "No timer"), (MenuItem::Timer(0), "No timer"),
(MenuItem::Timer(5), "5 second timer"), (MenuItem::Timer(5), "5 second timer"),
(MenuItem::Timer(10), "10 second timer"), (MenuItem::Timer(10), "10 second timer"),
(MenuItem::Cursor, "Show cursor"), (MenuItem::Cursor, "Show cursor"),
(MenuItem::Preview, "Show preview"), (MenuItem::Preview, "Show preview"),
(MenuItem::Sound, "Play sound"),
]; ];
/// Lays the toolbar out on an output of `output` logical px; `measure` /// Lays the toolbar out on an output of `output` logical px; `measure`
@@ -240,7 +242,8 @@ mod tests {
MenuItem::Timer(5), MenuItem::Timer(5),
MenuItem::Timer(10), MenuItem::Timer(10),
MenuItem::Cursor, MenuItem::Cursor,
MenuItem::Preview MenuItem::Preview,
MenuItem::Sound
] ]
); );
let cursor = centre(l.rows[3].1); let cursor = centre(l.rows[3].1);
+3
View File
@@ -41,6 +41,7 @@ pub struct Picked {
pub mode: Mode, pub mode: Mode,
pub cursor: bool, pub cursor: bool,
pub preview: bool, pub preview: bool,
pub sound: bool,
} }
/// Shows the bar, waits for a pick, saves it, runs the countdown. /// Shows the bar, waits for a pick, saves it, runs the countdown.
@@ -62,6 +63,7 @@ pub fn run(config: &Config) -> Result<Picked> {
mode: picked.1, mode: picked.1,
cursor: s.cursor, cursor: s.cursor,
preview: s.preview, preview: s.preview,
sound: s.sound,
}) })
} }
@@ -227,6 +229,7 @@ impl Toolbar {
MenuItem::Timer(t) => self.state.timer = t, MenuItem::Timer(t) => self.state.timer = t,
MenuItem::Cursor => self.state.cursor = !self.state.cursor, MenuItem::Cursor => self.state.cursor = !self.state.cursor,
MenuItem::Preview => self.state.preview = !self.state.preview, MenuItem::Preview => self.state.preview = !self.state.preview,
MenuItem::Sound => self.state.sound = !self.state.sound,
} }
self.draw(); self.draw();
} }
+8 -1
View File
@@ -24,6 +24,7 @@ pub struct ToolbarState {
pub timer: u32, pub timer: u32,
pub cursor: bool, pub cursor: bool,
pub preview: bool, pub preview: bool,
pub sound: bool,
} }
/// The file, every key optional. /// The file, every key optional.
@@ -33,6 +34,7 @@ struct File {
timer: Option<u32>, timer: Option<u32>,
cursor: Option<bool>, cursor: Option<bool>,
preview: Option<bool>, preview: Option<bool>,
sound: Option<bool>,
} }
pub const TIMERS: [u32; 3] = [0, 5, 10]; pub const TIMERS: [u32; 3] = [0, 5, 10];
@@ -51,6 +53,7 @@ pub fn defaults(config: &Config) -> ToolbarState {
timer: 0, timer: 0,
cursor: config.capture.show_cursor, cursor: config.capture.show_cursor,
preview: config.preview.enabled, preview: config.preview.enabled,
sound: config.sound.enabled,
} }
} }
@@ -85,6 +88,7 @@ fn parse(text: &str, fallback: ToolbarState) -> Result<ToolbarState> {
timer, timer,
cursor: file.cursor.unwrap_or(fallback.cursor), cursor: file.cursor.unwrap_or(fallback.cursor),
preview: file.preview.unwrap_or(fallback.preview), 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(); let mut config = Config::default();
config.capture.show_cursor = true; config.capture.show_cursor = true;
config.preview.enabled = false; config.preview.enabled = false;
config.sound.enabled = false;
assert_eq!( assert_eq!(
defaults(&config), defaults(&config),
ToolbarState { ToolbarState {
mode: Mode::Region, mode: Mode::Region,
timer: 0, timer: 0,
cursor: true, cursor: true,
preview: false preview: false,
sound: false,
} }
); );
} }
@@ -126,6 +132,7 @@ mod tests {
timer: 10, timer: 10,
cursor: true, cursor: true,
preview: true, preview: true,
sound: false,
}; };
save(&path, &state).unwrap(); save(&path, &state).unwrap();
assert_eq!(load(&path, &Config::default()), state); assert_eq!(load(&path, &Config::default()), state);