//! Local speech-to-text, via whisper.cpp.
//!
//! The browser's own recognition ships audio to a vendor's cloud. This does
//! not: the recording is written to a temp file, handed to a whisper.cpp
//! binary on this machine, and deleted — nothing leaves the host, and no key
//! is held. It is also simply better at technical vocabulary than the browser
//! engines, which is what the reviewer is actually speaking.
//!
//! Privacy invariant: the transcript is returned to the caller and dropped.
//! It is never logged, never stored, and never sent to telemetry — the only
//! things reported are that a transcription happened and how long it took.
//!
//! Not configured is not an error. With no model on disk this answers 503 and
//! the caller falls back to the browser's recogniser.
use std::path::PathBuf;
use std::process::Stdio;
use axum::Json;
use axum::body::Bytes;
use axum::extract::State;
use jac_telemetry::Envelope;
use serde_json::{Value, json};
use tokio::process::Command;
use crate::error::ApiError;
use crate::state::SharedState;
/// Roughly four minutes of 16 kHz mono 16-bit WAV. A spoken remark that runs
/// longer than this is a monologue, and whisper would take minutes on it.
pub(crate) const MAX_AUDIO_BYTES: usize = 8 * 1024 * 1024;
/// Where whisper.cpp lives. `brew install whisper-cpp` puts it on the PATH.
fn binary() -> String {
std::env::var("WHISPER_CLI").unwrap_or_else(|_| "whisper-cli".to_owned())
}
/// The ggml model file. Required — there is no sensible default to guess.
fn model() -> Option<PathBuf> {
std::env::var_os("WHISPER_MODEL").map(PathBuf::from)
}
/// `GET /api/transcribe` — is local transcription available?
///
/// Asked once by the client, so an unconfigured host costs one quiet 200
/// rather than a failed upload per utterance.
pub(crate) async fn available() -> Json<Value> {
let model = model();
let configured = match &model {
Some(path) => tokio::fs::try_exists(path).await.unwrap_or(false),
None => false,
};
Json(json!({
"configured": configured,
"engine": "whisper.cpp",
"model": model.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy()),
"honesty": "audio is transcribed on this machine and deleted; nothing is uploaded",
}))
}
/// `POST /api/transcribe` — a WAV body in, a transcript out.
pub(crate) async fn run(
State(state): State<SharedState>,
audio: Bytes,
) -> Result<Json<Value>, ApiError> {
let Some(model) = model() else {
return Err(ApiError::Unavailable(
"no WHISPER_MODEL — falling back to the browser recogniser".to_owned(),
));
};
if !tokio::fs::try_exists(&model).await.unwrap_or(false) {
return Err(ApiError::Unavailable(format!(
"WHISPER_MODEL `{}` is not on disk",
model.display()
)));
}
if audio.is_empty() {
return Err(ApiError::invalid("no audio"));
}
if audio.len() > MAX_AUDIO_BYTES {
return Err(ApiError::invalid(format!(
"audio is at most {} MiB",
MAX_AUDIO_BYTES / (1024 * 1024)
)));
}
// A process-unique name, so concurrent transcriptions cannot collide.
let path = std::env::temp_dir().join(format!("jac-listen-{}.wav", state.mint_id()));
tokio::fs::write(&path, &audio)
.await
.map_err(|e| ApiError::internal(format!("could not stage audio: {e}")))?;
let started = std::time::Instant::now();
let output = Command::new(binary())
.arg("-m")
.arg(&model)
.arg("-f")
.arg(&path)
// Plain text on stdout: no timestamps, no progress chatter.
.args(["--no-timestamps", "--no-prints", "--language", "en"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
// The recording goes away whatever happened next.
let _ = tokio::fs::remove_file(&path).await;
let output = output.map_err(|e| {
ApiError::Unavailable(format!(
"could not run `{}`: {e} — is whisper-cpp installed?",
binary()
))
})?;
if !output.status.success() {
let why = String::from_utf8_lossy(&output.stderr);
return Err(ApiError::internal(format!(
"whisper failed: {}",
why.lines().last().unwrap_or("no output").trim()
)));
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let elapsed = started.elapsed();
// Counts and timings only — never a word of what was said.
state.telemetry.emit(
Envelope::event("jac.transcribe.local", state.now_ms())
.attr("engine", "whisper.cpp")
// Lossless for anything this surface will ever see; the casts
// are bounded by the audio cap and by whisper's own output.
.measurement("audio_bytes", f64::from(u32::try_from(audio.len()).unwrap_or(u32::MAX)))
.measurement(
"elapsed_ms",
f64::from(u32::try_from(elapsed.as_millis()).unwrap_or(u32::MAX)),
)
.measurement(
"chars",
f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX)),
),
);
Ok(Json(json!({
"text": text,
"engine": "whisper.cpp",
"elapsed_ms": elapsed.as_millis(),
})))
}