Skip to content
Closed
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
334 changes: 212 additions & 122 deletions Cargo.lock

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ regex = "1"
# Async traits
async-trait = "0.1"

# Input handling (evdev for kernel-level key events)
evdev = "0.12"
# Cross-platform system interface
libc = "0.2"
inotify = "0.10" # Watch /dev/input for device hotplug
nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC

# Audio capture
Expand All @@ -64,12 +62,21 @@ parakeet-rs = { version = "0.2.9", optional = true }
# CPU count for thread detection
num_cpus = "1.16"

# File watching for status --follow
# File and device watching (status --follow, device hotplug)
notify = "6"

# Single instance check
pidlock = "0.1"

# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
evdev = "0.12" # Input handling (kernel-level key events)

# macOS-specific dependencies
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24" # CGEvent for hotkey capture and text injection
core-foundation = "0.10" # Core Foundation types

[features]
default = []
gpu-vulkan = ["whisper-rs/vulkan"]
Expand Down
43 changes: 18 additions & 25 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,10 @@ mod tests {
let cli = Cli::parse_from(["voxtype", "record", "start", "--file=out.txt"]);
match cli.command {
Some(Commands::Record { action }) => {
assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File));
assert_eq!(
action.output_mode_override(),
Some(OutputModeOverride::File)
);
assert_eq!(action.file_path(), Some("out.txt"));
}
_ => panic!("Expected Record command"),
Expand All @@ -821,7 +824,10 @@ mod tests {
let cli = Cli::parse_from(["voxtype", "record", "start", "--file"]);
match cli.command {
Some(Commands::Record { action }) => {
assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File));
assert_eq!(
action.output_mode_override(),
Some(OutputModeOverride::File)
);
assert_eq!(action.file_path(), Some("")); // Empty string means use config path
}
_ => panic!("Expected Record command"),
Expand All @@ -845,7 +851,10 @@ mod tests {
let cli = Cli::parse_from(["voxtype", "record", "start", "--file=/tmp/output.txt"]);
match cli.command {
Some(Commands::Record { action }) => {
assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File));
assert_eq!(
action.output_mode_override(),
Some(OutputModeOverride::File)
);
assert_eq!(action.file_path(), Some("/tmp/output.txt"));
}
_ => panic!("Expected Record command"),
Expand Down Expand Up @@ -904,17 +913,9 @@ mod tests {

#[test]
fn test_record_start_file_mutually_exclusive_with_paste() {
let result = Cli::try_parse_from([
"voxtype",
"record",
"start",
"--file=out.txt",
"--paste",
]);
assert!(
result.is_err(),
"Should not allow both --file and --paste"
);
let result =
Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--paste"]);
assert!(result.is_err(), "Should not allow both --file and --paste");
}

#[test]
Expand All @@ -934,17 +935,9 @@ mod tests {

#[test]
fn test_record_start_file_mutually_exclusive_with_type() {
let result = Cli::try_parse_from([
"voxtype",
"record",
"start",
"--file=out.txt",
"--type",
]);
assert!(
result.is_err(),
"Should not allow both --file and --type"
);
let result =
Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--type"]);
assert!(result.is_err(), "Should not allow both --file and --type");
}

#[test]
Expand Down
25 changes: 18 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ state_file = "auto"

[hotkey]
# Key to hold for push-to-talk
# Common choices: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24
# Use `evtest` to find key names for your keyboard
key = "SCROLLLOCK"
# Default: FN/Globe on macOS, SCROLLLOCK on Linux
# macOS options: FN/GLOBE, RIGHTOPTION, CAPSLOCK, F13-F20
# Linux options: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24
# key = "FN" # macOS default
# key = "SCROLLLOCK" # Linux default

# Optional modifier keys that must also be held
# Example: modifiers = ["LEFTCTRL", "LEFTALT"]
Expand Down Expand Up @@ -295,8 +297,10 @@ pub struct Config {
/// Hotkey detection configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HotkeyConfig {
/// Key name (evdev KEY_* constant name, without the KEY_ prefix)
/// Examples: "SCROLLLOCK", "RIGHTALT", "PAUSE", "F24"
/// Key name (without KEY_ prefix)
/// Default: "FN" on macOS, "SCROLLLOCK" on Linux
/// macOS: "FN", "GLOBE", "RIGHTOPTION", "CAPSLOCK", "F13"-"F20"
/// Linux: "SCROLLLOCK", "PAUSE", "RIGHTALT", "F13"-"F24"
#[serde(default = "default_hotkey_key")]
pub key: String,

Expand Down Expand Up @@ -362,7 +366,14 @@ pub struct AudioFeedbackConfig {
}

fn default_hotkey_key() -> String {
"SCROLLLOCK".to_string()
#[cfg(target_os = "macos")]
{
"FN".to_string()
}
#[cfg(not(target_os = "macos"))]
{
"SCROLLLOCK".to_string()
}
}

fn default_sound_theme() -> String {
Expand Down Expand Up @@ -887,7 +898,7 @@ pub struct NotificationConfig {
pub on_recording_stop: bool,

/// Notify with transcribed text after transcription completes
#[serde(default = "default_true")]
#[serde(default)]
pub on_transcription: bool,

/// Show engine icon in notification title (🦜 for Parakeet, 🗣️ for Whisper)
Expand Down
12 changes: 8 additions & 4 deletions src/cpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
//! particularly in virtualized environments where the hypervisor may not
//! expose all host CPU features.
//!
//! The SIGILL handler is installed via a .init_array constructor, which runs
//! before main() - this is critical because AVX-512 instructions can appear
//! in library initialization code, before our Rust main() even starts.
//! On Linux, the SIGILL handler is installed via a .init_array constructor,
//! which runs before main() - this is critical because AVX-512 instructions
//! can appear in library initialization code, before our Rust main() even starts.
//!
//! On macOS, this functionality is not needed as macOS builds target different
//! CPU instruction sets.

use std::sync::atomic::{AtomicBool, Ordering};

static SIGILL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false);

/// Constructor function that runs before main() via .init_array
/// Constructor function that runs before main() via .init_array (Linux only)
/// This ensures the SIGILL handler is installed before any library
/// initialization code that might use unsupported instructions.
#[cfg(target_os = "linux")]
#[used]
#[link_section = ".init_array"]
static INIT_SIGILL_HANDLER: extern "C" fn() = {
Expand Down
23 changes: 10 additions & 13 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,23 +458,23 @@ impl Daemon {
if let Some(t) = transcriber {
self.transcription_task =
Some(tokio::task::spawn_blocking(move || t.transcribe(&samples)));
return true;
true
} else {
tracing::error!("No transcriber available");
self.play_feedback(SoundEvent::Error);
self.reset_to_idle(state).await;
return false;
false
}
}
Err(e) => {
tracing::warn!("Recording error: {}", e);
self.reset_to_idle(state).await;
return false;
false
}
}
} else {
self.reset_to_idle(state).await;
return false;
false
}
}

Expand Down Expand Up @@ -585,18 +585,15 @@ impl Daemon {
};

let file_mode = &self.config.output.file_mode;
match write_transcription_to_file(&output_path, &final_text, file_mode).await
match write_transcription_to_file(&output_path, &final_text, file_mode)
.await
{
Ok(()) => {
let mode_str = match file_mode {
FileMode::Overwrite => "wrote",
FileMode::Append => "appended",
};
tracing::info!(
"{} transcription to {:?}",
mode_str,
output_path
);
tracing::info!("{} transcription to {:?}", mode_str, output_path);
}
Err(e) => {
tracing::error!(
Expand Down Expand Up @@ -718,8 +715,7 @@ impl Daemon {
return Err(crate::error::VoxtypeError::Config(format!(
"Another voxtype instance is already running (lock error: {:?})",
e
))
.into());
)));
}
}

Expand Down Expand Up @@ -1688,7 +1684,8 @@ mod tests {
// Should not panic
});
}


#[allow(dead_code)]
fn test_pidlock_acquisition_succeeds() {
with_test_runtime_dir(|dir| {
let lock_path = dir.join("voxtype.lock");
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ pub enum HotkeyError {

#[error("evdev error: {0}")]
Evdev(String),

#[error("Hotkey detection not supported: {0}")]
NotSupported(String),
}

/// Errors related to audio capture
Expand Down Expand Up @@ -127,6 +130,7 @@ pub enum OutputError {
/// Result type alias using VoxtypeError
pub type Result<T> = std::result::Result<T, VoxtypeError>;

#[cfg(target_os = "linux")]
impl From<evdev::Error> for HotkeyError {
fn from(e: evdev::Error) -> Self {
HotkeyError::Evdev(e.to_string())
Expand Down
Loading