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.
# 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
'';
+47
View File
@@ -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<String>)> {
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();
+22 -2
View File
@@ -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<bool>,
#[arg(skip)]
toolbar_preview: Option<bool>,
#[arg(skip)]
toolbar_sound: Option<bool>,
}
/// 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]
+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 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::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 {
+5 -2
View File
@@ -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);
+3
View File
@@ -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<Picked> {
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();
}
+8 -1
View File
@@ -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<u32>,
cursor: Option<bool>,
preview: Option<bool>,
sound: Option<bool>,
}
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<ToolbarState> {
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);