#![forbid(unsafe_code)]
mod arabic;
mod credentials;
mod endpoint;
mod error;
mod output;
mod provider;
pub use arabic::normalize_ar;
pub use credentials::resolve_api_key;
pub use endpoint::{Endpoint, COHERE_URL, DEFAULT_COHERE_MODEL};
pub use error::{Error, Result};
pub use output::OutputFormat;
pub use provider::{Provider, UnknownProvider};
use std::path::Path;
use std::time::Duration;
pub const DEFAULT_LANGUAGE: &str = "ar";
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
pub const CONNECT_TIMEOUT_SECS: u64 = 15;
pub const MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Transcript {
pub text: String,
pub text_normalized: String,
pub provider: String,
pub model: String,
pub language: String,
}
pub struct Transcriber {
endpoint: Endpoint,
language: String,
client: reqwest::blocking::Client,
}
impl Transcriber {
pub fn new(endpoint: Endpoint) -> Result<Self> {
Self::with_timeout(endpoint, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
}
pub fn with_timeout(endpoint: Endpoint, timeout: Duration) -> Result<Self> {
let client = reqwest::blocking::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|source| Error::Client { source })?;
Ok(Transcriber {
endpoint,
language: DEFAULT_LANGUAGE.to_string(),
client,
})
}
pub fn language(mut self, lang: impl Into<String>) -> Self {
self.language = lang.into();
self
}
pub fn transcribe(&self, audio: &Path) -> Result<Transcript> {
let read_err = |source| Error::ReadFile {
path: audio.to_path_buf(),
source,
};
let file = std::fs::File::open(audio).map_err(read_err)?;
let len = file.metadata().map_err(read_err)?.len();
let filename = audio
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("audio")
.to_string();
let file_part =
reqwest::blocking::multipart::Part::reader_with_length(file, len).file_name(filename);
let form = reqwest::blocking::multipart::Form::new()
.text("model", self.endpoint.model.clone())
.text("language", self.language.clone())
.part("file", file_part);
let mut req = self.client.post(&self.endpoint.url).multipart(form);
if let Some(key) = &self.endpoint.api_key {
req = req.bearer_auth(key);
}
let http_err = |source: reqwest::Error| Error::Http {
url: error::redact_url(&self.endpoint.url),
source: source.without_url(),
};
let resp = req.send().map_err(http_err)?;
let status = resp.status();
if status.is_redirection() {
return Err(Error::Redirect {
status: status.as_u16(),
location: resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(error::redact_url),
});
}
if let Some(len) = resp.content_length() {
if len > MAX_RESPONSE_BYTES {
return Err(Error::ResponseTooLarge {
got: len,
limit: MAX_RESPONSE_BYTES,
});
}
}
let body = resp.text().map_err(http_err)?;
if body.len() as u64 > MAX_RESPONSE_BYTES {
return Err(Error::ResponseTooLarge {
got: body.len() as u64,
limit: MAX_RESPONSE_BYTES,
});
}
let body = error::scrub_secret(body, self.endpoint.api_key.as_deref());
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
body: error::excerpt(body),
});
}
let text = match extract_text(&body) {
Some(text) => text,
None => {
return Err(Error::BadResponse {
body: error::excerpt(body),
})
}
};
Ok(Transcript {
text_normalized: normalize_ar(&text),
text,
provider: self.endpoint.provider.as_str().to_string(),
model: self.endpoint.model.clone(),
language: self.language.clone(),
})
}
}
#[derive(serde::Deserialize)]
struct Response {
text: String,
}
fn extract_text(body: &str) -> Option<String> {
serde_json::from_str::<Response>(body).ok().map(|r| r.text)
}
#[cfg(test)]
mod tests {
use super::{Transcriber, Transcript};
use super::extract_text;
#[test]
fn transcriber_and_transcript_are_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Transcriber>();
assert_send_sync::<Transcript>();
}
#[test]
fn pulls_text_and_ignores_extra_fields() {
let body = r#"{"text": "مرحبا", "duration": 1.5, "language": "ar"}"#;
assert_eq!(extract_text(body).as_deref(), Some("مرحبا"));
}
#[test]
fn rejects_responses_that_are_not_a_transcript() {
assert_eq!(extract_text("not json at all"), None);
assert_eq!(extract_text(r#"{"error": "model not found"}"#), None);
assert_eq!(extract_text(r#"{"text": 42}"#), None);
assert_eq!(extract_text(r#"{"text": null}"#), None);
assert_eq!(extract_text("[]"), None);
assert_eq!(extract_text(""), None);
}
}