seeding
This commit is contained in:
@@ -0,0 +1 @@
|
||||
models/
|
||||
@@ -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"] }
|
||||
Executable
+18
@@ -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"
|
||||
@@ -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}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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);
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user