- 1
//! Offline voice engines. Each is an operator-installed executable reached by - 2
//! path, never a shell command, run with an empty environment so provider - 3
//! credentials cannot leak into it (invariant 12). Neither engine is bundled: - 4
//! an unconfigured engine fails closed rather than pretending silence is - 5
//! speech or a transcript. Callers resolve the paths ([`TRANSCRIBER_VAR`], - 6
//! [`TTS_VAR`]) through the configuration chain; this crate never reads the - 7
//! environment. - 8
use crate::{SpeakFormat, VoiceError}; - 9
use std::path::PathBuf; - 10
use std::process::Stdio; - 11
use tokio::io::{AsyncReadExt, AsyncWriteExt}; - 12
use tokio::process::Command; - 13
use tokio_util::sync::CancellationToken; - 14
- 15
const MAX_ENGINE_OUTPUT_BYTES: u64 = 16 * 1024 * 1024; - 16
- 17
/// Names the local speech-to-text executable. - 18
pub const TRANSCRIBER_VAR: &str = "VAK_LOCAL_TRANSCRIBER"; - 19
/// Names the local text-to-speech executable. - 20
pub const TTS_VAR: &str = "VAK_LOCAL_TTS"; - 21
- 22
/// Read-only readiness of one local engine for administration and doctor - 23
/// surfaces. It inspects filesystem metadata only; it never executes the - 24
/// program and never exposes inherited environment values. - 25
#[derive(Debug, Clone, serde::Serialize)] - 26
pub struct EngineReadiness { - 27
pub configured: bool, - 28
pub ready: bool, - 29
pub executable: Option<String>, - 30
pub detail: String, - 31
} - 32
- 33
pub fn engine_readiness(executable: Option<&std::path::Path>) -> EngineReadiness { - 34
let Some(path) = executable else { - 35
return EngineReadiness { - 36
configured: false, - 37
ready: false, - 38
executable: None, - 39
detail: "not configured".into(), - 40
}; - 41
}; - 42
let (ready, detail) = match std::fs::metadata(path) { - 43
Ok(meta) if meta.is_file() => (true, "executable is present".to_string()), - 44
Ok(_) => (false, "configured path is not a regular file".to_string()), - 45
Err(error) => ( - 46
false, - 47
format!("configured executable is unavailable: {error}"), - 48
), - 49
}; - 50
EngineReadiness { - 51
configured: true, - 52
ready, - 53
executable: Some(path.display().to_string()), - 54
detail, - 55
} - 56
} - 57
- 58
/// Local speech-to-text. The executable ([`TRANSCRIBER_VAR`]) receives - 59
/// encoded audio on stdin — WAV from the voice socket, the channel's own - 60
/// container otherwise — and writes the transcript to stdout. - 61
pub struct LocalTranscriber { - 62
executable: Option<PathBuf>, - 63
} - 64
- 65
impl LocalTranscriber { - 66
pub fn new(executable: Option<PathBuf>) -> Self { - 67
Self { executable } - 68
} - 69
- 70
/// The transcript, trimmed. An engine that hears nothing may print - 71
/// nothing; that is an empty transcript, not an engine failure. - 72
pub async fn transcribe( - 73
&self, - 74
audio: &[u8], - 75
cancel: &CancellationToken, - 76
) -> Result<String, VoiceError> { - 77
if audio.is_empty() { - 78
return Err(VoiceError::InvalidRequest("audio must not be empty".into())); - 79
} - 80
let Some(executable) = &self.executable else { - 81
return Err(VoiceError::Unavailable( - 82
"local transcription is unavailable: configure VAK_LOCAL_TRANSCRIBER to an executable that reads audio on stdin and writes text on stdout".into(), - 83
)); - 84
}; - 85
let output = run_engine(executable, &[], audio, cancel, "local transcription").await?; - 86
let text = String::from_utf8(output).map_err(|_| { - 87
VoiceError::Unavailable("local transcription returned non UTF-8 output".into()) - 88
})?; - 89
Ok(text.trim().to_owned()) - 90
} - 91
} - 92
- 93
/// Local text-to-speech. The executable ([`TTS_VAR`]) is invoked with the - 94
/// requested encoding as its only argument (`wav`, `pcm16`, `ogg_opus` or - 95
/// `mp3`), receives UTF-8 text on stdin and writes that encoding to stdout; - 96
/// the bytes are returned unchanged. - 97
pub struct LocalTtsSpeaker { - 98
executable: Option<PathBuf>, - 99
} - 100
- 101
impl LocalTtsSpeaker { - 102
pub fn new(executable: Option<PathBuf>) -> Self { - 103
Self { executable } - 104
} - 105
- 106
pub async fn speak( - 107
&self, - 108
text: &str, - 109
format: SpeakFormat, - 110
cancel: &CancellationToken, - 111
) -> Result<Vec<u8>, VoiceError> { - 112
if text.trim().is_empty() { - 113
return Err(VoiceError::InvalidRequest( - 114
"speech text must not be empty".into(), - 115
)); - 116
} - 117
let Some(executable) = &self.executable else { - 118
return Err(VoiceError::Unavailable("local TTS is unavailable: configure VAK_LOCAL_TTS to an executable that reads UTF-8 text on stdin and writes audio on stdout".into())); - 119
}; - 120
let audio = run_engine( - 121
executable, - 122
&[format.as_arg()], - 123
text.as_bytes(), - 124
cancel, - 125
"local TTS", - 126
) - 127
.await?; - 128
if audio.is_empty() { - 129
return Err(VoiceError::Unavailable( - 130
"local TTS returned empty audio".into(), - 131
)); - 132
} - 133
Ok(audio) - 134
} - 135
} - 136
- 137
async fn run_engine( - 138
executable: &std::path::Path, - 139
args: &[&str], - 140
input: &[u8], - 141
cancel: &CancellationToken, - 142
label: &str, - 143
) -> Result<Vec<u8>, VoiceError> { - 144
if cancel.is_cancelled() { - 145
return Err(VoiceError::Cancelled); - 146
} - 147
let mut child = Command::new(executable) - 148
.args(args) - 149
.env_clear() - 150
.stdin(Stdio::piped()) - 151
.stdout(Stdio::piped()) - 152
.stderr(Stdio::piped()) - 153
.kill_on_drop(true) - 154
.spawn() - 155
.map_err(|error| { - 156
VoiceError::Unavailable(format!("{label} engine could not start: {error}")) - 157
})?; - 158
if let Some(mut stdin) = child.stdin.take() { - 159
stdin - 160
.write_all(input) - 161
.await - 162
.map_err(|error| VoiceError::Unavailable(format!("{label} input failed: {error}")))?; - 163
} - 164
let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else { - 165
return Err(VoiceError::Unavailable(format!( - 166
"{label} did not provide output pipes" - 167
))); - 168
}; - 169
let mut output = Vec::new(); - 170
let mut diagnostics = Vec::new(); - 171
let mut stdout = stdout.take(MAX_ENGINE_OUTPUT_BYTES + 1); - 172
let mut stderr = stderr.take(4096); - 173
// Drain both pipes together: an engine that fills stderr while stdout is - 174
// unread would otherwise block forever. - 175
let read = async { - 176
tokio::try_join!( - 177
stdout.read_to_end(&mut output), - 178
stderr.read_to_end(&mut diagnostics) - 179
) - 180
}; - 181
tokio::select! { - 182
result = read => { - 183
result.map_err(|error| VoiceError::Unavailable(format!("{label} output failed: {error}")))?; - 184
} - 185
() = cancel.cancelled() => { - 186
let _ = child.kill().await; - 187
return Err(VoiceError::Cancelled); - 188
} - 189
} - 190
if output.len() as u64 > MAX_ENGINE_OUTPUT_BYTES { - 191
let _ = child.kill().await; - 192
return Err(VoiceError::InvalidRequest(format!( - 193
"{label} output exceeds 16 MiB" - 194
))); - 195
} - 196
let status = child - 197
.wait() - 198
.await - 199
.map_err(|error| VoiceError::Unavailable(format!("{label} failed: {error}")))?; - 200
if !status.success() { - 201
let detail = String::from_utf8_lossy(&diagnostics).trim().to_owned(); - 202
return Err(VoiceError::Unavailable(format!( - 203
"{label} engine exited unsuccessfully{}", - 204
if detail.is_empty() { - 205
String::new() - 206
} else { - 207
format!(": {detail}") - 208
} - 209
))); - 210
} - 211
Ok(output) - 212
} - 213
- 214
#[cfg(test)] - 215
#[allow(clippy::expect_used, clippy::unwrap_used)] - 216
mod tests { - 217
use super::*; - 218
- 219
#[cfg(unix)] - 220
fn script(name: &str, body: &str) -> PathBuf { - 221
use std::os::unix::fs::PermissionsExt; - 222
let path = std::env::temp_dir().join(format!( - 223
"vak-voice-{name}-{}-{}", - 224
std::process::id(), - 225
std::time::SystemTime::now() - 226
.duration_since(std::time::UNIX_EPOCH) - 227
.expect("clock is after epoch") - 228
.as_nanos() - 229
)); - 230
std::fs::write(&path, body).unwrap(); - 231
let mut perms = std::fs::metadata(&path).unwrap().permissions(); - 232
perms.set_mode(0o700); - 233
std::fs::set_permissions(&path, perms).unwrap(); - 234
path - 235
} - 236
- 237
#[tokio::test] - 238
async fn transcription_fails_closed_without_an_installed_engine() { - 239
let result = LocalTranscriber::new(None) - 240
.transcribe(&[0, 0], &CancellationToken::new()) - 241
.await; - 242
assert!(matches!(result, Err(VoiceError::Unavailable(_)))); - 243
} - 244
- 245
#[cfg(unix)] - 246
#[tokio::test] - 247
async fn local_engine_receives_audio_without_provider_secrets() { - 248
let path = script( - 249
"stt", - 250
"#!/bin/sh\nif [ -n \"$GEMINI_API_KEY\" ] || [ -n \"$OPENAI_API_KEY\" ]; then exit 9; fi\ncat >/dev/null\nprintf 'offline transcript\\n'\n", - 251
); - 252
let result = LocalTranscriber::new(Some(path.clone())) - 253
.transcribe(&[1, 2, 3], &CancellationToken::new()) - 254
.await; - 255
let _ = std::fs::remove_file(&path); - 256
assert_eq!(result.unwrap(), "offline transcript"); - 257
} - 258
- 259
#[cfg(unix)] - 260
#[tokio::test] - 261
async fn an_engine_that_hears_nothing_returns_an_empty_transcript() { - 262
let path = script("stt-empty", "#!/bin/sh\ncat >/dev/null\n"); - 263
let result = LocalTranscriber::new(Some(path.clone())) - 264
.transcribe(&[1, 2], &CancellationToken::new()) - 265
.await; - 266
let _ = std::fs::remove_file(&path); - 267
assert_eq!(result.unwrap(), ""); - 268
} - 269
- 270
#[cfg(unix)] - 271
#[tokio::test] - 272
async fn a_failing_engine_reports_its_stderr() { - 273
let path = script( - 274
"stt-fail", - 275
"#!/bin/sh\ncat >/dev/null\necho 'model missing' >&2\nexit 3\n", - 276
); - 277
let result = LocalTranscriber::new(Some(path.clone())) - 278
.transcribe(&[1, 2], &CancellationToken::new()) - 279
.await; - 280
let _ = std::fs::remove_file(&path); - 281
let error = result.unwrap_err().to_string(); - 282
assert!(error.contains("model missing"), "{error}"); - 283
} - 284
- 285
#[tokio::test] - 286
async fn local_tts_fails_closed_without_an_installed_engine() { - 287
let result = LocalTtsSpeaker::new(Some("/definitely/missing/vak-tts".into())) - 288
.speak("hello", SpeakFormat::Wav, &CancellationToken::new()) - 289
.await; - 290
assert!(matches!(result, Err(VoiceError::Unavailable(_)))); - 291
} - 292
- 293
#[cfg(unix)] - 294
#[tokio::test] - 295
async fn local_tts_receives_text_and_returns_audio() { - 296
let path = script( - 297
"tts", - 298
"#!/bin/sh\n[ \"$1\" = mp3 ] || exit 4\ncat >/dev/null\nprintf 'audio-bytes'\n", - 299
); - 300
let audio = LocalTtsSpeaker::new(Some(path.clone())) - 301
.speak("hello", SpeakFormat::Mp3, &CancellationToken::new()) - 302
.await; - 303
let _ = std::fs::remove_file(&path); - 304
assert_eq!(audio.unwrap(), b"audio-bytes"); - 305
} - 306
} - 307
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.