Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82181545a6 | |||
| e3f823045c | |||
| eff93e4731 | |||
| daa1542672 | |||
| 3fa4d102df | |||
| 9ad870d260 |
Generated
+1
-1
@@ -2224,7 +2224,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mouth"
|
name = "mouth"
|
||||||
version = "0.2.0"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mouth"
|
name = "mouth"
|
||||||
version = "0.2.0"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Offline speech-to-text with global hotkey and paste"
|
description = "Offline speech-to-text with global hotkey and paste"
|
||||||
license-file = "LICENSE"
|
license-file = "LICENSE"
|
||||||
|
|||||||
@@ -82,6 +82,36 @@ A small colour-coded bar appears at the top (or bottom) of your screen:
|
|||||||
|
|
||||||
Set `overlay_position: none` to disable.
|
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
|
## Building from Source
|
||||||
|
|
||||||
Requires Rust 1.75+.
|
Requires Rust 1.75+.
|
||||||
|
|||||||
+7
-1
@@ -170,7 +170,13 @@ if [ ${#BUILT[@]} -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Publish release ${TAG} to ${FORGE}? [y/N] " confirm
|
if [ -t 0 ]; then
|
||||||
|
read -rp "Publish release ${TAG} to ${FORGE}? [y/N] " confirm
|
||||||
|
else
|
||||||
|
# Non-interactive (piped/scripted) — default to yes
|
||||||
|
confirm="y"
|
||||||
|
echo "Non-interactive mode: auto-publishing release ${TAG} to ${FORGE}"
|
||||||
|
fi
|
||||||
if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then
|
if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then
|
||||||
echo "Skipped. Artifacts are in ${RELEASE_DIR}/"
|
echo "Skipped. Artifacts are in ${RELEASE_DIR}/"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
use dialoguer::{Input, Select};
|
use dialoguer::{Input, Select};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -105,6 +106,8 @@ pub fn interactive() -> Result<()> {
|
|||||||
.interact()?;
|
.interact()?;
|
||||||
config.audio_feedback = feedback_idx == 0;
|
config.audio_feedback = feedback_idx == 0;
|
||||||
|
|
||||||
|
config.input_device = prompt_input_device(config.input_device.as_deref())?;
|
||||||
|
|
||||||
let vad_idx = Select::new()
|
let vad_idx = Select::new()
|
||||||
.with_prompt("VAD (voice activity detection)")
|
.with_prompt("VAD (voice activity detection)")
|
||||||
.items(&["enabled", "disabled"])
|
.items(&["enabled", "disabled"])
|
||||||
@@ -122,6 +125,52 @@ pub fn interactive() -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Prompt the user to either press a key combination or type it manually.
|
||||||
fn prompt_hotkey(label: &str, current: &str) -> Result<String> {
|
fn prompt_hotkey(label: &str, current: &str) -> Result<String> {
|
||||||
let choice = Select::new()
|
let choice = Select::new()
|
||||||
|
|||||||
+2
-17
@@ -22,10 +22,6 @@ pub fn run() -> Result<()> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Windows console window
|
|
||||||
#[cfg(windows)]
|
|
||||||
hide_console();
|
|
||||||
|
|
||||||
info!("Mouth v{} starting", env!("CARGO_PKG_VERSION"));
|
info!("Mouth v{} starting", env!("CARGO_PKG_VERSION"));
|
||||||
info!("Mode: {:?}", config.mode);
|
info!("Mode: {:?}", config.mode);
|
||||||
info!("Hotkey: {}", config.hotkey);
|
info!("Hotkey: {}", config.hotkey);
|
||||||
@@ -103,10 +99,11 @@ pub fn run() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.context("Failed to spawn recorder thread")?;
|
.context("Failed to spawn recorder thread")?;
|
||||||
|
|
||||||
|
let hotkey_state = Arc::clone(&shared_state);
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("mouth-hotkey".into())
|
.name("mouth-hotkey".into())
|
||||||
.spawn(move || {
|
.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")?;
|
.context("Failed to spawn hotkey thread")?;
|
||||||
|
|
||||||
@@ -145,15 +142,3 @@ pub fn run() -> Result<()> {
|
|||||||
ipc::cleanup();
|
ipc::cleanup();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn hide_console() {
|
|
||||||
use windows_sys::Win32::System::Console::GetConsoleWindow;
|
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_HIDE};
|
|
||||||
unsafe {
|
|
||||||
let console = GetConsoleWindow();
|
|
||||||
if !console.is_null() {
|
|
||||||
ShowWindow(console, SW_HIDE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-1
@@ -1,10 +1,14 @@
|
|||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use rdev::{self, Event, EventType, Key};
|
use rdev::{self, Event, EventType, Key};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::shared_state::SharedState;
|
||||||
|
|
||||||
/// Events sent from the hotkey listener to the coordinator.
|
/// Events sent from the hotkey listener to the coordinator.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum HotkeyEvent {
|
pub enum HotkeyEvent {
|
||||||
@@ -380,6 +384,7 @@ pub fn listen(
|
|||||||
hotkey: HotkeyCombination,
|
hotkey: HotkeyCombination,
|
||||||
cancel_key: HotkeyCombination,
|
cancel_key: HotkeyCombination,
|
||||||
tx: mpsc::Sender<HotkeyEvent>,
|
tx: mpsc::Sender<HotkeyEvent>,
|
||||||
|
shared_state: Arc<SharedState>,
|
||||||
) {
|
) {
|
||||||
let debounce_duration = Duration::from_millis(30);
|
let debounce_duration = Duration::from_millis(30);
|
||||||
|
|
||||||
@@ -406,8 +411,9 @@ pub fn listen(
|
|||||||
EventType::KeyPress(key) => {
|
EventType::KeyPress(key) => {
|
||||||
s.modifier_state.update(&key, true);
|
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 key == cancel_key.key && s.modifier_state.all_held(&cancel_key.modifiers) {
|
||||||
|
if shared_state.is_active.load(Ordering::Acquire) {
|
||||||
if now.duration_since(s.last_event_time) >= debounce_duration {
|
if now.duration_since(s.last_event_time) >= debounce_duration {
|
||||||
s.last_event_time = now;
|
s.last_event_time = now;
|
||||||
debug!("Cancel key pressed");
|
debug!("Cancel key pressed");
|
||||||
@@ -417,6 +423,8 @@ pub fn listen(
|
|||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Not active — let the key pass through
|
||||||
|
}
|
||||||
|
|
||||||
// Check hotkey — swallow it
|
// Check hotkey — swallow it
|
||||||
if key == hotkey.key && s.modifier_state.all_held(&hotkey.modifiers) {
|
if key == hotkey.key && s.modifier_state.all_held(&hotkey.modifiers) {
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::shared_state::SharedState;
|
use crate::shared_state::SharedState;
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ fn unix_listener(path: &str, shared_state: Arc<SharedState>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to serialize status: {e}");
|
tracing::warn!("Failed to serialize status: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-2
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod audio_feedback;
|
mod audio_feedback;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -48,7 +50,16 @@ enum Commands {
|
|||||||
Status,
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() {
|
||||||
|
// Attach to parent console when launched from a terminal so CLI subcommands
|
||||||
|
// can write output. Silently fails when double-clicked (no parent console).
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe {
|
||||||
|
windows_sys::Win32::System::Console::AttachConsole(
|
||||||
|
windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
@@ -57,8 +68,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
let is_daemon = matches!(cli.command, None | Some(Commands::Run));
|
||||||
|
|
||||||
match cli.command {
|
let result = match cli.command {
|
||||||
None | Some(Commands::Run) => cli::run_cmd::run(),
|
None | Some(Commands::Run) => cli::run_cmd::run(),
|
||||||
|
|
||||||
Some(Commands::Config { show, reset }) => {
|
Some(Commands::Config { show, reset }) => {
|
||||||
@@ -80,5 +92,32 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(Commands::Status) => cli::status_cmd::status(),
|
Some(Commands::Status) => cli::status_cmd::status(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
if is_daemon {
|
||||||
|
#[cfg(windows)]
|
||||||
|
show_error_dialog(&format!("Mouth failed to start:\n\n{e:#}"));
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
eprintln!("Error: {e:#}");
|
||||||
|
} else {
|
||||||
|
eprintln!("Error: {e:#}");
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn show_error_dialog(message: &str) {
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||||
|
let title: Vec<u16> = "Mouth".encode_utf16().chain(Some(0)).collect();
|
||||||
|
let msg: Vec<u16> = message.encode_utf16().chain(Some(0)).collect();
|
||||||
|
unsafe {
|
||||||
|
MessageBoxW(
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
msg.as_ptr(),
|
||||||
|
title.as_ptr(),
|
||||||
|
MB_OK | MB_ICONERROR,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
|
/// Thread-safe shared state accessible by the coordinator, IPC listener, and tray icon.
|
||||||
pub struct SharedState {
|
pub struct SharedState {
|
||||||
pub state: RwLock<String>,
|
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 model: String,
|
||||||
pub accelerator: String,
|
pub accelerator: String,
|
||||||
pub started_at: Instant,
|
pub started_at: Instant,
|
||||||
@@ -13,6 +17,7 @@ impl SharedState {
|
|||||||
pub fn new(model: String, accelerator: String) -> Self {
|
pub fn new(model: String, accelerator: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
state: RwLock::new("idle".to_string()),
|
state: RwLock::new("idle".to_string()),
|
||||||
|
is_active: AtomicBool::new(false),
|
||||||
model,
|
model,
|
||||||
accelerator,
|
accelerator,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
@@ -23,6 +28,7 @@ impl SharedState {
|
|||||||
if let Ok(mut s) = self.state.write() {
|
if let Ok(mut s) = self.state.write() {
|
||||||
*s = state.to_string();
|
*s = state.to_string();
|
||||||
}
|
}
|
||||||
|
self.is_active.store(state != "idle", Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_state(&self) -> String {
|
pub fn get_state(&self) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user