Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/dictation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
176 changes: 174 additions & 2 deletions src/app_settings.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
}
Expand All @@ -510,13 +564,15 @@ impl RecordingAudioBehavior {
Self::Mute => 0,
Self::PauseMedia => 1,
Self::DoNothing => 2,
Self::ReduceVolume => 3,
}
}

fn decode(value: u8) -> Self {
match value {
1 => Self::PauseMedia,
2 => Self::DoNothing,
3 => Self::ReduceVolume,
_ => Self::Mute,
}
}
Expand All @@ -531,6 +587,9 @@ pub struct AppSettings {
pub sound_effect_volume: f32,
pub microphone: Option<String>,
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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Self> {
let settings = Self::load_from(&path()?)?;
settings.apply_runtime();
Expand All @@ -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();
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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!(
Expand Down
Loading