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
10 changes: 10 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ modifiers = []
# - toggle: Press hotkey once to start recording, press again to stop
# mode = "push_to_talk"

# Modifier key for secondary model selection (evdev only)
# Hold this key while pressing the hotkey to use secondary_model
# model_modifier = "LEFTSHIFT"

# Profile modifiers for context-aware post-processing (evdev only)
# Maps modifier keys to named profiles defined in [profiles.*] sections
# [hotkey.profile_modifiers]
# LEFTSHIFT = "translate" # Shift+hotkey activates [profiles.translate]
# RIGHTALT = "formal" # RightAlt+hotkey activates [profiles.formal]

[audio]
# Audio input device ("default" uses system default)
# List devices with: pactl list sources short
Expand Down
33 changes: 33 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,39 @@ cancel_key = "ESC" # Press Escape to cancel

**Note:** This only applies when using evdev hotkey detection (`enabled = true`). When using compositor keybindings, use `voxtype record cancel` instead. See [User Manual - Canceling Transcription](USER_MANUAL.md#canceling-transcription).

### [hotkey.profile_modifiers]

**Type:** Table (key = modifier name, value = profile name)
**Default:** Empty (disabled)
**Required:** No

Maps modifier keys to named profiles. When a profile modifier is held while pressing the hotkey, that profile's post-processing command is used instead of the default. Profiles are defined in `[profiles.<name>]` sections.

**Example:**
```toml
[hotkey]
key = "SCROLLLOCK"

[hotkey.profile_modifiers]
RIGHTSHIFT = "translate" # Shift + hotkey activates [profiles.translate]
RIGHTALT = "formal" # RightAlt + hotkey activates [profiles.formal]

[profiles.translate]
post_process_command = "my-script.sh --translate-en"
post_process_timeout_ms = 10000

[profiles.formal]
post_process_command = "my-script.sh --formal"
```
Comment on lines +261 to +267
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example sets timeout_ms inside [profiles.translate], but the profile field is post_process_timeout_ms (while timeout_ms belongs under [output.post_process]). As written, this timeout won’t apply to the profile and will likely be ignored by config parsing. Update the example (and surrounding text if needed) to use post_process_timeout_ms for profiles.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b811b0b. Changed to post_process_timeout_ms.


**Valid key names:** Same modifier keys as `modifiers` option:
- `LEFTSHIFT`, `RIGHTSHIFT`
- `LEFTCTRL`, `RIGHTCTRL`
- `LEFTALT`, `RIGHTALT`
- `LEFTMETA`, `RIGHTMETA`

**Note:** This only applies when using evdev hotkey detection (`enabled = true`). When using compositor keybindings, use `voxtype record start --profile <name>` instead. Avoid using the same key in both `modifiers` and `profile_modifiers` -- every hotkey press would always activate that profile.

---

## [audio]
Expand Down
20 changes: 20 additions & 0 deletions docs/USER_MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,26 @@ Available modifiers:
- `LEFTSHIFT`, `RIGHTSHIFT`
- `LEFTMETA`, `RIGHTMETA` (Super/Windows key)

### Profile Modifiers

Map modifier keys to named profiles for different post-processing per recording. Hold a profile modifier while pressing the hotkey to activate that profile:

```toml
[hotkey]
key = "SCROLLLOCK"

[hotkey.profile_modifiers]
RIGHTSHIFT = "translate"

[profiles.translate]
post_process_command = "my-cleanup.sh --translate-en"
post_process_timeout_ms = 10000
```

With this config, bare ScrollLock uses default post-processing, while Right Shift + ScrollLock translates to English. See [Configuration - profile_modifiers](CONFIGURATION.md#hotkeyprofile_modifiers) for details.

When using compositor keybindings instead of evdev, use `voxtype record start --profile <name>` to achieve the same effect.

---

## Compositor Keybindings
Expand Down
82 changes: 82 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ pub struct HotkeyConfig {
/// Examples: "LEFTSHIFT", "RIGHTALT", "LEFTCTRL"
#[serde(default)]
pub model_modifier: Option<String>,

/// Optional modifier keys that activate named profiles (evdev KEY_* names, without KEY_ prefix)
/// When held while pressing the hotkey, activates the named profile for post-processing
/// Example: { "LEFTSHIFT" = "translate" } activates [profiles.translate] when Shift is held
#[serde(default)]
pub profile_modifiers: HashMap<String, String>,
}

/// Audio capture configuration
Expand Down Expand Up @@ -1745,6 +1751,7 @@ impl Default for Config {
enabled: true,
cancel_key: None,
model_modifier: None,
profile_modifiers: std::collections::HashMap::new(),
},
audio: AudioConfig {
device: "default".to_string(),
Expand Down Expand Up @@ -3623,4 +3630,79 @@ mod tests {
assert!(!config.output.restore_clipboard);
assert_eq!(config.output.restore_clipboard_delay_ms, 200);
}

#[test]
fn test_parse_profile_modifiers() {
let toml_str = r#"
[hotkey]
key = "SCROLLLOCK"

[hotkey.profile_modifiers]
LEFTSHIFT = "translate"
RIGHTALT = "formal"

[audio]
device = "default"
sample_rate = 16000
max_duration_secs = 60

[whisper]
model = "base.en"
language = "en"

[output]
mode = "type"

[profiles.translate]
post_process_command = "translate.sh"

[profiles.formal]
post_process_command = "formal.sh"
post_process_timeout_ms = 15000
"#;

let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.hotkey.profile_modifiers.len(), 2);
assert_eq!(
config.hotkey.profile_modifiers.get("LEFTSHIFT").unwrap(),
"translate"
);
assert_eq!(
config.hotkey.profile_modifiers.get("RIGHTALT").unwrap(),
"formal"
);
assert!(config.get_profile("translate").is_some());
assert!(config.get_profile("formal").is_some());
assert_eq!(
config
.get_profile("translate")
.unwrap()
.post_process_command
.as_deref(),
Some("translate.sh")
);
}

#[test]
fn test_profile_modifiers_default_empty() {
let toml_str = r#"
[hotkey]
key = "SCROLLLOCK"

[audio]
device = "default"
sample_rate = 16000
max_duration_secs = 60

[whisper]
model = "base.en"
language = "en"

[output]
mode = "type"
"#;

let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.hotkey.profile_modifiers.is_empty());
}
}
41 changes: 34 additions & 7 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ fn cleanup_profile_override() {
let _ = std::fs::remove_file(&profile_file);
}

