From 3a3192c7277d0f57d35ad66759bc507af2ae4668 Mon Sep 17 00:00:00 2001 From: lflanagan Date: Wed, 16 Sep 2026 18:10:09 -0700 Subject: [PATCH] feat(audio): add a Reduce volume behavior while dictating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the "Reduce volume" recording audio behavior from my fork of the legacy Swift app to the Rust app. Instead of muting or pausing, Hex lowers the default output device to a saved level (10–75%) for the life of the recording environment and returns it afterward, with optional fade out and fade in of up to two seconds. Playback already at or below the level is left alone, one environment covers overlapping sessions so a double-tap lock restores the original level (the restore bug reported as kitlangton/Hex#220), and a manual volume change while reduced or during either fade stops Hex writing and skips the restore. A level that cannot be read stops Hex writing as well. Settings gain level and fade rows that appear only for Reduce volume, values are clamped on load, and `hex preview settings --reduce-volume` captures that state. The output volume sits behind a small seam so lifecycle checks run against a scripted output: reduce and restore through Drop, the at-or-below skip, manual takeover before and during restore, cancellation after a landed fade-out step, read-back adoption, and a failed read. Ramp math, thresholds, encoding, clamping, and the CLI flag are unit-checked; audible Core Audio behavior remains unverified by tests. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 +- docs/features/README.md | 39 ++- docs/features/dictation.md | 2 +- src/app_settings.rs | 176 ++++++++++- src/app_window.rs | 234 +++++++++++++-- src/main.rs | 20 ++ src/recording_environment.rs | 560 ++++++++++++++++++++++++++++++++++- 7 files changed, 1003 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9586bab..75f4d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,8 @@ substitute for source inspection or a second roadmap. lifecycle, recording owner, exact shortcut boundaries, recovery handoff, and disposable bounded command audio projection. - `recording_environment`: serialized RAII ownership of idle-sleep prevention, - output muting, and supported media-player pause/resume behavior. + output muting or volume reduction with optional fades, and supported + media-player pause/resume behavior. - `dictation_processor`: context-selected corrections and deadline-bounded OpenCode rewrite profiles with corrected-transcript fallback. The macOS app discovers the `opencode2` beta executable, links missing installs to @@ -402,6 +403,7 @@ cargo run -- preview transcription-picker --language zh --model-state installed ./scripts/capture-preview.sh /tmp/hex-model-missing.png settings --model-missing ./scripts/capture-preview.sh /tmp/hex-command-model-missing.png settings --command-model-missing ./scripts/capture-preview.sh /tmp/hex-microphone-confirmation.png settings --confirm-release-microphone +./scripts/capture-preview.sh /tmp/hex-reduce-volume.png settings --reduce-volume ./scripts/capture-preview.sh /tmp/hex-history-retention.png history --open-history-retention ./scripts/capture-preview.sh /tmp/hex-modes.png modes ./scripts/capture-preview.sh /tmp/hex-modes-collapsed.png modes --collapse-mode-processing diff --git a/docs/features/README.md b/docs/features/README.md index e769713..4fda720 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -238,8 +238,12 @@ Settings // settings │ ├── Keep ready (fast) -> Open while idle; pre-roll available │ └── Release when idle -> Open on press; no pre-roll; startup delay │ └── Commands enabled? -> Confirm turning Commands off -├── While dictating -> Mute / Pause media / Do nothing -│ └── Intentional capture only, not ordinary shortcut chords +├── While dictating -> Mute / Reduce volume / Pause media / Do nothing +│ ├── Intentional capture only, not ordinary shortcut chords +│ └── Reduce volume -> Reduced volume 10–75%, Fade out, Fade in (0–2 s) +│ ├── Already at or below the level -> Left alone; nothing restored +│ ├── Overlapping sessions -> One environment; original level restored +│ └── Manual volume change while reduced or fading -> HEX stops; no restore └── Sound volume -> Immediate feedback setting; zero suppresses tones ``` @@ -250,6 +254,37 @@ Persistence, conflict, and ownership checks live in [audio.rs](../../src/audio.rs). Settings previews do not prove physical device switching or native mute support; muting is best-effort, not universal. +Reduce volume lowers the default output device's virtual main volume to the +saved level for the life of the recording environment and returns it to the +level observed at start, fading in each direction when a fade is set. Before +every write, during both fades and at restore, the current level is compared +with the last level HEX applied, with a threshold that ignores Core Audio +rounding but catches one volume-key step; after a manual change HEX stops +writing and leaves the output where the user put it. The level read back after +each write becomes the expected level, so coarse device volume steps are not +mistaken for the user; a change landing between a write and its read-back is +adopted as HEX's own, a short but unbounded interval accepted as a trade-off. +A level that cannot be read stops HEX writing. Out-of-range persisted values +are clamped on load. Checks in [app_settings.rs](../../src/app_settings.rs): +`reduce_volume_settings_round_trip_through_json`, +`loading_clamps_out_of_range_volume_reduction_values`, +`recording_audio_behavior_runtime_encoding_round_trips`. Checks in +[recording_environment.rs](../../src/recording_environment.rs) through a +scripted output (`FakeOutput`), not Core Audio: +`reduction_lowers_the_output_and_dropping_it_restores_exactly`, +`output_already_at_or_below_the_level_is_left_untouched`, +`a_manual_change_while_reduced_skips_the_restore`, +`restore_fade_yields_to_a_manual_change_between_steps`, +`dropping_during_a_fade_out_cancels_it_and_restores_from_the_current_level`, +`a_change_between_a_write_and_its_read_back_is_adopted_not_released`, +`a_failed_read_before_a_write_stops_the_ramp_instead_of_writing_blind`, the +ramp-math and threshold checks, and the existing overlapping-session controller +checks. Not covered: real device quantization, an output-device switch during +dictation (restore targets the device observed at start, as Mute does), and +the expanded rows at the minimum window width. The restore fade runs on the +environment worker, so a fade-in of up to two seconds delays the next +environment start, never the recording itself. + **Easy to misread:** an open microphone is not an active recording. Sleeping Commands still needs open input; it is not Release when idle. diff --git a/docs/features/dictation.md b/docs/features/dictation.md index fd31d64..cda07fb 100644 --- a/docs/features/dictation.md +++ b/docs/features/dictation.md @@ -35,7 +35,7 @@ Ordinary hold // lock activation is mapped below ├── Release before 300 ms -> Discard // dictate.short-tap └── Early unrelated shortcut/click -> Discard // not intentional dictation -Ordinary shortcut -> No recording mute/pause or idle-sleep prevention +Ordinary shortcut -> No recording mute/reduce/pause or idle-sleep prevention Intentional recording -> No automatic duration limit ``` diff --git a/src/app_settings.rs b/src/app_settings.rs index f6960b9..b75e19b 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -1,6 +1,6 @@ use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::sync::{OnceLock, RwLock}; use color_eyre::eyre::{Result, eyre}; @@ -11,6 +11,10 @@ use serde::{Deserialize, Serialize}; use crate::transcription_models::{TranscriptionModelId, TranscriptionSelection}; static RECORDING_AUDIO_BEHAVIOR: AtomicU8 = AtomicU8::new(0); +static RECORDING_REDUCED_VOLUME: AtomicU32 = + AtomicU32::new(RecordingVolumeReduction::DEFAULT_VOLUME.to_bits()); +static RECORDING_VOLUME_FADE_OUT: AtomicU32 = AtomicU32::new(0); +static RECORDING_VOLUME_FADE_IN: AtomicU32 = AtomicU32::new(0); static DOUBLE_TAP_LOCK: AtomicBool = AtomicBool::new(true); static DOUBLE_TAP_ONLY: AtomicBool = AtomicBool::new(false); static MICROPHONE_POLICY: AtomicU8 = AtomicU8::new(0); @@ -410,11 +414,54 @@ impl Default for RuntimeHotkeys { #[serde(rename_all = "snake_case")] pub enum RecordingAudioBehavior { Mute, + ReduceVolume, PauseMedia, #[default] DoNothing, } +/// Output-volume ducking applied while `RecordingAudioBehavior::ReduceVolume` +/// is active. Volume is a 0..=1 fraction of the output device's virtual main +/// volume; fades are seconds and are clamped to a bounded maximum so a +/// misconfigured value can never hold the recording environment for long. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RecordingVolumeReduction { + pub volume: f32, + pub fade_out_seconds: f32, + pub fade_in_seconds: f32, +} + +impl RecordingVolumeReduction { + pub const DEFAULT_VOLUME: f32 = 0.2; + pub const MAX_FADE_SECONDS: f32 = 2.0; + + pub fn clamp_volume(volume: f32) -> f32 { + if volume.is_finite() { + volume.clamp(0.0, 1.0) + } else { + Self::DEFAULT_VOLUME + } + } + + pub fn clamp_fade(seconds: f32) -> f32 { + if seconds.is_finite() { + seconds.clamp(0.0, Self::MAX_FADE_SECONDS) + } else { + 0.0 + } + } +} + +impl Default for RecordingVolumeReduction { + fn default() -> Self { + Self { + volume: Self::DEFAULT_VOLUME, + fade_out_seconds: 0.0, + fade_in_seconds: 0.0, + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct DictationProcessingSettings { @@ -495,11 +542,18 @@ impl Default for DictationPostProcessing { } impl RecordingAudioBehavior { - pub const ALL: [Self; 3] = [Self::DoNothing, Self::Mute, Self::PauseMedia]; + /// Every behavior, in the order Settings presents them. + pub const ALL: [Self; 4] = [ + Self::Mute, + Self::ReduceVolume, + Self::PauseMedia, + Self::DoNothing, + ]; pub const fn label(self) -> &'static str { match self { Self::Mute => "Mute", + Self::ReduceVolume => "Reduce volume", Self::PauseMedia => "Pause media", Self::DoNothing => "Do nothing", } @@ -510,6 +564,7 @@ impl RecordingAudioBehavior { Self::Mute => 0, Self::PauseMedia => 1, Self::DoNothing => 2, + Self::ReduceVolume => 3, } } @@ -517,6 +572,7 @@ impl RecordingAudioBehavior { match value { 1 => Self::PauseMedia, 2 => Self::DoNothing, + 3 => Self::ReduceVolume, _ => Self::Mute, } } @@ -531,6 +587,9 @@ pub struct AppSettings { pub sound_effect_volume: f32, pub microphone: Option, pub recording_audio_behavior: RecordingAudioBehavior, + pub recording_reduced_volume: f32, + pub recording_volume_fade_out_seconds: f32, + pub recording_volume_fade_in_seconds: f32, pub double_tap_lock: bool, pub double_tap_only: bool, pub dictation_hotkey: HotkeyBinding, @@ -567,6 +626,9 @@ impl Default for AppSettings { sound_effect_volume: 0.5, microphone: None, recording_audio_behavior: RecordingAudioBehavior::DoNothing, + recording_reduced_volume: RecordingVolumeReduction::DEFAULT_VOLUME, + recording_volume_fade_out_seconds: 0.0, + recording_volume_fade_in_seconds: 0.0, double_tap_lock: true, double_tap_only: false, dictation_hotkey: HotkeyBinding::default(), @@ -696,6 +758,27 @@ impl AppSettings { } } + /// Hand-edited or partially written volume settings stay inside the + /// ranges the recording environment can honor. + fn normalize_recording_volume_settings(&mut self) { + let reduction = self.recording_volume_reduction(); + self.recording_reduced_volume = reduction.volume; + self.recording_volume_fade_out_seconds = reduction.fade_out_seconds; + self.recording_volume_fade_in_seconds = reduction.fade_in_seconds; + } + + pub fn recording_volume_reduction(&self) -> RecordingVolumeReduction { + RecordingVolumeReduction { + volume: RecordingVolumeReduction::clamp_volume(self.recording_reduced_volume), + fade_out_seconds: RecordingVolumeReduction::clamp_fade( + self.recording_volume_fade_out_seconds, + ), + fade_in_seconds: RecordingVolumeReduction::clamp_fade( + self.recording_volume_fade_in_seconds, + ), + } + } + pub fn load() -> Result { let settings = Self::load_from(&path()?)?; settings.apply_runtime(); @@ -709,6 +792,7 @@ impl AppSettings { let microphone_policy_migrated = settings.normalize_microphone_policy(); settings.dictation_processing.default_mode.name = "Global".into(); settings.normalize_double_tap_settings(); + settings.normalize_recording_volume_settings(); settings.migrate_legacy_replacements(); let mode_applications_migrated = settings.normalize_mode_application_names(); let transcription_migrated = settings.migrate_disabled_transcription_model(); @@ -772,6 +856,10 @@ impl AppSettings { crate::feedback::set_enabled(self.sound_effects); crate::feedback::set_volume(self.sound_effect_volume.clamp(0.0, 1.0)); RECORDING_AUDIO_BEHAVIOR.store(self.recording_audio_behavior.encoded(), Ordering::Relaxed); + let reduction = self.recording_volume_reduction(); + RECORDING_REDUCED_VOLUME.store(reduction.volume.to_bits(), Ordering::Relaxed); + RECORDING_VOLUME_FADE_OUT.store(reduction.fade_out_seconds.to_bits(), Ordering::Relaxed); + RECORDING_VOLUME_FADE_IN.store(reduction.fade_in_seconds.to_bits(), Ordering::Relaxed); DOUBLE_TAP_LOCK.store(self.double_tap_lock, Ordering::Relaxed); DOUBLE_TAP_ONLY.store( self.double_tap_lock && self.double_tap_only && self.dictation_hotkey.key.is_some(), @@ -953,6 +1041,20 @@ pub fn recording_audio_behavior() -> RecordingAudioBehavior { RecordingAudioBehavior::decode(RECORDING_AUDIO_BEHAVIOR.load(Ordering::Relaxed)) } +pub fn recording_volume_reduction() -> RecordingVolumeReduction { + RecordingVolumeReduction { + volume: RecordingVolumeReduction::clamp_volume(f32::from_bits( + RECORDING_REDUCED_VOLUME.load(Ordering::Relaxed), + )), + fade_out_seconds: RecordingVolumeReduction::clamp_fade(f32::from_bits( + RECORDING_VOLUME_FADE_OUT.load(Ordering::Relaxed), + )), + fade_in_seconds: RecordingVolumeReduction::clamp_fade(f32::from_bits( + RECORDING_VOLUME_FADE_IN.load(Ordering::Relaxed), + )), + } +} + pub fn commands_enabled() -> bool { microphone_policy().commands_enabled } @@ -1058,6 +1160,10 @@ mod tests { settings.recording_audio_behavior, RecordingAudioBehavior::DoNothing ); + assert_eq!( + settings.recording_volume_reduction(), + RecordingVolumeReduction::default() + ); assert!(settings.double_tap_lock); assert!(!settings.double_tap_only); assert_eq!(settings.dictation_hotkey, HotkeyBinding::default()); @@ -1140,6 +1246,72 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn reduce_volume_settings_round_trip_through_json() { + let settings = AppSettings { + recording_audio_behavior: RecordingAudioBehavior::ReduceVolume, + recording_reduced_volume: 0.35, + recording_volume_fade_out_seconds: 0.5, + recording_volume_fade_in_seconds: 1.25, + ..AppSettings::default() + }; + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains(r#""recording_audio_behavior":"reduce_volume""#)); + let restored: AppSettings = serde_json::from_str(&json).unwrap(); + assert_eq!( + restored.recording_audio_behavior, + RecordingAudioBehavior::ReduceVolume + ); + assert_eq!( + restored.recording_volume_reduction(), + RecordingVolumeReduction { + volume: 0.35, + fade_out_seconds: 0.5, + fade_in_seconds: 1.25, + } + ); + } + + #[test] + fn loading_clamps_out_of_range_volume_reduction_values() { + let directory = std::env::temp_dir().join(format!( + "hex-volume-reduction-{}-{}", + std::process::id(), + SETTINGS_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&directory).unwrap(); + let path = directory.join("settings.json"); + fs::write( + &path, + br#"{"recording_audio_behavior":"reduce_volume","recording_reduced_volume":1.7,"recording_volume_fade_out_seconds":-3,"recording_volume_fade_in_seconds":9}"#, + ) + .unwrap(); + + let settings = AppSettings::load_from(&path).unwrap(); + assert_eq!(settings.recording_reduced_volume, 1.0); + assert_eq!(settings.recording_volume_fade_out_seconds, 0.0); + assert_eq!( + settings.recording_volume_fade_in_seconds, + RecordingVolumeReduction::MAX_FADE_SECONDS + ); + assert_eq!( + settings.recording_volume_reduction(), + RecordingVolumeReduction { + volume: 1.0, + fade_out_seconds: 0.0, + fade_in_seconds: RecordingVolumeReduction::MAX_FADE_SECONDS, + } + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn recording_audio_behavior_runtime_encoding_round_trips() { + for behavior in RecordingAudioBehavior::ALL { + assert_eq!(RecordingAudioBehavior::decode(behavior.encoded()), behavior); + } + } + #[test] fn loading_strips_finder_bundle_extensions_from_mode_applications() { let directory = std::env::temp_dir().join(format!( diff --git a/src/app_window.rs b/src/app_window.rs index 87abcac..efd0fe4 100644 --- a/src/app_window.rs +++ b/src/app_window.rs @@ -373,6 +373,7 @@ pub struct AppWindowPreview { pub command_model_missing: bool, pub open_history_retention: bool, pub confirm_release_microphone: bool, + pub reduce_volume: bool, pub update_available: bool, } @@ -917,6 +918,10 @@ pub struct AppWindow { dock_icon_toggle: ToggleSpring, sound_volume_spring: ToggleSpring, recording_audio_spring: ToggleSpring, + reduce_volume_visibility: ToggleSpring, + reduced_volume_spring: ToggleSpring, + volume_fade_out_spring: ToggleSpring, + volume_fade_in_spring: ToggleSpring, variant_picker_open: Option, transcription_hints: ProcessingInput, transcription_picker_language: Option, @@ -1072,6 +1077,11 @@ impl AppWindow { let mut settings = AppSettings { commands_enabled: preview.command_model_missing || preview.confirm_release_microphone, + recording_audio_behavior: if preview.reduce_volume { + RecordingAudioBehavior::ReduceVolume + } else { + RecordingAudioBehavior::DoNothing + }, ..AppSettings::default() }; settings.voice_action.enabled = preview.voice_action_enabled; @@ -1337,6 +1347,21 @@ impl AppWindow { recording_audio_spring: ToggleSpring::at(recording_audio_index( settings.recording_audio_behavior, ) as f32), + reduce_volume_visibility: ToggleSpring::new( + settings.recording_audio_behavior == RecordingAudioBehavior::ReduceVolume, + ), + reduced_volume_spring: ToggleSpring::at(nearest_choice_index( + settings.recording_reduced_volume, + &REDUCED_VOLUME_CHOICES, + ) as f32), + volume_fade_out_spring: ToggleSpring::at(nearest_choice_index( + settings.recording_volume_fade_out_seconds, + &VOLUME_FADE_CHOICES, + ) as f32), + volume_fade_in_spring: ToggleSpring::at(nearest_choice_index( + settings.recording_volume_fade_in_seconds, + &VOLUME_FADE_CHOICES, + ) as f32), variant_picker_open: None, transcription_hints, transcription_picker_language: preview_picker.map(|(language, _)| language.clone()), @@ -2723,7 +2748,8 @@ impl AppWindow { &mut self.hotkey_side_selection_springs[hotkey_kind_index(kind)]; side_selection_spring.set_target(hotkey_side_index(selected) as f32); let selection_position = side_selection_spring.render_position(window); - let (selection_left, selection_width) = segmented_geometry(selection_position, side_widths); + let (selection_left, selection_width) = + segmented_geometry(selection_position, &side_widths); let side_selector = div() .w(px(HOTKEY_SIDE_SELECTOR_WIDTH * side_position)) .mr(px(8.0 * side_position)) @@ -3427,8 +3453,8 @@ impl AppWindow { toggle(launch_at_login_position) }; let recording_audio_position = self.recording_audio_spring.render_position(window); - let audio_widths = [50.0, 90.0, 80.0]; - let (audio_left, audio_width) = segmented_geometry(recording_audio_position, audio_widths); + let audio_widths = [50.0, 96.0, 90.0, 80.0]; + let (audio_left, audio_width) = segmented_geometry(recording_audio_position, &audio_widths); let audio_behavior = segmented_control() .relative() .child( @@ -3441,15 +3467,8 @@ impl AppWindow { .rounded(px(4.0)) .bg(rgb(SURFACE_SELECTED)), ) - .children( - [ - RecordingAudioBehavior::ALL[1], - RecordingAudioBehavior::ALL[2], - RecordingAudioBehavior::ALL[0], - ] - .into_iter() - .enumerate() - .map(|(index, behavior)| { + .children(RecordingAudioBehavior::ALL.into_iter().enumerate().map( + |(index, behavior)| { let selected = self.settings.recording_audio_behavior == behavior; segmented_item(selected) .id(("recording-audio-behavior", index)) @@ -3464,10 +3483,70 @@ impl AppWindow { } this.settings.recording_audio_behavior = behavior; this.recording_audio_spring.set_target(index as f32); + this.reduce_volume_visibility + .set_enabled(behavior == RecordingAudioBehavior::ReduceVolume); this.save_settings(cx); })) - }), - ); + }, + )); + self.reduce_volume_visibility.set_enabled( + self.settings.recording_audio_behavior == RecordingAudioBehavior::ReduceVolume, + ); + let reduce_volume_visibility = self + .reduce_volume_visibility + .render_position(window) + .clamp(0.0, 1.0); + let reduced_volume_position = self.reduced_volume_spring.render_position(window); + let reduced_volume = segmented_choice( + "reduced-volume", + reduced_volume_position, + REDUCED_VOLUME_CHOICE_WIDTH, + &REDUCED_VOLUME_CHOICES, + nearest_choice_index( + self.settings.recording_reduced_volume, + &REDUCED_VOLUME_CHOICES, + ), + cx, + |this, index, volume, cx| { + this.settings.recording_reduced_volume = volume; + this.reduced_volume_spring.set_target(index as f32); + this.save_settings(cx); + }, + ); + let volume_fade_out_position = self.volume_fade_out_spring.render_position(window); + let volume_fade_out = segmented_choice( + "volume-fade-out", + volume_fade_out_position, + VOLUME_FADE_CHOICE_WIDTH, + &VOLUME_FADE_CHOICES, + nearest_choice_index( + self.settings.recording_volume_fade_out_seconds, + &VOLUME_FADE_CHOICES, + ), + cx, + |this, index, seconds, cx| { + this.settings.recording_volume_fade_out_seconds = seconds; + this.volume_fade_out_spring.set_target(index as f32); + this.save_settings(cx); + }, + ); + let volume_fade_in_position = self.volume_fade_in_spring.render_position(window); + let volume_fade_in = segmented_choice( + "volume-fade-in", + volume_fade_in_position, + VOLUME_FADE_CHOICE_WIDTH, + &VOLUME_FADE_CHOICES, + nearest_choice_index( + self.settings.recording_volume_fade_in_seconds, + &VOLUME_FADE_CHOICES, + ), + cx, + |this, index, seconds, cx| { + this.settings.recording_volume_fade_in_seconds = seconds; + this.volume_fade_in_spring.set_target(index as f32); + this.save_settings(cx); + }, + ); let microphone_mode = segmented_control() .id("microphone-mode") .relative() @@ -3639,6 +3718,42 @@ impl AppWindow { ) .id("recording-audio-setting"), ) + .child( + // Clip to an animated height only while the + // rows are appearing; once shown they take + // their natural height so wrapped copy at + // narrow widths is never cut off. + div() + .when(reduce_volume_visibility < 1.0, |rows| { + rows.h(px(72.0 * 3.0 * reduce_volume_visibility)) + .overflow_hidden() + }) + .opacity(reduce_volume_visibility) + .child( + settings_row( + "Reduced volume", + "Playback level while dictating; your own volume changes are kept", + reduced_volume, + ) + .id("reduced-volume-setting"), + ) + .child( + settings_row( + "Fade out", + "How long playback takes to reach the reduced level", + volume_fade_out, + ) + .id("volume-fade-out-setting"), + ) + .child( + settings_row( + "Fade in", + "How long playback takes to return after dictation", + volume_fade_in, + ) + .id("volume-fade-in-setting"), + ), + ) .child( div() .w_full() @@ -7788,22 +7903,93 @@ fn hotkey_keycaps_width(keycap_count: usize) -> f32 { } } +const REDUCED_VOLUME_CHOICES: [(&str, f32); 5] = [ + ("10%", 0.1), + ("20%", 0.2), + ("30%", 0.3), + ("50%", 0.5), + ("75%", 0.75), +]; +const REDUCED_VOLUME_CHOICE_WIDTH: f32 = 36.0; +const VOLUME_FADE_CHOICES: [(&str, f32); 5] = [ + ("Off", 0.0), + ("0.25s", 0.25), + ("0.5s", 0.5), + ("1s", 1.0), + ("2s", 2.0), +]; +const VOLUME_FADE_CHOICE_WIDTH: f32 = 40.0; + +/// Position of a behavior in the "While dictating" control; the selection +/// spring animates across these indices. fn recording_audio_index(behavior: RecordingAudioBehavior) -> usize { - match behavior { - RecordingAudioBehavior::Mute => 0, - RecordingAudioBehavior::PauseMedia => 1, - RecordingAudioBehavior::DoNothing => 2, - } + RecordingAudioBehavior::ALL + .iter() + .position(|segment| *segment == behavior) + .unwrap_or(0) +} + +/// Index of the choice closest to a persisted value, so hand-edited or +/// future values still highlight a sensible segment. +fn nearest_choice_index(value: f32, choices: &[(&str, f32)]) -> usize { + choices + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| (value - left.1).abs().total_cmp(&(value - right.1).abs())) + .map_or(0, |(index, _)| index) +} + +/// Equal-width segmented control whose selection indicator follows `position`. +fn segmented_choice( + id: &'static str, + position: f32, + item_width: f32, + choices: &'static [(&'static str, f32)], + selected: usize, + cx: &mut Context, + on_select: impl Fn(&mut AppWindow, usize, f32, &mut Context) + Clone + 'static, +) -> Div { + segmented_control() + .relative() + .child( + div() + .absolute() + .left(px(2.0 + position * item_width)) + .top(px(2.0)) + .w(px(item_width)) + .h(px(26.0)) + .rounded(px(4.0)) + .bg(rgb(SURFACE_SELECTED)), + ) + .children(choices.iter().enumerate().map(|(index, (label, value))| { + let value = *value; + let on_select = on_select.clone(); + segmented_item(index == selected) + .id((id, index)) + .w(px(item_width)) + .px(px(0.0)) + .justify_center() + .text_size(px(9.0)) + .bg(rgba(0x00000000)) + .child(*label) + .on_click(cx.listener(move |this, _, _, cx| { + if index == selected { + return; + } + on_select(this, index, value, cx); + })) + })) } -fn segmented_geometry(position: f32, widths: [f32; 3]) -> (f32, f32) { - let position = position.clamp(0.0, 2.0); +fn segmented_geometry(position: f32, widths: &[f32]) -> (f32, f32) { + let last = widths.len().saturating_sub(1); + let position = position.clamp(0.0, last as f32); let lower = position.floor() as usize; - let upper = (lower + 1).min(2); + let upper = (lower + 1).min(last); let progress = position - lower as f32; - let lefts = [2.0, 2.0 + widths[0], 2.0 + widths[0] + widths[1]]; + let left = |index: usize| 2.0 + widths[..index].iter().sum::(); ( - lefts[lower] + (lefts[upper] - lefts[lower]) * progress, + left(lower) + (left(upper) - left(lower)) * progress, widths[lower] + (widths[upper] - widths[lower]) * progress, ) } diff --git a/src/main.rs b/src/main.rs index db2153b..69ab3ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -215,6 +215,9 @@ enum Command { /// Show the idle microphone release confirmation with Commands enabled. #[arg(long)] confirm_release_microphone: bool, + /// Select the Reduce volume behavior so its level and fade rows are visible. + #[arg(long)] + reduce_volume: bool, /// Show the sidebar update action without starting the updater. #[arg(long)] update_available: bool, @@ -473,6 +476,7 @@ fn main() -> Result<()> { command_model_missing, open_history_retention, confirm_release_microphone, + reduce_volume, update_available, } => { if matches!(target, AppPreviewTarget::DictationHud) { @@ -522,6 +526,7 @@ fn main() -> Result<()> { command_model_missing, open_history_retention, confirm_release_microphone, + reduce_volume, update_available, }, ) @@ -786,6 +791,21 @@ mod tests { assert!(Cli::try_parse_from(["hex", "app", "--confirm-release-microphone"]).is_err()); } + #[test] + fn reduce_volume_rows_require_an_explicit_preview_flag() { + for (args, expected) in [ + (vec!["hex", "preview", "settings"], false), + (vec!["hex", "preview", "settings", "--reduce-volume"], true), + ] { + let cli = Cli::try_parse_from(args).unwrap(); + let Some(Command::Preview { reduce_volume, .. }) = cli.command else { + panic!("expected preview command"); + }; + assert_eq!(reduce_volume, expected); + } + assert!(Cli::try_parse_from(["hex", "app", "--reduce-volume"]).is_err()); + } + #[test] fn available_update_requires_an_explicit_preview_flag() { for (args, expected) in [ diff --git a/src/recording_environment.rs b/src/recording_environment.rs index 2cdb12b..97e06f5 100644 --- a/src/recording_environment.rs +++ b/src/recording_environment.rs @@ -3,8 +3,11 @@ use std::mem::size_of; use std::path::Path; use std::process::{Child, Command}; use std::ptr::NonNull; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::{self, Sender}; -use std::thread; +use std::thread::{self, JoinHandle}; +use std::time::Duration; use objc2_core_audio::{ AudioObjectGetPropertyData, AudioObjectPropertyAddress, AudioObjectSetPropertyData, @@ -12,9 +15,17 @@ use objc2_core_audio::{ kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeOutput, kAudioObjectSystemObject, }; -use crate::app_settings::{self, RecordingAudioBehavior}; +use crate::app_settings::{self, RecordingAudioBehavior, RecordingVolumeReduction}; const VIRTUAL_MAIN_VOLUME: u32 = u32::from_be_bytes(*b"vmvc"); +/// Ignore Core Audio rounding while still detecting a single volume-key step. +const MANUAL_VOLUME_THRESHOLD: f32 = 0.025; +/// Playback already at or below the reduced level (within this headroom) is +/// left alone rather than nudged and later "restored". +const REDUCTION_HEADROOM: f32 = 0.005; +/// Interval between volume writes while fading; short enough to sound smooth, +/// long enough that a cancelled fade stops promptly. +const VOLUME_RAMP_STEP: Duration = Duration::from_millis(25); const PAUSE_MUSIC: &str = r#" try if application "Music" is running then @@ -169,6 +180,7 @@ impl Drop for PreventSleep { enum AudioBehaviorGuard { Muted { device: u32, previous: f32 }, + Reduced(VolumeReduction), Paused { players: Vec }, None, } @@ -182,6 +194,10 @@ impl AudioBehaviorGuard { Self::Muted { device, previous } }) } + RecordingAudioBehavior::ReduceVolume => { + reduce_output(app_settings::recording_volume_reduction()) + .map_or(Self::None, Self::Reduced) + } RecordingAudioBehavior::PauseMedia => { let players = pause_media(); if players.is_empty() { @@ -206,6 +222,10 @@ impl Drop for AudioBehaviorGuard { tracing::info!(volume = *previous, "restored system output after dictation"); } } + // `VolumeReduction` restores the output in its own `Drop`. + Self::Reduced(reduction) => { + tracing::debug!(previous = reduction.previous, "ending output reduction") + } Self::Paused { players } => resume_media(players), Self::None => {} } @@ -218,6 +238,290 @@ fn mute_output() -> Option<(u32, f32)> { set_output_volume(device, 0.0).then_some((device, previous)) } +/// The output whose volume a reduction owns. Core Audio in the app; a scripted +/// fake in tests so ramp, takeover, and restore ordering can be checked without +/// a device. +trait OutputVolume: Clone + Send + 'static { + fn read(&self) -> Option; + fn write(&self, volume: f32) -> bool; +} + +#[derive(Clone, Copy)] +struct CoreAudioOutput { + device: u32, +} + +impl OutputVolume for CoreAudioOutput { + fn read(&self) -> Option { + output_volume(self.device) + } + + fn write(&self, volume: f32) -> bool { + set_output_volume(self.device, volume) + } +} + +/// Lowers the default output to the configured level for the duration of a +/// recording environment, optionally fading in each direction. +/// +/// The previous volume is captured once, when the environment starts, and the +/// controller only starts one environment for overlapping sessions, so a +/// double-tap lock that overlaps a release grace period restores the original +/// level rather than the already-reduced one (kitlangton/Hex#220). A manual +/// volume change while reduced, or during either fade, is treated as the user +/// taking over: HEX stops writing and restores nothing afterward. +struct VolumeReduction { + output: O, + previous: f32, + fade_in: Duration, + shared: Arc, + fade_out: Option>, +} + +struct ReductionShared { + cancelled: AtomicBool, + released: AtomicBool, + last_applied: AtomicU32, +} + +impl ReductionShared { + fn new(volume: f32) -> Self { + Self { + cancelled: AtomicBool::new(false), + released: AtomicBool::new(false), + last_applied: AtomicU32::new(volume.to_bits()), + } + } + + fn last_applied(&self) -> f32 { + f32::from_bits(self.last_applied.load(Ordering::Acquire)) + } + + fn record_applied(&self, volume: f32) { + self.last_applied.store(volume.to_bits(), Ordering::Release); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RampOutcome { + Completed, + Cancelled, + /// The user changed the volume mid-ramp; HEX stops touching the output. + Released, + Failed, +} + +fn reduce_output(settings: RecordingVolumeReduction) -> Option> { + let device = default_output_device()?; + reduce_output_of(CoreAudioOutput { device }, settings) +} + +fn reduce_output_of( + output: O, + settings: RecordingVolumeReduction, +) -> Option> { + let previous = output.read()?; + let target = settings.volume; + if !needs_reduction(previous, target) { + tracing::info!( + volume = previous, + target, + "kept system output for dictation; already at or below the reduced level" + ); + return None; + } + let shared = Arc::new(ReductionShared::new(previous)); + let fade_out = Duration::from_secs_f32(settings.fade_out_seconds); + let ramp = volume_ramp(previous, target, fade_out); + let fade_out = if ramp.len() == 1 { + match run_ramp(&output, ramp, &shared, RampGuard::CancelOrManualChange) { + RampOutcome::Completed => {} + RampOutcome::Failed => { + tracing::warn!("could not reduce system output for dictation"); + return None; + } + RampOutcome::Cancelled | RampOutcome::Released => return None, + } + None + } else { + let worker = Arc::clone(&shared); + let fading = output.clone(); + Some(thread::spawn(move || { + match run_ramp(&fading, ramp, &worker, RampGuard::CancelOrManualChange) { + RampOutcome::Completed | RampOutcome::Cancelled => {} + RampOutcome::Released => { + tracing::info!("stopped reducing system output after a manual volume change") + } + RampOutcome::Failed => { + tracing::warn!("could not finish reducing system output for dictation") + } + } + })) + }; + tracing::info!( + previous, + target, + fade_out_seconds = settings.fade_out_seconds, + "reduced system output for dictation" + ); + Some(VolumeReduction { + output, + previous, + fade_in: Duration::from_secs_f32(settings.fade_in_seconds), + shared, + fade_out, + }) +} + +impl Drop for VolumeReduction { + fn drop(&mut self) { + self.shared.cancelled.store(true, Ordering::Release); + if let Some(fade_out) = self.fade_out.take() { + let _ = fade_out.join(); + } + if self.shared.released.load(Ordering::Acquire) { + return; + } + let Some(current) = self.output.read() else { + tracing::warn!("could not read system output after dictation; leaving it unchanged"); + return; + }; + let expected = self.shared.last_applied(); + if is_manual_adjustment(current, expected) { + tracing::info!( + current, + expected, + "left system output alone after a manual volume change during dictation" + ); + return; + } + let ramp = volume_ramp(current, self.previous, self.fade_in); + match run_ramp(&self.output, ramp, &self.shared, RampGuard::ManualChange) { + RampOutcome::Completed => { + tracing::info!( + volume = self.previous, + "restored system output after dictation" + ) + } + RampOutcome::Released => { + tracing::info!("stopped restoring system output after a manual volume change") + } + RampOutcome::Failed => { + tracing::warn!("could not restore system output after dictation") + } + RampOutcome::Cancelled => {} + } + } +} + +#[derive(Clone, Copy)] +enum RampGuard { + /// Stop when the environment ends or the user moves the volume themselves. + CancelOrManualChange, + /// Stop only for the user; the environment-end flag is already set while + /// restoring. + ManualChange, +} + +/// Writes each ramp step, sleeping between steps. Before every write the +/// current level is compared with the last level HEX applied; a difference +/// beyond the manual threshold means the user took over. The level read back +/// after each write becomes the new expected level, so devices that quantize +/// volume to coarse steps do not look like manual changes. A level that cannot +/// be read means HEX cannot prove ownership, so it stops rather than write +/// blind. A user change that lands between a write and its read-back is +/// adopted as HEX's own level; that interval is short but not bounded, and the +/// trade-off is accepted. +fn run_ramp( + output: &O, + ramp: VolumeRamp, + shared: &ReductionShared, + guard: RampGuard, +) -> RampOutcome { + let last = ramp.len().saturating_sub(1); + for (index, volume) in ramp.enumerate() { + if matches!(guard, RampGuard::CancelOrManualChange) + && shared.cancelled.load(Ordering::Acquire) + { + return RampOutcome::Cancelled; + } + let Some(current) = output.read() else { + return RampOutcome::Failed; + }; + if is_manual_adjustment(current, shared.last_applied()) { + shared.released.store(true, Ordering::Release); + return RampOutcome::Released; + } + if !output.write(volume) { + return RampOutcome::Failed; + } + shared.record_applied(output.read().unwrap_or(volume)); + if index < last { + thread::sleep(VOLUME_RAMP_STEP); + } + } + RampOutcome::Completed +} + +fn needs_reduction(current: f32, target: f32) -> bool { + current > target + REDUCTION_HEADROOM +} + +fn is_manual_adjustment(current: f32, expected: f32) -> bool { + (current - expected).abs() > MANUAL_VOLUME_THRESHOLD +} + +/// Evenly spaced volumes from just after `from` to exactly `to`; a zero +/// duration yields the single target step. +#[derive(Clone, Debug)] +struct VolumeRamp { + from: f32, + to: f32, + steps: usize, + next: usize, +} + +fn volume_ramp(from: f32, to: f32, duration: Duration) -> VolumeRamp { + let steps = (duration.as_secs_f32() / VOLUME_RAMP_STEP.as_secs_f32()).ceil(); + VolumeRamp { + from, + to, + steps: if steps.is_finite() && steps >= 1.0 { + steps as usize + } else { + 1 + }, + next: 1, + } +} + +impl Iterator for VolumeRamp { + type Item = f32; + + fn next(&mut self) -> Option { + if self.next > self.steps { + return None; + } + let volume = if self.next == self.steps { + // Land exactly on the target so a restore returns the precise + // level observed at start rather than a rounded neighbor. + self.to + } else { + let progress = self.next as f32 / self.steps as f32; + self.from + (self.to - self.from) * progress + }; + self.next += 1; + Some(volume.clamp(0.0, 1.0)) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.steps.saturating_sub(self.next - 1); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for VolumeRamp {} + fn default_output_device() -> Option { let mut address = AudioObjectPropertyAddress { mSelector: kAudioHardwarePropertyDefaultOutputDevice, @@ -388,6 +692,258 @@ mod tests { assert_eq!(events.try_iter().collect::>(), expected); } + /// Scripted output: a shared level plus optional user changes injected + /// either right after a given write (before HEX reads it back) or right + /// before a given read (between ramp steps). + #[derive(Clone, Default)] + struct FakeOutput { + state: Arc>, + } + + #[derive(Default)] + struct FakeOutputState { + volume: f32, + writes: Vec, + reads: usize, + change_after_write: Option<(usize, f32)>, + change_before_read: Option<(usize, f32)>, + fail_reads_from: Option, + write_observer: Option>, + } + + impl FakeOutput { + fn at(volume: f32) -> Self { + let output = Self::default(); + output.state.lock().unwrap().volume = volume; + output + } + + fn user_changes_after_write(self, write: usize, volume: f32) -> Self { + self.state.lock().unwrap().change_after_write = Some((write, volume)); + self + } + + fn user_changes_before_read(self, read: usize, volume: f32) -> Self { + self.state.lock().unwrap().change_before_read = Some((read, volume)); + self + } + + fn reads_fail_from(self, read: usize) -> Self { + self.state.lock().unwrap().fail_reads_from = Some(read); + self + } + + /// Reports the running write count after each write, so a test can + /// wait for a fade-out worker to make progress before acting. + fn observe_writes(self) -> (Self, Receiver) { + let (sender, receiver) = mpsc::channel(); + self.state.lock().unwrap().write_observer = Some(sender); + (self, receiver) + } + + fn volume(&self) -> f32 { + self.state.lock().unwrap().volume + } + + fn writes(&self) -> Vec { + self.state.lock().unwrap().writes.clone() + } + } + + impl OutputVolume for FakeOutput { + fn read(&self) -> Option { + let mut state = self.state.lock().unwrap(); + state.reads += 1; + if let Some((read, volume)) = state.change_before_read + && state.reads == read + { + state.volume = volume; + } + if state + .fail_reads_from + .is_some_and(|read| state.reads >= read) + { + return None; + } + Some(state.volume) + } + + fn write(&self, volume: f32) -> bool { + let mut state = self.state.lock().unwrap(); + state.volume = volume; + state.writes.push(volume); + if let Some((write, volume)) = state.change_after_write + && state.writes.len() == write + { + state.volume = volume; + } + if let Some(observer) = &state.write_observer { + let _ = observer.send(state.writes.len()); + } + true + } + } + + fn reduction( + volume: f32, + fade_out_seconds: f32, + fade_in_seconds: f32, + ) -> RecordingVolumeReduction { + RecordingVolumeReduction { + volume, + fade_out_seconds, + fade_in_seconds, + } + } + + #[test] + fn reduction_lowers_the_output_and_dropping_it_restores_exactly() { + let output = FakeOutput::at(0.8); + let reduction = reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).unwrap(); + assert_eq!(output.writes(), vec![0.2]); + drop(reduction); + assert_eq!(output.writes(), vec![0.2, 0.8]); + assert_eq!(output.volume(), 0.8); + } + + #[test] + fn output_already_at_or_below_the_level_is_left_untouched() { + let output = FakeOutput::at(0.15); + assert!(reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).is_none()); + assert!(output.writes().is_empty()); + } + + #[test] + fn a_manual_change_while_reduced_skips_the_restore() { + let output = FakeOutput::at(0.8); + let reduction = reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).unwrap(); + output.state.lock().unwrap().volume = 0.5; + drop(reduction); + assert_eq!(output.writes(), vec![0.2]); + assert_eq!(output.volume(), 0.5); + } + + #[test] + fn restore_fade_yields_to_a_manual_change_between_steps() { + // Reads so far: 1 (start), 2 (pre-write check), 3 (read-back). + // Restore: 4 (end check), 5 (step 1 pre-write), 6 (read-back), + // 7 (step 2 pre-write) sees the user's change and stops. + let output = FakeOutput::at(0.8).user_changes_before_read(7, 0.05); + let reduction = reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.075)).unwrap(); + let shared = Arc::clone(&reduction.shared); + drop(reduction); + let writes = output.writes(); + assert_eq!(writes.len(), 2, "{writes:?}"); + assert_eq!(writes[0], 0.2); + assert!(writes[1] > 0.2 && writes[1] < 0.8, "{writes:?}"); + assert_eq!(output.volume(), 0.05); + assert!(shared.released.load(Ordering::Acquire)); + } + + #[test] + fn dropping_during_a_fade_out_cancels_it_and_restores_from_the_current_level() { + let (output, writes_seen) = FakeOutput::at(0.8).observe_writes(); + let reduction = reduce_output_of(output.clone(), reduction(0.2, 2.0, 0.0)).unwrap(); + assert!(reduction.fade_out.is_some()); + // Wait for the worker to land its first step so the drop provably + // interrupts a reduction in progress rather than racing its start. + assert_eq!(writes_seen.recv_timeout(Duration::from_secs(2)).unwrap(), 1); + drop(reduction); + let writes = output.writes(); + let (fade_out, restore) = writes.split_at(writes.len() - 1); + assert!(!fade_out.is_empty() && fade_out.len() < 80, "{writes:?}"); + assert!( + fade_out.iter().all(|volume| *volume > 0.2 && *volume < 0.8), + "fade-out steps must be partial: {writes:?}" + ); + assert_eq!(restore, [0.8]); + assert_eq!(output.volume(), 0.8); + } + + #[test] + fn a_failed_read_before_a_write_stops_the_ramp_instead_of_writing_blind() { + // Reads: 1 (start), 2 (pre-write), 3 (read-back); the restore end + // check is read 4 and fails, so nothing more is written. + let output = FakeOutput::at(0.8).reads_fail_from(4); + let owned = reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).unwrap(); + drop(owned); + assert_eq!(output.writes(), vec![0.2]); + + // A read failure before the first write stops the reduction too. + let output = FakeOutput::at(0.8).reads_fail_from(2); + assert!(reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).is_none()); + assert!(output.writes().is_empty()); + } + + #[test] + fn a_change_between_a_write_and_its_read_back_is_adopted_not_released() { + // Documented, accepted trade-off: HEX treats the read-back as its own level. + let output = FakeOutput::at(0.8).user_changes_after_write(1, 0.3); + let reduction = reduce_output_of(output.clone(), reduction(0.2, 0.0, 0.0)).unwrap(); + assert_eq!(reduction.shared.last_applied(), 0.3); + drop(reduction); + assert_eq!(output.volume(), 0.8); + } + + fn assert_volumes(actual: &[f32], expected: &[f32]) { + assert_eq!(actual.len(), expected.len(), "{actual:?} vs {expected:?}"); + for (actual, expected) in actual.iter().zip(expected) { + assert!( + (actual - expected).abs() < 1e-6, + "{actual} differs from {expected}" + ); + } + } + + #[test] + fn zero_duration_ramp_is_a_single_target_step() { + assert_eq!( + volume_ramp(0.8, 0.2, Duration::ZERO).collect::>(), + vec![0.2] + ); + } + + #[test] + fn timed_ramp_descends_evenly_and_ends_exactly_on_target() { + let ramp = volume_ramp(1.0, 0.2, Duration::from_millis(100)); + assert_eq!(ramp.len(), 4); + let volumes = ramp.collect::>(); + assert_volumes(&volumes, &[0.8, 0.6, 0.4, 0.2]); + assert_eq!(volumes.last(), Some(&0.2)); + assert!(volumes.windows(2).all(|pair| pair[1] < pair[0])); + } + + #[test] + fn partial_step_durations_round_up_and_still_end_on_target() { + let volumes = volume_ramp(0.35, 0.7, Duration::from_millis(30)).collect::>(); + assert_eq!(volumes.len(), 2); + assert_eq!(volumes.last(), Some(&0.7)); + } + + #[test] + fn restore_ramp_ascends_and_clamps_to_the_device_range() { + let volumes = volume_ramp(0.2, 1.0, Duration::from_millis(50)).collect::>(); + assert_volumes(&volumes, &[0.6, 1.0]); + assert!(volume_ramp(0.0, 1.5, Duration::ZERO).all(|volume| (0.0..=1.0).contains(&volume))); + } + + #[test] + fn reduction_skips_playback_already_at_or_below_the_target() { + assert!(needs_reduction(0.6, 0.2)); + assert!(!needs_reduction(0.2, 0.2)); + assert!(!needs_reduction(0.203, 0.2)); + assert!(needs_reduction(0.21, 0.2)); + } + + #[test] + fn manual_adjustment_ignores_core_audio_rounding_but_catches_a_key_step() { + assert!(!is_manual_adjustment(0.2, 0.2)); + assert!(!is_manual_adjustment(0.2001, 0.2)); + assert!(!is_manual_adjustment(0.22, 0.2)); + assert!(is_manual_adjustment(0.2625, 0.2)); + assert!(is_manual_adjustment(0.0, 0.2)); + } + #[test] fn sessions_are_harmless_after_the_environment_worker_disconnects() { let (commands, receiver) = mpsc::channel();