raqeem_core/lib.rs
1//! `raqeem-core` — رقيم. Turn an audio file into Arabic text by delegating
2//! inference to a transcription endpoint (Cohere's hosted API, or your own
3//! vLLM). This crate never loads model weights: it decodes nothing, runs no
4//! model — it POSTs the audio and folds the result. That is what keeps it
5//! light and callable from any language over the CLI.
6//!
7//! ```no_run
8//! use raqeem_core::{Endpoint, Transcriber};
9//!
10//! let endpoint = Endpoint::cohere(std::env::var("COHERE_API_KEY").unwrap(), None);
11//! let transcript = Transcriber::new(endpoint)
12//! .language("ar")
13//! .transcribe(std::path::Path::new("voice_note.ogg"))?;
14//! println!("{}", transcript.text);
15//! # Ok::<(), raqeem_core::Error>(())
16//! ```
17
18mod arabic;
19mod endpoint;
20mod error;
21mod output;
22mod provider;
23
24pub use arabic::normalize_ar;
25pub use endpoint::{Endpoint, COHERE_URL, DEFAULT_COHERE_MODEL};
26pub use error::{Error, Result};
27pub use output::OutputFormat;
28pub use provider::Provider;
29
30use std::path::Path;
31use std::time::Duration;
32
33/// Default transcription language (ISO-639-1). Arabic-first, by design.
34pub const DEFAULT_LANGUAGE: &str = "ar";
35
36/// Default total-request timeout, in seconds. Generous on purpose: it must cover
37/// upload + model inference + download, and CPU inference of a voice note can take
38/// many seconds. (reqwest's blocking client otherwise silently defaults to 30s,
39/// which truncates a slow transcription mid-inference.)
40pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
41
42/// A completed transcription. `text` is the model's verbatim output; the folded
43/// `text_normalized` (see [`normalize_ar`]) is what downstream parsers consume.
44#[derive(Debug, Clone, serde::Serialize)]
45pub struct Transcript {
46 pub text: String,
47 pub text_normalized: String,
48 pub provider: String,
49 pub model: String,
50 pub language: String,
51}
52
53/// Transcribes audio by POSTing it to a configured [`Endpoint`].
54pub struct Transcriber {
55 endpoint: Endpoint,
56 language: String,
57 client: reqwest::blocking::Client,
58}
59
60impl Transcriber {
61 /// Build a transcriber for the given endpoint, defaulting to Arabic and a
62 /// [`DEFAULT_TIMEOUT_SECS`] total-request timeout.
63 pub fn new(endpoint: Endpoint) -> Self {
64 Self::with_timeout(endpoint, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
65 }
66
67 /// Like [`new`](Self::new) but with an explicit total-request timeout — raise it
68 /// for slow CPU inference or large files, lower it to fail faster. The timeout
69 /// spans the whole round-trip (connect + upload + inference + download).
70 pub fn with_timeout(endpoint: Endpoint, timeout: Duration) -> Self {
71 let client = reqwest::blocking::Client::builder()
72 .timeout(timeout)
73 .build()
74 .unwrap_or_else(|_| reqwest::blocking::Client::new());
75 Transcriber {
76 endpoint,
77 language: DEFAULT_LANGUAGE.to_string(),
78 client,
79 }
80 }
81
82 /// Override the transcription language (ISO-639-1).
83 pub fn language(mut self, lang: impl Into<String>) -> Self {
84 self.language = lang.into();
85 self
86 }
87
88 /// Read `audio`, send it to the endpoint, and return the transcript.
89 pub fn transcribe(&self, audio: &Path) -> Result<Transcript> {
90 let bytes = std::fs::read(audio).map_err(|source| Error::ReadFile {
91 path: audio.to_path_buf(),
92 source,
93 })?;
94 let filename = audio
95 .file_name()
96 .and_then(|n| n.to_str())
97 .unwrap_or("audio")
98 .to_string();
99
100 // Same multipart shape for every backend: model + language + file.
101 // Order matters: Cohere rejects the request unless the text fields
102 // (model, language) appear BEFORE the file part in the body.
103 let file_part = reqwest::blocking::multipart::Part::bytes(bytes).file_name(filename);
104 let form = reqwest::blocking::multipart::Form::new()
105 .text("model", self.endpoint.model.clone())
106 .text("language", self.language.clone())
107 .part("file", file_part);
108
109 let mut req = self.client.post(&self.endpoint.url).multipart(form);
110 if let Some(key) = &self.endpoint.api_key {
111 req = req.bearer_auth(key);
112 }
113
114 let resp = req.send().map_err(|source| Error::Http {
115 url: self.endpoint.url.clone(),
116 source,
117 })?;
118 let status = resp.status();
119 let body = resp.text().map_err(|source| Error::Http {
120 url: self.endpoint.url.clone(),
121 source,
122 })?;
123 if !status.is_success() {
124 return Err(Error::Api {
125 status: status.as_u16(),
126 body,
127 });
128 }
129
130 let text = extract_text(&body).ok_or_else(|| Error::BadResponse { body: body.clone() })?;
131 Ok(Transcript {
132 text_normalized: normalize_ar(&text),
133 text,
134 provider: self.endpoint.provider.as_str().to_string(),
135 model: self.endpoint.model.clone(),
136 language: self.language.clone(),
137 })
138 }
139}
140
141/// Pull the transcript out of an endpoint response.
142///
143// ponytail: assumes the OpenAI-standard `{"text": "..."}` shape, which vLLM and
144// (per Cohere's docs) the hosted API both return. If a backend nests it
145// differently, add a branch here rather than a whole response type — the raw
146// body is surfaced in `Error::BadResponse` so a mismatch is obvious on first run.
147fn extract_text(body: &str) -> Option<String> {
148 let v: serde_json::Value = serde_json::from_str(body).ok()?;
149 v.get("text")?.as_str().map(str::to_string)
150}