seeding
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user