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
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}