jacquardSnapshot

← snapshot

5395 bytes
//! 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(),
    })))
}