/// Write a profile override file so the daemon uses the named profile for post-processing.
/// Same mechanism as `voxtype record start --profile <name>`.
fn write_profile_override(profile_name: &str) {
let profile_file = Config::runtime_dir().join("profile_override");
if let Err(e) = std::fs::write(&profile_file, profile_name) {
tracing::warn!("Failed to write profile override: {}", e);
} else {
tracing::info!("Profile modifier activated: {}", profile_name);
}
}

/// Read and consume a boolean override file from the runtime directory.
/// Returns Some(true) or Some(false) if the file exists and is valid, None otherwise.
fn read_bool_override(name: &str) -> Option<bool> {
Expand Down Expand Up @@ -1465,8 +1476,9 @@ impl Daemon {
pub async fn run(&mut self) -> Result<()> {
tracing::info!("Starting voxtype daemon");

// Clean up any stale cancel file from previous runs
// Clean up any stale cancel and profile override files from previous runs
cleanup_cancel_file();
cleanup_profile_override();

// Clean up any stale meeting command files
cleanup_meeting_files();
Expand Down Expand Up @@ -1636,10 +1648,15 @@ impl Daemon {
} => {
match (hotkey_event, activation_mode) {
// === PUSH-TO-TALK MODE ===
(HotkeyEvent::Pressed { model_override }, ActivationMode::PushToTalk) => {
tracing::debug!("Received HotkeyEvent::Pressed (push-to-talk), state.is_idle() = {}, model_override = {:?}",
state.is_idle(), model_override);
(HotkeyEvent::Pressed { model_override, profile_override }, ActivationMode::PushToTalk) => {
tracing::debug!("Received HotkeyEvent::Pressed (push-to-talk), state.is_idle() = {}, model_override = {:?}, profile_override = {:?}",
state.is_idle(), model_override, profile_override);
if state.is_idle() {
// Write profile override file if a profile modifier was held
if let Some(ref profile_name) = profile_override {
write_profile_override(profile_name);
}

tracing::info!("Recording started");

// Send notification if enabled
Expand Down Expand Up @@ -1740,6 +1757,7 @@ impl Daemon {
}
Err(e) => {
tracing::error!("Failed to create audio capture: {}", e);
cleanup_profile_override();
self.play_feedback(SoundEvent::Error);
}
}
Expand Down Expand Up @@ -1818,11 +1836,16 @@ impl Daemon {
}

// === TOGGLE MODE ===
(HotkeyEvent::Pressed { model_override }, ActivationMode::Toggle) => {
tracing::debug!("Received HotkeyEvent::Pressed (toggle), state.is_idle() = {}, state.is_recording() = {}, model_override = {:?}",
state.is_idle(), state.is_recording(), model_override);
(HotkeyEvent::Pressed { model_override, profile_override }, ActivationMode::Toggle) => {
tracing::debug!("Received HotkeyEvent::Pressed (toggle), state.is_idle() = {}, state.is_recording() = {}, model_override = {:?}, profile_override = {:?}",
state.is_idle(), state.is_recording(), model_override, profile_override);

if state.is_idle() {
// Write profile override file if a profile modifier was held
if let Some(ref profile_name) = profile_override {
write_profile_override(profile_name);
}

// Start recording
tracing::info!("Recording started (toggle mode)");

Expand Down Expand Up @@ -1920,6 +1943,7 @@ impl Daemon {
}
Err(e) => {
tracing::error!("Failed to create audio capture: {}", e);
cleanup_profile_override();
self.play_feedback(SoundEvent::Error);
}
}
Expand Down Expand Up @@ -2721,6 +2745,9 @@ impl Daemon {
let _ = self.stop_meeting().await;
}

// Remove override files on shutdown
cleanup_profile_override();

// Remove state file on shutdown
if let Some(ref path) = self.state_file_path {
cleanup_state_file(path);
Expand Down
Loading