pub mod audio;
pub mod config;
pub mod history;
pub mod hotkey;
pub mod llm;
pub mod overlay;
pub mod state;
pub mod transcription;
pub mod tray;
pub mod tts;
pub mod window;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::transcription::openai_realtime_protocol::{OpenAiRealtimeProfile, TurnDetectionMode};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "lowercase")]
pub enum Command {
Toggle {
#[serde(default, skip_serializing_if = "Option::is_none")]
language: Option<String>,
},
Cancel,
Status,
Log {
#[serde(default = "default_log_limit")]
limit: usize,
},
#[serde(rename = "clear-history")]
ClearHistory,
#[serde(rename = "command")]
CommandMode,
#[serde(alias = "read")]
Speak,
}
fn default_log_limit() -> usize {
20
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum Response {
Ok { state: State },
Error { message: String },
History { entries: Vec<history::HistoryEntry> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
Idle,
Recording,
Transcribing,
Synthesizing,
Speaking,
}
impl std::fmt::Display for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
State::Idle => write!(f, "idle"),
State::Recording => write!(f, "recording"),
State::Transcribing => write!(f, "transcribing"),
State::Synthesizing => write!(f, "synthesizing"),
State::Speaking => write!(f, "speaking"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub audio: AudioConfig,
#[serde(default)]
pub input: InputConfig,
#[serde(default)]
pub deepgram: Option<DeepgramConfig>,
#[serde(default)]
pub groq: Option<GroqConfig>,
#[serde(default)]
pub openai: Option<OpenAiConfig>,
#[serde(default, rename = "local-whisper", alias = "local")]
pub local_whisper: Option<LocalWhisperConfig>,
#[serde(default, rename = "local-vosk")]
pub local_vosk: Option<LocalVoskConfig>,
#[serde(default, rename = "local-parakeet")]
pub local_parakeet: Option<LocalParakeetConfig>,
#[serde(default, rename = "asr-sidecar", alias = "asr", alias = "vibevoice")]
pub asr_sidecar: Option<AsrSidecarConfig>,
#[serde(default, rename = "openai-compatible-realtime")]
pub openai_compatible_realtime: Option<OpenAiCompatibleRealtimeConfig>,
#[serde(default)]
pub llm: Option<llm::LlmConfig>,
#[serde(default)]
pub tts: Option<TtsConfig>,
#[serde(default)]
pub hotkeys: Option<HotkeyConfig>,
#[serde(default)]
pub overlay: Option<OverlayConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HotkeyConfig {
pub toggle: Option<String>,
pub cancel: Option<String>,
pub command: Option<String>,
#[serde(alias = "read")]
pub speak: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayConfig {
#[serde(default = "default_overlay_theme")]
pub theme: String,
#[serde(default = "default_overlay_width")]
pub width: u32,
#[serde(default = "default_overlay_height")]
pub height: u32,
#[serde(default)]
pub colors: Option<OverlayColors>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayColors {
pub background: Option<String>,
pub ring: Option<String>,
pub recording: Option<String>,
pub transcribing: Option<String>,
pub speaking: Option<String>,
pub glow: Option<String>,
}
fn default_overlay_theme() -> String {
"carbon".to_string()
}
fn default_overlay_width() -> u32 {
100
}
fn default_overlay_height() -> u32 {
40
}
impl Default for OverlayConfig {
fn default() -> Self {
Self {
theme: default_overlay_theme(),
width: default_overlay_width(),
height: default_overlay_height(),
colors: None,
}
}
}
impl OverlayConfig {
pub fn clamped_width(&self) -> u32 {
self.width.clamp(90, 120)
}
pub fn clamped_height(&self) -> u32 {
self.height.clamp(36, 48)
}
}
pub fn parse_hex_color(s: &str) -> Option<[u8; 4]> {
let s = s.trim().trim_start_matches('#');
let (r, g, b, a) = match s.len() {
3 => {
let r = u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?;
(r, g, b, 255u8)
}
6 => {
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
(r, g, b, 255u8)
}
8 => {
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
let a = u8::from_str_radix(&s[6..8], 16).ok()?;
(r, g, b, a)
}
_ => return None,
};
Some([a, r, g, b])
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_silence_timeout")]
pub silence_timeout_ms: u64,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default)]
pub remove_filler_words: bool,
#[serde(default)]
pub filler_words: Vec<String>,
#[serde(default)]
pub audio_feedback: bool,
#[serde(default = "default_audio_feedback_volume")]
pub audio_feedback_volume: f32,
#[serde(default)]
pub vocabulary: Vec<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default = "default_true")]
pub tray: bool,
#[serde(default)]
pub overlay: bool,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
backend: default_backend(),
language: default_language(),
silence_timeout_ms: default_silence_timeout(),
notify: true,
remove_filler_words: false,
filler_words: Vec::new(),
audio_feedback: false,
audio_feedback_volume: default_audio_feedback_volume(),
vocabulary: Vec::new(),
prompt: None,
tray: true,
overlay: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
#[serde(default = "default_device")]
pub device: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum InjectorBackend {
#[default]
Auto,
Uinput,
WaylandVk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputConfig {
#[serde(default = "default_key_delay_ms")]
pub key_delay_ms: u64,
#[serde(default)]
pub backend: InjectorBackend,
}
impl Default for InputConfig {
fn default() -> Self {
Self {
key_delay_ms: default_key_delay_ms(),
backend: InjectorBackend::default(),
}
}
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device: default_device(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeepgramConfig {
pub api_key: String,
#[serde(default = "default_deepgram_model")]
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroqConfig {
pub api_key: String,
#[serde(default = "default_groq_model")]
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiConfig {
pub api_key: String,
#[serde(default = "default_openai_model")]
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_tts_backend")]
pub backend: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub voice: Option<String>,
#[serde(default = "default_tts_response_format")]
pub response_format: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
impl Default for TtsConfig {
fn default() -> Self {
Self {
enabled: false,
backend: default_tts_backend(),
model: None,
voice: None,
response_format: default_tts_response_format(),
api_key: None,
url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalWhisperConfig {
pub model_path: String,
#[serde(default = "default_local_whisper_segmentation")]
pub segmentation: String,
#[serde(default = "default_phrase_silence_ms")]
pub phrase_silence_ms: u64,
}
impl LocalWhisperConfig {
pub fn new(model_path: String) -> Self {
Self {
model_path,
segmentation: default_local_whisper_segmentation(),
phrase_silence_ms: default_phrase_silence_ms(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalVoskConfig {
pub model_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalParakeetConfig {
pub model_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AsrSidecarConfig {
#[serde(default = "default_asr_sidecar_url")]
pub url: String,
#[serde(default = "default_asr_sidecar_model")]
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiCompatibleRealtimeConfig {
pub url: String,
#[serde(default = "default_openai_compatible_realtime_model")]
pub model: String,
#[serde(default = "default_openai_compatible_realtime_profile")]
pub profile: String,
#[serde(default = "default_openai_compatible_realtime_turn_detection")]
pub turn_detection: String,
#[serde(default)]
pub api_key: Option<String>,
}
fn default_backend() -> String {
"groq".to_string()
}
fn default_language() -> String {
"en".to_string()
}
fn default_silence_timeout() -> u64 {
2000
}
fn default_true() -> bool {
true
}
fn default_device() -> String {
"default".to_string()
}
fn default_audio_feedback_volume() -> f32 {
0.5
}
fn default_key_delay_ms() -> u64 {
2
}
fn default_deepgram_model() -> String {
"nova-3".to_string()
}
fn default_groq_model() -> String {
"whisper-large-v3-turbo".to_string()
}
fn default_openai_model() -> String {
"gpt-4o-mini-transcribe".to_string()
}
fn default_tts_backend() -> String {
"groq".to_string()
}
fn default_tts_response_format() -> String {
"wav".to_string()
}
fn default_local_whisper_segmentation() -> String {
"silence".to_string()
}
fn default_phrase_silence_ms() -> u64 {
400
}
fn default_asr_sidecar_url() -> String {
"http://127.0.0.1:8765/transcribe".to_string()
}
fn default_asr_sidecar_model() -> String {
"microsoft/VibeVoice-ASR-HF".to_string()
}
fn default_openai_compatible_realtime_model() -> String {
"Whisper-Tiny".to_string()
}
fn default_openai_compatible_realtime_profile() -> String {
"lemonade".to_string()
}
fn default_openai_compatible_realtime_turn_detection() -> String {
"server-vad".to_string()
}
#[derive(Debug)]
pub enum RestartOutcome {
Restarted,
NoSystemdUnit,
Failed,
}
pub fn restart_daemon_via_systemd() -> RestartOutcome {
if !has_systemd_unit() {
return RestartOutcome::NoSystemdUnit;
}
let status = std::process::Command::new("systemctl")
.args(["--user", "restart", "whisrs.service"])
.status();
match status {
Ok(s) if s.success() => RestartOutcome::Restarted,
_ => RestartOutcome::Failed,
}
}
fn has_systemd_unit() -> bool {
let Ok(output) = std::process::Command::new("systemctl")
.args(["--user", "is-enabled", "whisrs.service"])
.output()
else {
return false;
};
if output.status.success() {
return true;
}
let Ok(output) = std::process::Command::new("systemctl")
.args(["--user", "list-unit-files", "whisrs.service"])
.output()
else {
return false;
};
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("whisrs.service")
}
pub fn socket_path() -> PathBuf {
if let Some(runtime_dir) = dirs::runtime_dir() {
runtime_dir.join("whisrs.sock")
} else {
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/whisrs-{uid}.sock"))
}
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("whisrs")
.join("config.toml")
}
pub fn validate_language_override(lang: &str) -> Result<String, String> {
let trimmed = lang.trim();
let invalid = || {
format!(
"invalid language '{trimmed}': use an ISO 639 code like 'en', \
optionally with a region ('en-US'), or 'auto'"
)
};
if trimmed.eq_ignore_ascii_case("auto") || trimmed.eq_ignore_ascii_case("multi") {
return Ok(trimmed.to_ascii_lowercase());
}
let mut subtags = trimmed.split(['-', '_']);
let primary = subtags.next().unwrap_or("");
if !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) {
return Err(invalid());
}
let mut normalized = primary.to_ascii_lowercase();
for subtag in subtags {
if !(2..=8).contains(&subtag.len()) || !subtag.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(invalid());
}
normalized.push('-');
if subtag.len() == 2 {
normalized.push_str(&subtag.to_ascii_uppercase());
} else {
normalized.push_str(subtag);
}
}
Ok(normalized)
}
#[derive(Debug, Clone)]
pub struct ConfigWarning {
pub message: String,
}
impl std::fmt::Display for ConfigWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl Config {
pub fn validate(&self) -> Result<Vec<ConfigWarning>, WhisrsError> {
let mut warnings = Vec::new();
let backend = self.general.backend.as_str();
match backend {
"deepgram" | "deepgram-streaming" => {
let has_config_key = self
.deepgram
.as_ref()
.map(|d| !d.api_key.is_empty())
.unwrap_or(false);
let has_env_key = std::env::var("WHISRS_DEEPGRAM_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
if !has_config_key && !has_env_key {
return Err(WhisrsError::Config(
"Deepgram backend selected but no API key configured.\n\
Set WHISRS_DEEPGRAM_API_KEY or add [deepgram] api_key to config.toml.\n\
Run 'whisrs setup' to get started."
.to_string(),
));
}
}
"groq" => {
let has_config_key = self
.groq
.as_ref()
.map(|g| !g.api_key.is_empty())
.unwrap_or(false);
let has_env_key = std::env::var("WHISRS_GROQ_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
if !has_config_key && !has_env_key {
return Err(WhisrsError::Config(
"Groq backend selected but no API key configured.\n\
Set WHISRS_GROQ_API_KEY or add [groq] api_key to config.toml.\n\
Run 'whisrs setup' to get started."
.to_string(),
));
}
}
"openai" | "openai-realtime" => {
let has_config_key = self
.openai
.as_ref()
.map(|o| !o.api_key.is_empty())
.unwrap_or(false);
let has_env_key = std::env::var("WHISRS_OPENAI_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
if !has_config_key && !has_env_key {
return Err(WhisrsError::Config(
"OpenAI backend selected but no API key configured.\n\
Set WHISRS_OPENAI_API_KEY or add [openai] api_key to config.toml.\n\
Run 'whisrs setup' to get started."
.to_string(),
));
}
}
"local-whisper" | "local" => {
let model_path = self
.local_whisper
.as_ref()
.map(|l| l.model_path.clone())
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("~/.local/share"))
.join("whisrs/models/ggml-base.en.bin")
.to_string_lossy()
.to_string()
});
if !std::path::Path::new(&model_path).exists() {
warnings.push(ConfigWarning {
message: format!(
"Local whisper backend selected but model file not found: {model_path}\n\
Run 'whisrs setup' to download a model."
),
});
}
}
"local-vosk" => {
let model_path = self
.local_vosk
.as_ref()
.map(|l| l.model_path.clone())
.unwrap_or_default();
if model_path.is_empty() || !std::path::Path::new(&model_path).exists() {
warnings.push(ConfigWarning {
message: "Vosk backend selected but model directory not found.\n\
Run 'whisrs setup' to download a model."
.to_string(),
});
}
}
"local-parakeet" => {
let model_path = self
.local_parakeet
.as_ref()
.map(|l| l.model_path.clone())
.unwrap_or_default();
if model_path.is_empty() || !std::path::Path::new(&model_path).exists() {
warnings.push(ConfigWarning {
message: "Parakeet backend selected but model directory not found.\n\
Run 'whisrs setup' to download a model."
.to_string(),
});
}
}
"asr-sidecar" | "asr" | "vibevoice" => {
let url = self
.asr_sidecar
.as_ref()
.map(|v| v.url.trim())
.unwrap_or("");
if url.is_empty() {
return Err(WhisrsError::Config(
"ASR sidecar backend selected but no sidecar URL configured.\n\
Add [asr-sidecar] url to config.toml."
.to_string(),
));
}
}
"openai-compatible-realtime" => {
let config = self.openai_compatible_realtime.as_ref().ok_or_else(|| {
WhisrsError::Config(
"OpenAI-compatible realtime backend selected but no config section found.\n\
Add [openai-compatible-realtime] to config.toml."
.to_string(),
)
})?;
let url = config.url.trim();
if url.is_empty() {
return Err(WhisrsError::Config(
"OpenAI-compatible realtime backend selected but no WebSocket URL configured.\n\
Add [openai-compatible-realtime] url to config.toml."
.to_string(),
));
}
let parsed_url = reqwest::Url::parse(url).map_err(|e| {
WhisrsError::Config(format!("OpenAI-compatible realtime URL is invalid: {e}"))
})?;
match parsed_url.scheme() {
"ws" | "wss" => {}
scheme => {
return Err(WhisrsError::Config(format!(
"OpenAI-compatible realtime URL must use ws:// or wss://, got {scheme}://"
)));
}
}
if config.model.trim().is_empty() {
return Err(WhisrsError::Config(
"OpenAI-compatible realtime backend selected but model is empty.\n\
Set [openai-compatible-realtime] model in config.toml."
.to_string(),
));
}
OpenAiRealtimeProfile::parse(config.profile.trim()).map_err(|e| {
WhisrsError::Config(format!(
"OpenAI-compatible realtime profile is invalid: {e}"
))
})?;
if config.profile.trim() != "lemonade" {
return Err(WhisrsError::Config(
"OpenAI-compatible realtime backend currently supports only profile 'lemonade'."
.to_string(),
));
}
TurnDetectionMode::parse(config.turn_detection.trim()).map_err(|e| {
WhisrsError::Config(format!(
"OpenAI-compatible realtime turn detection is invalid: {e}"
))
})?;
}
other => {
return Err(WhisrsError::Config(format!(
"Unknown backend '{other}'. Valid options: deepgram, deepgram-streaming, \
groq, openai, openai-realtime, openai-compatible-realtime, \
local-whisper, local-vosk, local-parakeet, asr-sidecar"
)));
}
}
if self.general.silence_timeout_ms == 0 {
warnings.push(ConfigWarning {
message: "silence_timeout_ms is 0 — auto-stop is effectively disabled".to_string(),
});
}
Ok(warnings)
}
pub fn has_any_backend_configured(&self) -> bool {
let has_deepgram = self
.deepgram
.as_ref()
.map(|d| !d.api_key.is_empty())
.unwrap_or(false)
|| std::env::var("WHISRS_DEEPGRAM_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
let has_groq = self
.groq
.as_ref()
.map(|g| !g.api_key.is_empty())
.unwrap_or(false)
|| std::env::var("WHISRS_GROQ_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
let has_openai = self
.openai
.as_ref()
.map(|o| !o.api_key.is_empty())
.unwrap_or(false)
|| std::env::var("WHISRS_OPENAI_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false);
let has_local = self.local_whisper.is_some()
|| self.local_vosk.is_some()
|| self.local_parakeet.is_some();
let has_asr_sidecar = self
.asr_sidecar
.as_ref()
.map(|v| !v.url.trim().is_empty())
.unwrap_or(false);
let has_openai_compatible_realtime = self
.openai_compatible_realtime
.as_ref()
.map(|v| !v.url.trim().is_empty())
.unwrap_or(false);
has_deepgram
|| has_groq
|| has_openai
|| has_local
|| has_asr_sidecar
|| has_openai_compatible_realtime
}
}
#[derive(Debug, thiserror::Error)]
pub enum WhisrsError {
#[error("IPC error: {0}")]
Ipc(String),
#[error("configuration error: {0}")]
Config(String),
#[error("audio error: {0}")]
Audio(String),
#[error("transcription error: {0}")]
Transcription(String),
#[error("invalid state transition from {from} on {action}")]
InvalidTransition { from: State, action: String },
}
pub fn encode_message<T: Serialize>(msg: &T) -> anyhow::Result<Vec<u8>> {
let json = serde_json::to_vec(msg)?;
let len = (json.len() as u32).to_be_bytes();
let mut buf = Vec::with_capacity(4 + json.len());
buf.extend_from_slice(&len);
buf.extend_from_slice(&json);
Ok(buf)
}
pub async fn read_message<T: serde::de::DeserializeOwned>(
reader: &mut (impl tokio::io::AsyncReadExt + Unpin),
) -> anyhow::Result<T> {
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf) as usize;
anyhow::ensure!(len <= 1024 * 1024, "message too large: {len} bytes");
let mut body = vec![0u8; len];
reader.read_exact(&mut body).await?;
Ok(serde_json::from_slice(&body)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_serialization_roundtrip() {
let cmd = Command::Toggle { language: None };
let json = serde_json::to_string(&cmd).unwrap();
let parsed: Command = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, Command::Toggle { language: None }));
}
#[test]
fn toggle_language_serialization_roundtrip() {
let cmd = Command::Toggle {
language: Some("pl".to_string()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, r#"{"cmd":"toggle","language":"pl"}"#);
let parsed: Command = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, Command::Toggle { language: Some(l) } if l == "pl"));
}
#[test]
fn language_override_accepts_iso_codes() {
assert_eq!(validate_language_override("en").unwrap(), "en");
assert_eq!(validate_language_override("PL").unwrap(), "pl");
assert_eq!(validate_language_override("auto").unwrap(), "auto");
}
#[test]
fn language_override_accepts_region_tags() {
assert_eq!(validate_language_override("en-US").unwrap(), "en-US");
assert_eq!(validate_language_override("pt-br").unwrap(), "pt-BR");
assert_eq!(validate_language_override("en_US").unwrap(), "en-US");
}
#[test]
fn language_override_accepts_backend_specific_codes() {
assert_eq!(validate_language_override("multi").unwrap(), "multi");
assert_eq!(validate_language_override("yue").unwrap(), "yue");
assert_eq!(validate_language_override("zh-Hans").unwrap(), "zh-Hans");
}
#[test]
fn language_override_rejects_clearly_invalid() {
assert!(validate_language_override("").is_err());
assert!(validate_language_override("english").is_err());
assert!(validate_language_override("123").is_err());
assert!(validate_language_override("e").is_err());
assert!(validate_language_override("en-").is_err());
}
#[test]
fn response_serialization_roundtrip() {
let resp = Response::Ok {
state: State::Recording,
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert!(matches!(
parsed,
Response::Ok {
state: State::Recording
}
));
}
#[test]
fn command_json_format() {
let cmd = Command::Toggle { language: None };
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, r#"{"cmd":"toggle"}"#);
}
#[test]
fn speak_command_serializes_lowercase() {
let cmd = Command::Speak;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, r#"{"cmd":"speak"}"#);
}
#[test]
fn speak_command_roundtrip() {
let parsed: Command = serde_json::from_str(r#"{"cmd":"speak"}"#).unwrap();
assert!(matches!(parsed, Command::Speak));
}
#[test]
fn speak_command_read_alias() {
let parsed: Command = serde_json::from_str(r#"{"cmd":"read"}"#).unwrap();
assert!(matches!(parsed, Command::Speak));
}
#[test]
fn config_tts_section_roundtrip() {
let config: Config = toml::from_str(
r#"
[general]
backend = "groq"
[tts]
enabled = true
model = "canopylabs/orpheus-v1-english"
voice = "autumn"
response_format = "wav"
"#,
)
.unwrap();
let tts = config.tts.as_ref().expect("tts section parsed");
assert!(tts.enabled);
assert_eq!(tts.model.as_deref(), Some("canopylabs/orpheus-v1-english"));
assert_eq!(tts.voice.as_deref(), Some("autumn"));
assert_eq!(tts.response_format, "wav");
assert!(tts.api_key.is_none());
let serialized = toml::to_string(&config).unwrap();
let reparsed: Config = toml::from_str(&serialized).unwrap();
assert!(reparsed.tts.unwrap().enabled);
}
#[test]
fn config_tts_backend_and_url_roundtrip() {
let config: Config = toml::from_str(
r#"
[general]
backend = "groq"
[tts]
enabled = true
backend = "tts-sidecar"
model = "kokoro"
voice = "af_heart"
url = "http://127.0.0.1:8880/v1/audio/speech"
"#,
)
.unwrap();
let tts = config.tts.as_ref().expect("tts section parsed");
assert_eq!(tts.backend, "tts-sidecar");
assert_eq!(
tts.url.as_deref(),
Some("http://127.0.0.1:8880/v1/audio/speech")
);
let serialized = toml::to_string(&config).unwrap();
let reparsed: Config = toml::from_str(&serialized).unwrap();
let tts = reparsed.tts.unwrap();
assert_eq!(tts.backend, "tts-sidecar");
assert_eq!(
tts.url.as_deref(),
Some("http://127.0.0.1:8880/v1/audio/speech")
);
}
#[test]
fn config_tts_backend_defaults_to_groq() {
let config: Config = toml::from_str(
r#"
[general]
backend = "groq"
[tts]
enabled = true
"#,
)
.unwrap();
assert_eq!(config.tts.unwrap().backend, "groq");
}
#[test]
fn config_tts_defaults_when_minimal() {
let config: Config = toml::from_str(
r#"
[general]
backend = "groq"
[tts]
enabled = true
"#,
)
.unwrap();
let tts = config.tts.unwrap();
assert!(tts.enabled);
assert!(tts.model.is_none());
assert!(tts.voice.is_none());
assert_eq!(tts.response_format, "wav");
}
#[test]
fn config_without_tts_is_none() {
let config: Config = toml::from_str(
r#"
[general]
backend = "groq"
"#,
)
.unwrap();
assert!(config.tts.is_none());
}
#[test]
fn hotkey_speak_read_alias() {
let hotkeys: HotkeyConfig = toml::from_str(r#"read = "Super+Shift+R""#).unwrap();
assert_eq!(hotkeys.speak.as_deref(), Some("Super+Shift+R"));
}
#[test]
fn response_json_format() {
let resp = Response::Ok { state: State::Idle };
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"status":"ok","state":"idle"}"#);
let err = Response::Error {
message: "no microphone found".to_string(),
};
let json = serde_json::to_string(&err).unwrap();
assert_eq!(
json,
r#"{"status":"error","message":"no microphone found"}"#
);
}
#[test]
fn state_display() {
assert_eq!(State::Idle.to_string(), "idle");
assert_eq!(State::Recording.to_string(), "recording");
assert_eq!(State::Transcribing.to_string(), "transcribing");
assert_eq!(State::Synthesizing.to_string(), "synthesizing");
assert_eq!(State::Speaking.to_string(), "speaking");
}
#[test]
fn state_serde_wire_format() {
assert_eq!(
serde_json::to_string(&State::Synthesizing).unwrap(),
r#""synthesizing""#
);
assert_eq!(
serde_json::to_string(&State::Speaking).unwrap(),
r#""speaking""#
);
let parsed: State = serde_json::from_str(r#""speaking""#).unwrap();
assert_eq!(parsed, State::Speaking);
}
#[test]
fn socket_path_is_not_empty() {
let path = socket_path();
assert!(!path.as_os_str().is_empty());
}
#[tokio::test]
async fn encode_decode_roundtrip() {
let cmd = Command::Status;
let encoded = encode_message(&cmd).unwrap();
let mut cursor = std::io::Cursor::new(encoded);
let decoded: Command = read_message(&mut cursor).await.unwrap();
assert!(matches!(decoded, Command::Status));
}
#[tokio::test]
async fn ipc_client_server_roundtrip() {
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
let dir = std::env::temp_dir().join(format!("whisrs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let sock_path = dir.join("test.sock");
let _ = std::fs::remove_file(&sock_path);
let listener = UnixListener::bind(&sock_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut reader, mut writer) = stream.into_split();
let cmd: Command = read_message(&mut reader).await.unwrap();
assert!(matches!(cmd, Command::Toggle { language: None }));
let response = Response::Ok {
state: State::Recording,
};
let encoded = encode_message(&response).unwrap();
writer.write_all(&encoded).await.unwrap();
writer.shutdown().await.unwrap();
});
let stream = UnixStream::connect(&sock_path).await.unwrap();
let (mut reader, mut writer) = stream.into_split();
let cmd = Command::Toggle { language: None };
let encoded = encode_message(&cmd).unwrap();
writer.write_all(&encoded).await.unwrap();
writer.shutdown().await.unwrap();
let response: Response = read_message(&mut reader).await.unwrap();
assert!(matches!(
response,
Response::Ok {
state: State::Recording
}
));
server.await.unwrap();
let _ = std::fs::remove_file(&sock_path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn injector_backend_toml_roundtrip() {
for (variant, name) in [
(InjectorBackend::Auto, "auto"),
(InjectorBackend::Uinput, "uinput"),
(InjectorBackend::WaylandVk, "wayland-vk"),
] {
#[derive(Serialize, Deserialize)]
struct Wrap {
backend: InjectorBackend,
}
let toml_str = toml::to_string(&Wrap { backend: variant }).unwrap();
assert_eq!(toml_str.trim(), format!("backend = \"{name}\""));
let parsed: Wrap = toml::from_str(&format!("backend = \"{name}\"")).unwrap();
assert_eq!(parsed.backend, variant);
}
}
#[test]
fn input_config_backend_defaults_to_auto_when_absent() {
let input: InputConfig = toml::from_str(
r#"
key_delay_ms = 5
"#,
)
.unwrap();
assert_eq!(input.backend, InjectorBackend::Auto);
let input: InputConfig = toml::from_str(
r#"
backend = "wayland-vk"
"#,
)
.unwrap();
assert_eq!(input.backend, InjectorBackend::WaylandVk);
}
#[test]
fn config_input_backend_back_compat() {
let config: Config = toml::from_str(
r#"
[general]
backend = "local-whisper"
[audio]
device = "default"
[input]
key_delay_ms = 8
"#,
)
.unwrap();
assert_eq!(config.input.backend, InjectorBackend::Auto);
}
#[test]
fn config_validate_unknown_backend() {
let config = Config {
general: GeneralConfig {
backend: "nonexistent".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: None,
llm: None,
tts: None,
hotkeys: None,
overlay: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("Unknown backend"));
assert!(err.to_string().contains("openai-compatible-realtime"));
}
#[test]
fn config_defaults_overlay_off_for_old_configs() {
let config: Config = toml::from_str(
r#"
[general]
backend = "local-whisper"
[audio]
device = "default"
"#,
)
.unwrap();
assert!(!config.general.overlay);
}
#[test]
fn config_validate_groq_no_key() {
std::env::remove_var("WHISRS_GROQ_API_KEY");
let config = Config {
general: GeneralConfig {
backend: "groq".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: None,
llm: None,
tts: None,
hotkeys: None,
overlay: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("no API key"));
}
#[test]
fn config_validate_groq_with_key() {
let config = Config {
general: GeneralConfig {
backend: "groq".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: Some(GroqConfig {
api_key: "test-key".to_string(),
model: "whisper-large-v3-turbo".to_string(),
}),
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: None,
llm: None,
tts: None,
hotkeys: None,
overlay: None,
};
let result = config.validate();
assert!(result.is_ok());
}
#[test]
fn config_parse_asr_sidecar_defaults() {
let config: Config = toml::from_str(
r#"
[general]
backend = "asr-sidecar"
[asr-sidecar]
"#,
)
.unwrap();
let asr_sidecar = config.asr_sidecar.unwrap();
assert_eq!(asr_sidecar.url, "http://127.0.0.1:8765/transcribe");
assert_eq!(asr_sidecar.model, "microsoft/VibeVoice-ASR-HF");
}
#[test]
fn config_validate_asr_sidecar_with_url() {
let config = Config {
general: GeneralConfig {
backend: "asr-sidecar".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: Some(AsrSidecarConfig {
url: "http://127.0.0.1:8765/transcribe".to_string(),
model: "microsoft/VibeVoice-ASR-HF".to_string(),
}),
openai_compatible_realtime: None,
llm: None,
tts: None,
hotkeys: None,
overlay: None,
};
assert!(config.validate().is_ok());
}
#[test]
fn config_parse_vibevoice_alias() {
let config: Config = toml::from_str(
r#"
[general]
backend = "vibevoice"
[vibevoice]
url = "http://127.0.0.1:8765/transcribe"
model = "microsoft/VibeVoice-ASR-HF"
"#,
)
.unwrap();
assert!(config.validate().is_ok());
assert!(config.asr_sidecar.is_some());
}
#[test]
fn config_validate_zero_silence_timeout() {
let config = Config {
general: GeneralConfig {
backend: "groq".to_string(),
silence_timeout_ms: 0,
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: Some(GroqConfig {
api_key: "test-key".to_string(),
model: "whisper-large-v3-turbo".to_string(),
}),
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: None,
llm: None,
tts: None,
hotkeys: None,
overlay: None,
};
let warnings = config.validate().unwrap();
assert!(warnings
.iter()
.any(|w| w.message.contains("silence_timeout_ms")));
}
#[test]
fn config_parse_openai_compatible_realtime_defaults() {
let config: Config = toml::from_str(
r#"
[general]
backend = "openai-compatible-realtime"
[openai-compatible-realtime]
url = "ws://localhost:1234/realtime"
"#,
)
.unwrap();
let realtime = config.openai_compatible_realtime.unwrap();
assert_eq!(realtime.url, "ws://localhost:1234/realtime");
assert_eq!(realtime.model, "Whisper-Tiny");
assert_eq!(realtime.profile, "lemonade");
assert_eq!(realtime.turn_detection, "server-vad");
assert!(realtime.api_key.is_none());
}
#[test]
fn config_validate_openai_compatible_realtime_with_valid_config() {
let config = Config {
general: GeneralConfig {
backend: "openai-compatible-realtime".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: "ws://localhost:1234/realtime".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "lemonade".to_string(),
turn_detection: "server-vad".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
assert!(config.validate().is_ok());
}
#[test]
fn config_validate_openai_compatible_realtime_rejects_missing_url() {
let config = Config {
general: GeneralConfig {
backend: "openai-compatible-realtime".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: " ".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "lemonade".to_string(),
turn_detection: "server-vad".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("WebSocket URL"));
}
#[test]
fn config_validate_openai_compatible_realtime_rejects_non_websocket_url() {
let config = Config {
general: GeneralConfig {
backend: "openai-compatible-realtime".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: "http://localhost:1234/realtime".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "lemonade".to_string(),
turn_detection: "server-vad".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("ws:// or wss://"));
}
#[test]
fn config_validate_openai_compatible_realtime_rejects_unknown_profile() {
let config = Config {
general: GeneralConfig {
backend: "openai-compatible-realtime".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: "ws://localhost:1234/realtime".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "bogus".to_string(),
turn_detection: "server-vad".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("profile is invalid"));
}
#[test]
fn config_validate_openai_compatible_realtime_rejects_unsupported_turn_detection() {
let config = Config {
general: GeneralConfig {
backend: "openai-compatible-realtime".to_string(),
..Default::default()
},
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: "ws://localhost:1234/realtime".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "lemonade".to_string(),
turn_detection: "bogus".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("turn detection is invalid"));
}
#[test]
fn has_any_backend_configured_counts_openai_compatible_realtime_url() {
let config = Config {
general: Default::default(),
audio: Default::default(),
input: Default::default(),
deepgram: None,
groq: None,
openai: None,
local_whisper: None,
local_vosk: None,
local_parakeet: None,
asr_sidecar: None,
openai_compatible_realtime: Some(OpenAiCompatibleRealtimeConfig {
url: "ws://localhost:1234/realtime".to_string(),
model: "Whisper-Tiny".to_string(),
profile: "lemonade".to_string(),
turn_detection: "server-vad".to_string(),
api_key: None,
}),
llm: None,
hotkeys: None,
overlay: None,
tts: None,
};
assert!(config.has_any_backend_configured());
}
#[tokio::test]
async fn ipc_error_response_roundtrip() {
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
let dir = std::env::temp_dir().join(format!("whisrs-test-err-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let sock_path = dir.join("test.sock");
let _ = std::fs::remove_file(&sock_path);
let listener = UnixListener::bind(&sock_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut reader, mut writer) = stream.into_split();
let _cmd: Command = read_message(&mut reader).await.unwrap();
let response = Response::Error {
message: "test error".to_string(),
};
let encoded = encode_message(&response).unwrap();
writer.write_all(&encoded).await.unwrap();
writer.shutdown().await.unwrap();
});
let stream = UnixStream::connect(&sock_path).await.unwrap();
let (mut reader, mut writer) = stream.into_split();
let encoded = encode_message(&Command::Cancel).unwrap();
writer.write_all(&encoded).await.unwrap();
writer.shutdown().await.unwrap();
let response: Response = read_message(&mut reader).await.unwrap();
match response {
Response::Error { message } => assert_eq!(message, "test error"),
_ => panic!("expected error response"),
}
server.await.unwrap();
let _ = std::fs::remove_file(&sock_path);
let _ = std::fs::remove_dir(&dir);
}
}