Skip to main content

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
18#![forbid(unsafe_code)]
19
20mod arabic;
21mod credentials;
22mod endpoint;
23mod error;
24mod output;
25mod provider;
26
27pub use arabic::normalize_ar;
28pub use credentials::resolve_api_key;
29pub use endpoint::{Endpoint, COHERE_URL, DEFAULT_COHERE_MODEL};
30pub use error::{Error, Result};
31pub use output::OutputFormat;
32pub use provider::{Provider, UnknownProvider};
33
34use std::path::Path;
35use std::time::Duration;
36
37/// Default transcription language (ISO-639-1). Arabic-first, by design.
38pub const DEFAULT_LANGUAGE: &str = "ar";
39
40/// Default total-request timeout, in seconds. Generous on purpose: it must cover
41/// upload + model inference + download, and CPU inference of a voice note can take
42/// many seconds. (reqwest's blocking client otherwise silently defaults to 30s,
43/// which truncates a slow transcription mid-inference.)
44pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
45
46/// Time allowed to establish the TCP/TLS connection, within the total timeout above.
47/// Separate because a host that never answers should fail in seconds, not in minutes.
48pub const CONNECT_TIMEOUT_SECS: u64 = 15;
49
50/// Largest response body accepted from an endpoint, in bytes.
51///
52/// A transcript is kilobytes; 64 MiB is roughly six hundred times more headroom than any
53/// real one needs. The cap exists because everything downstream — the UTF-8 decode, the
54/// JSON parse, [`normalize_ar`] — allocates again, so an endpoint returning 200 MB cost
55/// ~580 MB of resident memory before this was here.
56pub const MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
57
58/// A completed transcription. `text` is the model's verbatim output; the folded
59/// `text_normalized` (see [`normalize_ar`]) is what downstream parsers consume.
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct Transcript {
62    pub text: String,
63    pub text_normalized: String,
64    pub provider: String,
65    pub model: String,
66    pub language: String,
67}
68
69/// Transcribes audio by POSTing it to a configured [`Endpoint`].
70pub struct Transcriber {
71    endpoint: Endpoint,
72    language: String,
73    client: reqwest::blocking::Client,
74}
75
76impl Transcriber {
77    /// Build a transcriber for the given endpoint, defaulting to Arabic and a
78    /// [`DEFAULT_TIMEOUT_SECS`] total-request timeout.
79    pub fn new(endpoint: Endpoint) -> Result<Self> {
80        Self::with_timeout(endpoint, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
81    }
82
83    /// Like [`new`](Self::new) but with an explicit total-request timeout — raise it
84    /// for slow CPU inference or large files, lower it to fail faster. The timeout
85    /// spans the whole round-trip (connect + upload + inference + download).
86    ///
87    /// Fails only if the HTTP client cannot be built, which in practice means the TLS
88    /// backend would not initialize. This used to fall back to `Client::new()` on
89    /// error, which is not a fallback at all: that constructor panics on the very same
90    /// failure, and had it succeeded it would have installed reqwest's default 30s
91    /// timeout — silently truncating exactly the slow inference this parameter exists
92    /// to accommodate.
93    pub fn with_timeout(endpoint: Endpoint, timeout: Duration) -> Result<Self> {
94        let client = reqwest::blocking::Client::builder()
95            .timeout(timeout)
96            // A black-holed host would otherwise burn the entire total timeout — five
97            // minutes by default — before admitting it never connected.
98            .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
99            // Redirects are never followed. reqwest's default is to follow up to ten,
100            // and for a client posting to one known API route that only causes harm:
101            // a 302/303 downgrades POST to GET and drops the body, so the audio is never
102            // uploaded and whatever comes back gets parsed as a transcript. See
103            // `Error::Redirect`.
104            .redirect(reqwest::redirect::Policy::none())
105            .build()
106            .map_err(|source| Error::Client { source })?;
107        Ok(Transcriber {
108            endpoint,
109            language: DEFAULT_LANGUAGE.to_string(),
110            client,
111        })
112    }
113
114    /// Override the transcription language (ISO-639-1).
115    pub fn language(mut self, lang: impl Into<String>) -> Self {
116        self.language = lang.into();
117        self
118    }
119
120    /// Stream `audio` to the endpoint and return the transcript.
121    pub fn transcribe(&self, audio: &Path) -> Result<Transcript> {
122        let read_err = |source| Error::ReadFile {
123            path: audio.to_path_buf(),
124            source,
125        };
126        // Opened and streamed rather than read into a Vec: an hour of 16-bit 44.1kHz WAV
127        // is ~600MB, and buffering it made peak memory scale with the recording.
128        let file = std::fs::File::open(audio).map_err(read_err)?;
129        let len = file.metadata().map_err(read_err)?.len();
130
131        let filename = audio
132            .file_name()
133            .and_then(|n| n.to_str())
134            .unwrap_or("audio")
135            .to_string();
136
137        // Same multipart shape for every backend: model + language + file.
138        // Order matters: Cohere rejects the request unless the text fields
139        // (model, language) appear BEFORE the file part in the body.
140        //
141        // reader_with_length, not reader: the length is what keeps this a
142        // Content-Length body. Without it reqwest switches to chunked transfer-encoding,
143        // which not every endpoint accepts.
144        let file_part =
145            reqwest::blocking::multipart::Part::reader_with_length(file, len).file_name(filename);
146        let form = reqwest::blocking::multipart::Form::new()
147            .text("model", self.endpoint.model.clone())
148            .text("language", self.language.clone())
149            .part("file", file_part);
150
151        let mut req = self.client.post(&self.endpoint.url).multipart(form);
152        if let Some(key) = &self.endpoint.api_key {
153            req = req.bearer_auth(key);
154        }
155
156        let http_err = |source: reqwest::Error| Error::Http {
157            // Redacted, and reqwest's own copy dropped: it appends " for url (...)" to its
158            // Display, which would print the endpoint twice — credentials included.
159            url: error::redact_url(&self.endpoint.url),
160            source: source.without_url(),
161        };
162
163        let resp = req.send().map_err(http_err)?;
164        let status = resp.status();
165
166        // Refuse a redirect rather than follow it. See `Error::Redirect` for why following
167        // is worse than failing here.
168        if status.is_redirection() {
169            return Err(Error::Redirect {
170                status: status.as_u16(),
171                location: resp
172                    .headers()
173                    .get(reqwest::header::LOCATION)
174                    .and_then(|v| v.to_str().ok())
175                    // A redirect target can carry credentials too.
176                    .map(error::redact_url),
177            });
178        }
179
180        // Bound the response before reading it. `Content-Length` is the cheap check; a
181        // chunked reply advertises none, so that case is caught after the fact instead.
182        if let Some(len) = resp.content_length() {
183            if len > MAX_RESPONSE_BYTES {
184                return Err(Error::ResponseTooLarge {
185                    got: len,
186                    limit: MAX_RESPONSE_BYTES,
187                });
188            }
189        }
190        let body = resp.text().map_err(http_err)?;
191        if body.len() as u64 > MAX_RESPONSE_BYTES {
192            return Err(Error::ResponseTooLarge {
193                got: body.len() as u64,
194                limit: MAX_RESPONSE_BYTES,
195            });
196        }
197        // Anything the endpoint sent back may be quoted into an error below, so take the
198        // caller's own key out of it first.
199        let body = error::scrub_secret(body, self.endpoint.api_key.as_deref());
200        if !status.is_success() {
201            return Err(Error::Api {
202                status: status.as_u16(),
203                body: error::excerpt(body),
204            });
205        }
206
207        let text = match extract_text(&body) {
208            Some(text) => text,
209            None => {
210                return Err(Error::BadResponse {
211                    body: error::excerpt(body),
212                })
213            }
214        };
215        Ok(Transcript {
216            text_normalized: normalize_ar(&text),
217            text,
218            provider: self.endpoint.provider.as_str().to_string(),
219            model: self.endpoint.model.clone(),
220            language: self.language.clone(),
221        })
222    }
223}
224
225/// The OpenAI-standard transcription response, which vLLM and (per Cohere's docs) the
226/// hosted API both return. Unknown fields are ignored, so a backend sending extra
227/// metadata alongside `text` still parses.
228#[derive(serde::Deserialize)]
229struct Response {
230    text: String,
231}
232
233/// Pull the transcript out of an endpoint response.
234///
235// Assumes the `{"text": "..."}` shape. If a backend nests it differently, add a branch
236// here rather than a whole response type — an excerpt of the raw body is surfaced in
237// `Error::BadResponse` so a mismatch is obvious on first run.
238fn extract_text(body: &str) -> Option<String> {
239    serde_json::from_str::<Response>(body).ok().map(|r| r.text)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::{Transcriber, Transcript};
245
246    use super::extract_text;
247
248    /// The README tells people to share one `Transcriber` across threads instead of
249    /// building one per file, which is only sound if this holds. Compile-time check: it
250    /// fails to build, not at runtime.
251    #[test]
252    fn transcriber_and_transcript_are_send_and_sync() {
253        fn assert_send_sync<T: Send + Sync>() {}
254        assert_send_sync::<Transcriber>();
255        assert_send_sync::<Transcript>();
256    }
257
258    #[test]
259    fn pulls_text_and_ignores_extra_fields() {
260        let body = r#"{"text": "مرحبا", "duration": 1.5, "language": "ar"}"#;
261        assert_eq!(extract_text(body).as_deref(), Some("مرحبا"));
262    }
263
264    #[test]
265    fn rejects_responses_that_are_not_a_transcript() {
266        // Each of these used to be indistinguishable from "no text field" — they still
267        // are, but the point is that none of them panic or yield a bogus transcript.
268        assert_eq!(extract_text("not json at all"), None);
269        assert_eq!(extract_text(r#"{"error": "model not found"}"#), None);
270        assert_eq!(extract_text(r#"{"text": 42}"#), None);
271        assert_eq!(extract_text(r#"{"text": null}"#), None);
272        assert_eq!(extract_text("[]"), None);
273        assert_eq!(extract_text(""), None);
274    }
275}