feat: combo shutter sound
This commit is contained in:
1 parent
7068b7d28b
commit
2e839398b2
8 files changed
+181
-7
No files matched your search
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user