use super::transcript::{Segment, Transcript, Word, segments_from_words};
use super::{TranscribeRequest, Transcriber, multipart::Multipart};
use crate::config::ApiKey;
use crate::provider::BoxFuture;
use rustlavel_client::Client;
use rustlavel_core::{Error, Json, Result};
pub const OPENAI_BASE_URL: &str = "https://api.openai.com";
pub const WHISPER_DEFAULT_MODEL: &str = "whisper-1";
pub struct Whisper {
client: Client,
key: ApiKey,
base_url: String,
model: String,
}
impl Whisper {
pub fn new(key: impl Into<ApiKey>) -> Whisper {
Whisper {
client: Client::new(),
key: key.into(),
base_url: OPENAI_BASE_URL.to_string(),
model: WHISPER_DEFAULT_MODEL.to_string(),
}
}
pub fn local(base_url: impl Into<String>) -> Whisper {
Whisper::new(ApiKey::default()).base_url(base_url)
}
pub fn client(mut self, client: Client) -> Whisper {
self.client = client;
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Whisper {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
pub fn model(mut self, model: impl Into<String>) -> Whisper {
self.model = model.into();
self
}
fn endpoint(&self) -> String {
format!("{}/v1/audio/transcriptions", self.base_url)
}
}
impl Transcriber for Whisper {
fn name(&self) -> &'static str {
"whisper"
}
fn default_model(&self) -> &str {
&self.model
}
fn transcribe<'a>(&'a self, request: &'a TranscribeRequest) -> BoxFuture<'a, Result<Transcript>> {
Box::pin(async move {
if request.diarize {
return Err(Error::msg(
"whisper does not tell speakers apart. Use the deepgram provider with `.diarize()`, \
or drop `.diarize()` and take the words without speakers.",
));
}
let model = request.model.clone().unwrap_or_else(|| self.model.clone());
let mut form = Multipart::new()
.text("model", &model)
.text("response_format", "verbose_json")
.text("timestamp_granularities[]", "word")
.text("timestamp_granularities[]", "segment");
if let Some(language) = &request.language {
form = form.text("language", language);
}
if let Some(prompt) = &request.prompt {
form = form.text("prompt", prompt);
}
let (content_type, body) =
form.file("file", &request.audio.file_name, &request.audio.mime, &request.audio.bytes).finish();
let mut http = self.client.post(self.endpoint()).header("content-type", &content_type).body(body);
if !self.key.is_empty() {
http = http.bearer(self.key.expose());
}
let response = http
.send()
.await
.and_then(rustlavel_client::ClientResponse::error_for_status)
.map_err(|error| self.key.scrub_error(error))?;
let mut transcript = parse(&response.json()?)?;
transcript.model = model;
Ok(transcript)
})
}
}
pub fn parse(json: &Json) -> Result<Transcript> {
let text = json
.get("text")
.and_then(Json::as_str)
.ok_or_else(|| Error::msg("the transcription answer has no `text`"))?
.trim()
.to_string();
let words: Vec<Word> = json
.get("words")
.and_then(Json::as_array)
.unwrap_or(&[])
.iter()
.filter_map(|w| {
Some(Word {
text: w.get("word")?.as_str()?.trim().to_string(),
start: w.get("start")?.as_f64()?,
end: w.get("end")?.as_f64()?,
speaker: None,
confidence: None,
})
})
.collect();
let mut segments: Vec<Segment> = json
.get("segments")
.and_then(Json::as_array)
.unwrap_or(&[])
.iter()
.filter_map(|s| {
Some(Segment {
text: s.get("text")?.as_str()?.trim().to_string(),
start: s.get("start")?.as_f64()?,
end: s.get("end")?.as_f64()?,
speaker: None,
})
})
.collect();
if segments.is_empty() {
segments = segments_from_words(&words);
}
Ok(Transcript {
text,
language: json.get("language").and_then(Json::as_str).map(normalise_language),
duration: json.get("duration").and_then(Json::as_f64),
segments,
words,
diarized: false,
model: String::new(),
provider: "whisper".to_string(),
})
}
fn normalise_language(language: &str) -> String {
let lower = language.trim().to_lowercase();
match lower.as_str() {
"indonesian" => "id",
"english" => "en",
"malay" => "ms",
"japanese" => "ja",
"korean" => "ko",
"chinese" | "mandarin" => "zh",
"spanish" => "es",
"french" => "fr",
"german" => "de",
"portuguese" => "pt",
"arabic" => "ar",
"hindi" => "hi",
"javanese" => "jv",
"sundanese" => "su",
"thai" => "th",
"vietnamese" => "vi",
"tagalog" | "filipino" => "tl",
_ => return lower,
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transcribe::{Audio, Transcription};
use rustlavel_client::fake::{Fake, FakeResponse};
const ANSWER: &str = r#"{
"task": "transcribe",
"language": "indonesian",
"duration": 2.7,
"text": " Halo semua. Apa kabar hari ini? ",
"words": [
{"word": "Halo", "start": 0.0, "end": 0.4},
{"word": "semua.", "start": 0.5, "end": 0.9},
{"word": "Apa", "start": 1.2, "end": 1.4},
{"word": "kabar", "start": 1.5, "end": 1.8},
{"word": "hari", "start": 1.9, "end": 2.2},
{"word": "ini?", "start": 2.3, "end": 2.7}
],
"segments": [
{"id": 0, "start": 0.0, "end": 0.9, "text": " Halo semua.", "avg_logprob": -0.2},
{"id": 1, "start": 1.2, "end": 2.7, "text": " Apa kabar hari ini?", "avg_logprob": -0.3}
]
}"#;
#[test]
fn reads_words_segments_and_the_language_as_a_code() {
let transcript = parse(&Json::parse(ANSWER).unwrap()).unwrap();
assert_eq!(transcript.text, "Halo semua. Apa kabar hari ini?");
assert_eq!(transcript.language.as_deref(), Some("id"));
assert_eq!(transcript.duration, Some(2.7));
assert_eq!(transcript.words.len(), 6);
assert_eq!(transcript.words[3].text, "kabar");
assert_eq!(transcript.words[3].start, 1.5);
assert_eq!(transcript.segments.len(), 2);
assert_eq!(transcript.segments[1].text, "Apa kabar hari ini?");
assert!(!transcript.diarized);
assert_eq!(transcript.speakers(), 0);
}
#[test]
fn segments_are_built_when_the_server_sends_none() {
let mut json = Json::parse(ANSWER).unwrap();
if let Json::Object(map) = &mut json {
map.remove("segments");
}
let transcript = parse(&json).unwrap();
assert_eq!(transcript.segments.len(), 2, "{:?}", transcript.segments);
assert_eq!(transcript.segments[0].text, "Halo semua.");
}
#[tokio::test]
async fn sends_a_multipart_request_with_word_timestamps_asked_for() {
let fake = Fake::new().on("*/v1/audio/transcriptions", FakeResponse::json(Json::parse(ANSWER).unwrap()));
let client = Client::new().faking(fake);
let whisper = Whisper::new("sk-test").client(client.clone());
let speech = Transcription::new(whisper);
let transcript = speech
.transcribe(Audio::new(vec![1, 2, 3], "clip.mp3"))
.language("id")
.prompt("Rustlavel, Videotto")
.run()
.await
.unwrap();
assert_eq!(transcript.model, "whisper-1");
assert_eq!(transcript.words.len(), 6);
let sent = client.fake().unwrap().recorded().remove(0);
assert_eq!(sent.url, "https://api.openai.com/v1/audio/transcriptions");
assert_eq!(sent.headers.get("authorization"), Some("Bearer sk-test"));
let content_type = sent.headers.get("content-type").unwrap();
assert!(content_type.starts_with("multipart/form-data; boundary="), "{content_type}");
let body = sent.body_text();
for field in [
"name=\"model\"\r\n\r\nwhisper-1",
"name=\"response_format\"\r\n\r\nverbose_json",
"name=\"timestamp_granularities[]\"\r\n\r\nword",
"name=\"timestamp_granularities[]\"\r\n\r\nsegment",
"name=\"language\"\r\n\r\nid",
"name=\"prompt\"\r\n\r\nRustlavel, Videotto",
"name=\"file\"; filename=\"clip.mp3\"\r\nContent-Type: audio/mpeg",
] {
assert!(body.contains(field), "missing {field:?} in\n{body}");
}
assert!(sent.body.windows(3).any(|w| w == [1, 2, 3]));
}
#[tokio::test]
async fn a_local_server_gets_no_authorization_header_and_its_own_model() {
let fake = Fake::new().fallback(FakeResponse::json(Json::parse(ANSWER).unwrap()));
let client = Client::new().faking(fake);
let whisper = Whisper::local("http://whisper.internal:8000/").model("Systran/faster-whisper-large-v3").client(client.clone());
let transcript = Transcription::new(whisper).transcribe(Audio::new(vec![9], "a.wav")).run().await.unwrap();
assert_eq!(transcript.model, "Systran/faster-whisper-large-v3");
let sent = client.fake().unwrap().recorded().remove(0);
assert_eq!(sent.url, "http://whisper.internal:8000/v1/audio/transcriptions");
assert!(sent.headers.get("authorization").is_none(), "a key was sent that does not exist");
assert!(sent.body_text().contains("Systran/faster-whisper-large-v3"));
}
#[tokio::test]
async fn asking_whisper_for_speakers_is_refused_before_any_upload() {
let client = Client::new().faking(Fake::new());
let speech = Transcription::new(Whisper::new("k").client(client.clone()));
let error = speech.transcribe(Audio::new(vec![1], "a.mp3")).diarize().run().await.unwrap_err().to_string();
assert!(error.contains("deepgram"), "{error}");
assert_eq!(client.fake().unwrap().count(), 0, "the audio was uploaded anyway");
}
#[tokio::test]
async fn an_error_body_never_carries_the_key() {
let fake = Fake::new().fallback(FakeResponse::text("invalid key sk-secret-123").status(401));
let client = Client::new().faking(fake);
let speech = Transcription::new(Whisper::new("sk-secret-123").client(client));
let error = speech.transcribe(Audio::new(vec![1], "a.mp3")).run().await.unwrap_err().to_string();
assert!(!error.contains("sk-secret-123"), "{error}");
}
}