4 Commits

Author SHA1 Message Date
steve eff93e4731 Bump version to 0.2.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:22:20 +01:00
steve daa1542672 Add input device selection to interactive config TUI
Enumerates available audio input devices via cpal and presents them
in a dropdown, with "System default" as the first option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:21:44 +01:00
steve 3fa4d102df v0.2.1: Fix cancel key (Escape) being swallowed globally
The cancel key was consumed by rdev::grab at all times, not just during
recording/transcribing. This made the Escape key unusable system-wide
while Mouth was running. Now the cancel key only gets swallowed when
Mouth is actively recording or transcribing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:37:50 +01:00
steve 9ad870d260 Add Windows Defender false positive guidance to README
Unsigned Rust binaries that use keyboard hooks, input simulation, and
clipboard access trigger Defender heuristics. Document the workaround
(Defender exclusion) and point users to building from source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:59:59 +01:00
7 changed files with 104 additions and 10 deletions
Generated
+1 -1
View File
@@ -2224,7 +2224,7 @@ dependencies = [
[[package]]
name = "mouth"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mouth"
version = "0.2.0"
version = "0.2.2"
edition = "2024"
description = "Offline speech-to-text with global hotkey and paste"
license-file = "LICENSE"
+30
View File
@@ -82,6 +82,36 @@ A small colour-coded bar appears at the top (or bottom) of your screen:
Set `overlay_position: none` to disable.
## Windows Defender False Positive
Windows Defender may flag `mouth.exe` as malicious and quarantine it. This is a
false positive caused by the way Mouth works — it uses global keyboard hooks,
simulated input, and clipboard access, which are the same techniques used by
legitimate accessibility tools but also match heuristic patterns that antivirus
software looks for.
Mouth is open source and you can inspect every line of code in this repository.
Unfortunately, the only reliable way to prevent these warnings is to purchase a
code signing certificate, which I can't justify for a free, non-commercial
project. If you're not comfortable adding an exception, you're welcome to build
the exe yourself from source (see below) — a locally built binary is far less
likely to be flagged.
To add an exclusion in Windows Defender:
1. Open **Windows Security** (search for it in the Start menu)
2. Go to **Virus & threat protection**
3. Under "Virus & threat protection settings", click **Manage settings**
4. Scroll down to **Exclusions** and click **Add or remove exclusions**
5. Click **Add an exclusion****File**, then select `mouth.exe`
If Defender has already quarantined the file, you'll need to restore it first:
1. In **Virus & threat protection**, click **Protection history**
2. Find the Mouth entry, expand it, and click **Restore**
Then add the exclusion above to prevent it happening again.
## Building from Source
Requires Rust 1.75+.
+49
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use dialoguer::{Input, Select};
use std::time::Duration;
@@ -105,6 +106,8 @@ pub fn interactive() -> Result<()> {
.interact()?;
config.audio_feedback = feedback_idx == 0;
config.input_device = prompt_input_device(config.input_device.as_deref())?;
let vad_idx = Select::new()
.with_prompt("VAD (voice activity detection)")
.items(&["enabled", "disabled"])
@@ -122,6 +125,52 @@ pub fn interactive() -> Result<()> {
Ok(())
}
/// Prompt the user to select an audio input device from available devices.
fn prompt_input_device(current: Option<&str>) -> Result<Option<String>> {
let current_label = current.unwrap_or("system default");
let host = cpal::default_host();
let mut device_names: Vec<String> = Vec::new();
if let Ok(devices) = host.input_devices() {
for device in devices {
if let Ok(name) = device.name() {
device_names.push(name);
}
}
}
// Build selection list: "System default" first, then each detected device
let mut items: Vec<String> = vec!["System default".to_string()];
for name in &device_names {
items.push(name.clone());
}
// Find the default selection index
let default_idx = if let Some(cur) = current {
let cur_lower = cur.to_lowercase();
device_names
.iter()
.position(|n| n.to_lowercase().contains(&cur_lower))
.map(|i| i + 1) // offset by 1 for "System default"
.unwrap_or(0)
} else {
0
};
let sel = Select::new()
.with_prompt(format!("Input device (current: {current_label})"))
.items(&items)
.default(default_idx)
.interact()?;
if sel == 0 {
Ok(None)
} else {
Ok(Some(device_names[sel - 1].clone()))
}
}
/// Prompt the user to either press a key combination or type it manually.
fn prompt_hotkey(label: &str, current: &str) -> Result<String> {
let choice = Select::new()
+2 -1
View File
@@ -103,10 +103,11 @@ pub fn run() -> Result<()> {
})
.context("Failed to spawn recorder thread")?;
let hotkey_state = Arc::clone(&shared_state);
thread::Builder::new()
.name("mouth-hotkey".into())
.spawn(move || {
hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx);
hotkey::listen(hotkey_combo, cancel_combo, hotkey_tx, hotkey_state);
})
.context("Failed to spawn hotkey thread")?;
+15 -7
View File
@@ -1,10 +1,14 @@
use anyhow::{bail, Result};
use rdev::{self, Event, EventType, Key};
use std::cell::RefCell;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, error, info};
use crate::shared_state::SharedState;
/// Events sent from the hotkey listener to the coordinator.
#[derive(Debug, Clone, Copy)]
pub enum HotkeyEvent {
@@ -380,6 +384,7 @@ pub fn listen(
hotkey: HotkeyCombination,
cancel_key: HotkeyCombination,
tx: mpsc::Sender<HotkeyEvent>,
shared_state: Arc<SharedState>,
) {
let debounce_duration = Duration::from_millis(30);
@@ -406,16 +411,19 @@ pub fn listen(
EventType::KeyPress(key) => {
s.modifier_state.update(&key, true);
// Check cancel key — swallow it
// Check cancel key — only swallow it when actively recording/transcribing
if key == cancel_key.key && s.modifier_state.all_held(&cancel_key.modifiers) {
if now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
if shared_state.is_active.load(Ordering::Acquire) {
if now.duration_since(s.last_event_time) >= debounce_duration {
s.last_event_time = now;
debug!("Cancel key pressed");
if tx.send(HotkeyEvent::Cancel).is_err() {
error!("Failed to send cancel event");
}
}
return None;
}
return None;
// Not active — let the key pass through
}
// Check hotkey — swallow it
+6
View File
@@ -1,9 +1,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::time::Instant;
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
pub struct SharedState {
pub state: RwLock<String>,
/// True when recording or transcribing — the hotkey listener uses this to
/// decide whether to swallow the cancel key.
pub is_active: AtomicBool,
pub model: String,
pub accelerator: String,
pub started_at: Instant,
@@ -13,6 +17,7 @@ impl SharedState {
pub fn new(model: String, accelerator: String) -> Self {
Self {
state: RwLock::new("idle".to_string()),
is_active: AtomicBool::new(false),
model,
accelerator,
started_at: Instant::now(),
@@ -23,6 +28,7 @@ impl SharedState {
if let Ok(mut s) = self.state.write() {
*s = state.to_string();
}
self.is_active.store(state != "idle", Ordering::Release);
}
pub fn get_state(&self) -> String {