Add transcribator page: audio recording + Whisper STT proxy
Some checks failed
ci/woodpecker/push/deploy Pipeline failed

Browser records audio via MediaRecorder API, bcard proxies it to
Whisper STT service and returns transcription as JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-18 20:23:10 +03:00
parent c1db6ae562
commit 22a16affa5
5 changed files with 1500 additions and 12 deletions

View File

@@ -1,9 +1,14 @@
use axum::{response::Html, routing::get, Router};
mod transcribe;
use axum::{response::Html, routing::{get, post}, Router};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(handler));
let app = Router::new()
.route("/", get(handler))
.route("/transcribator", get(transcribator_page))
.route("/api/transcribe", post(transcribe::transcribe));
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
println!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
@@ -14,6 +19,10 @@ async fn handler() -> Html<&'static str> {
Html("<h1>Mikhail Kilin</h1>")
}
async fn transcribator_page() -> Html<&'static str> {
Html(include_str!("../static/transcribator.html"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -23,4 +32,10 @@ mod tests {
let response = handler().await;
assert!(response.0.contains("Mikhail Kilin"));
}
#[tokio::test]
async fn test_transcribator_page() {
let response = transcribator_page().await;
assert!(response.0.contains("Transcribator"));
}
}

78
src/transcribe.rs Normal file
View File

@@ -0,0 +1,78 @@
use axum::{extract::Multipart, http::StatusCode, Json};
use serde::Serialize;
const WHISPER_URL: &str = "http://whisper.whisper.svc:8000/v1/audio/transcriptions";
#[derive(Serialize)]
pub struct TranscribeResponse {
pub text: String,
}
pub async fn transcribe(
mut multipart: Multipart,
) -> Result<Json<TranscribeResponse>, (StatusCode, String)> {
let mut audio_data: Option<(Vec<u8>, String)> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
if field.name() == Some("audio") {
let file_name = field
.file_name()
.unwrap_or("recording.webm")
.to_string();
let data = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
audio_data = Some((data.to_vec(), file_name));
}
}
let (data, file_name) = audio_data
.ok_or((StatusCode::BAD_REQUEST, "No audio field".to_string()))?;
let mime = if file_name.ends_with(".m4a") || file_name.ends_with(".mp4") {
"audio/mp4"
} else {
"audio/webm"
};
let part = reqwest::multipart::Part::bytes(data)
.file_name(file_name)
.mime_str(mime)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("model", "Systran/faster-whisper-medium")
.text("language", "ru");
let client = reqwest::Client::new();
let resp = client
.post(WHISPER_URL)
.multipart(form)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Whisper unavailable: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err((StatusCode::BAD_GATEWAY, format!("Whisper {status}: {body}")));
}
let whisper_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Invalid Whisper response: {e}")))?;
let text = whisper_resp["text"]
.as_str()
.unwrap_or("")
.to_string();
Ok(Json(TranscribeResponse { text }))
}