This commit is contained in:
Omar Sobh
2026-06-15 21:51:23 -07:00
parent 1f8beb6fea
commit 3f8d2b9d51
73 changed files with 13369 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
models/
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "transcription-svc"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
anyhow = "1"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
whisper-rs = "0.14"
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Fetch a ggml Whisper model for the transcription service.
# Usage: ./scripts/fetch-model.sh [model-name] (default: tiny.en)
set -euo pipefail
MODEL="${1:-tiny.en}"
DIR="$(cd "$(dirname "$0")/.." && pwd)/models"
OUT="$DIR/ggml-${MODEL}.bin"
URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${MODEL}.bin"
mkdir -p "$DIR"
if [ -f "$OUT" ]; then
echo "model already present: $OUT"
exit 0
fi
echo "downloading $URL"
curl -fL --retry 3 -o "$OUT" "$URL"
echo "saved $OUT"
+63
View File
@@ -0,0 +1,63 @@
//! Self-hosted Whisper-class transcription service (§6.4). A separate service from
//! the review API so transcription load never touches the real-time path, and on
//! RedClaw infrastructure so partner findings never leave RedClaw control.
pub mod whisper;
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
/// Audio → text. Injected so the HTTP layer is testable without a model, and so
/// the real engine (whisper.cpp) can be swapped without touching the service.
#[async_trait]
pub trait Transcriber: Send + Sync {
async fn transcribe(&self, audio: &[u8], content_type: &str) -> anyhow::Result<String>;
}
#[derive(Clone)]
pub struct AppState {
pub transcriber: Arc<dyn Transcriber>,
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/transcribe", post(transcribe))
.with_state(state)
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
/// Accepts a raw audio body (any container ffmpeg can read) and returns `{ "text": ... }`.
async fn transcribe(
State(st): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, (StatusCode, String)> {
if body.is_empty() {
return Err((StatusCode::BAD_REQUEST, "empty audio body".to_string()));
}
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream");
match st.transcriber.transcribe(&body, content_type).await {
Ok(text) => Ok(Json(json!({ "text": text }))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("transcription failed: {e}"),
)),
}
}
+30
View File
@@ -0,0 +1,30 @@
use std::sync::Arc;
use anyhow::Context;
use transcription_svc::{build_router, whisper::WhisperTranscriber, AppState};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "transcription_svc=info,info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let model = std::env::var("WHISPER_MODEL").unwrap_or_else(|_| "models/ggml-tiny.bin".into());
let transcriber = Arc::new(
WhisperTranscriber::new(&model)
.with_context(|| format!("initializing whisper from {model}"))?,
);
let app = build_router(AppState { transcriber });
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8099".to_string());
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("transcription-svc listening on http://{addr} (model {model})");
axum::serve(listener, app).await?;
Ok(())
}
+92
View File
@@ -0,0 +1,92 @@
//! Real transcription engine: ffmpeg normalizes any input container to 16 kHz mono
//! f32 PCM, then whisper.cpp (via `whisper-rs`) runs inference. This is the path the
//! deployed service uses; `WHISPER_MODEL` points at a ggml model file.
use std::sync::Arc;
use async_trait::async_trait;
use anyhow::{bail, Context};
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
use crate::Transcriber;
pub struct WhisperTranscriber {
ctx: Arc<WhisperContext>,
}
impl WhisperTranscriber {
pub fn new(model_path: &str) -> anyhow::Result<Self> {
let ctx = WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
.with_context(|| format!("loading whisper model from {model_path}"))?;
Ok(Self { ctx: Arc::new(ctx) })
}
}
#[async_trait]
impl Transcriber for WhisperTranscriber {
async fn transcribe(&self, audio: &[u8], _content_type: &str) -> anyhow::Result<String> {
let samples = decode_to_pcm_f32_16k_mono(audio).await?;
if samples.is_empty() {
bail!("decoded audio is empty");
}
// Inference is CPU-bound and blocking; move it off the async runtime.
let ctx = self.ctx.clone();
let text = tokio::task::spawn_blocking(move || run_whisper(&ctx, &samples)).await??;
Ok(text)
}
}
fn run_whisper(ctx: &WhisperContext, samples: &[f32]) -> anyhow::Result<String> {
let mut state = ctx.create_state().context("creating whisper state")?;
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
let threads = std::thread::available_parallelism()
.map(|n| n.get() as i32)
.unwrap_or(4);
params.set_n_threads(threads);
params.set_print_special(false);
params.set_print_progress(false);
params.set_print_realtime(false);
params.set_print_timestamps(false);
state.full(params, samples).context("whisper inference")?;
let n = state.full_n_segments().context("counting segments")?;
let mut text = String::new();
for i in 0..n {
text.push_str(&state.full_get_segment_text(i).context("reading segment")?);
}
Ok(text.trim().to_string())
}
/// Decode arbitrary audio bytes to 16 kHz mono f32 PCM via ffmpeg.
async fn decode_to_pcm_f32_16k_mono(audio: &[u8]) -> anyhow::Result<Vec<f32>> {
let input = std::env::temp_dir().join(format!("clawreview-asr-{}", uuid::Uuid::new_v4()));
tokio::fs::write(&input, audio)
.await
.context("writing temp audio")?;
let result = tokio::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-i"])
.arg(&input)
.args(["-f", "f32le", "-ac", "1", "-ar", "16000", "pipe:1"])
.output()
.await
.context("spawning ffmpeg (is it installed?)")?;
tokio::fs::remove_file(&input).await.ok();
if !result.status.success() {
bail!(
"ffmpeg decode failed: {}",
String::from_utf8_lossy(&result.stderr)
);
}
Ok(result
.stdout
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect())
}
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
//! HTTP-contract tests for the transcription service, using a fake transcriber so
//! they run fast with no model. The real whisper path is covered in `whisper.rs`.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use transcription_svc::{build_router, AppState, Transcriber};
struct FakeTranscriber {
reply: String,
}
#[async_trait]
impl Transcriber for FakeTranscriber {
async fn transcribe(&self, audio: &[u8], _content_type: &str) -> anyhow::Result<String> {
assert!(!audio.is_empty(), "handler must not call transcriber on empty body");
Ok(self.reply.clone())
}
}
async fn spawn(reply: &str) -> (String, reqwest::Client) {
let state = AppState {
transcriber: Arc::new(FakeTranscriber {
reply: reply.to_string(),
}),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, build_router(state)).await.unwrap();
});
(format!("http://{addr}"), reqwest::Client::new())
}
#[tokio::test]
async fn transcribe_returns_text_json() {
let (base, client) = spawn("nfc read resolved quickly").await;
let res = client
.post(format!("{base}/transcribe"))
.header("content-type", "audio/webm")
.body(b"-some-audio-".to_vec())
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: Value = res.json().await.unwrap();
assert_eq!(body["text"], "nfc read resolved quickly");
}
#[tokio::test]
async fn empty_body_is_400() {
let (base, client) = spawn("unused").await;
let res = client
.post(format!("{base}/transcribe"))
.header("content-type", "audio/webm")
.body(Vec::<u8>::new())
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
}
#[tokio::test]
async fn health_ok() {
let (base, client) = spawn("unused").await;
let res = client.get(format!("{base}/health")).send().await.unwrap();
assert_eq!(res.status(), 200);
}
+38
View File
@@ -0,0 +1,38 @@
//! End-to-end test of the real whisper.cpp engine: a recorded speech fixture is
//! decoded by ffmpeg and transcribed. Ignored by default because it needs a
//! downloaded model (`scripts/fetch-model.sh`) and ffmpeg on PATH; run with:
//! cargo test -p transcription-svc --test whisper -- --ignored
use std::path::PathBuf;
use transcription_svc::{whisper::WhisperTranscriber, Transcriber};
fn manifest(rel: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
}
#[tokio::test]
#[ignore = "needs a downloaded model and ffmpeg"]
async fn transcribes_real_speech() {
let model = std::env::var("WHISPER_MODEL")
.map(PathBuf::from)
.unwrap_or_else(|_| manifest("models/ggml-tiny.en.bin"));
assert!(
model.exists(),
"model not found at {model:?} — run scripts/fetch-model.sh"
);
let transcriber = WhisperTranscriber::new(model.to_str().unwrap()).expect("load model");
let audio = std::fs::read(manifest("tests/fixtures/speech.wav")).expect("read fixture");
let text = transcriber
.transcribe(&audio, "audio/wav")
.await
.expect("transcribe")
.to_lowercase();
assert!(
text.contains("fox"),
"expected 'fox' in transcript, got: {text:?}"
);
}