Aarya2004
Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)
47b2a99 | """AudioModel: local speech-to-text for the spoken voice note (ADR-0009). | |
| Wraps CohereLabs/cohere-transcribe-03-2026 (2B, #1 WER, on-device) via transformers' | |
| canonical path β AutoProcessor + CohereAsrForConditionalGeneration, the approach the | |
| model card documents (the generic `pipeline()` API errors on this model). Verified | |
| locally: a trade note transcribes cleanly. | |
| Heavy (torch + a 2B model), so the import + load are LAZY β importing this module | |
| costs nothing; the model loads on first `.transcribe()`. Keeps the spoken note inside | |
| the on-device Private Stack (π Off the Grid). Gated repo: needs HF access + token. | |
| """ | |
| DEFAULT_MODEL = "CohereLabs/cohere-transcribe-03-2026" | |
| def to_wav_16k_mono(path: str) -> str: | |
| """Return a path to a 16kHz mono WAV for `path`. | |
| Browser/phone MediaRecorder emits a **webm** container (Opus), which librosa / | |
| transformers' `load_audio` cannot decode ("appears to be a video file"). Twilio call | |
| recordings can be `.mp3` too. We normalize anything that isn't already a `.wav` to | |
| 16kHz mono PCM WAV with ffmpeg first β the format the ASR model wants, and a safer | |
| container all round. A `.wav` input is returned unchanged (no needless transcode). | |
| Raises RuntimeError with a clear message if ffmpeg isn't installed. | |
| """ | |
| import os | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| if path.lower().endswith(".wav"): | |
| return path | |
| if shutil.which("ffmpeg") is None: | |
| raise RuntimeError( | |
| "ffmpeg is required to transcode the voice note (browser/phone records webm, " | |
| "which the ASR model can't read). Install it (e.g. `brew install ffmpeg`)." | |
| ) | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| out = tmp.name | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-i", path, "-ar", "16000", "-ac", "1", "-f", "wav", out], | |
| check=True, | |
| capture_output=True, | |
| ) | |
| if not os.path.getsize(out): | |
| raise RuntimeError(f"ffmpeg produced an empty WAV transcoding {path!r}.") | |
| return out | |
| class AudioModel: | |
| def __init__(self, model: str = DEFAULT_MODEL, language: str = "en"): | |
| self.name = model | |
| self._language = language | |
| self._processor = None | |
| self._model = None | |
| def _load(self): | |
| if self._model is None: | |
| from transformers import AutoProcessor, CohereAsrForConditionalGeneration | |
| self._processor = AutoProcessor.from_pretrained(self.name) | |
| self._model = CohereAsrForConditionalGeneration.from_pretrained( | |
| self.name, device_map="auto" | |
| ) | |
| return self._processor, self._model | |
| def transcribe(self, path: str) -> str: | |
| import os | |
| from transformers.audio_utils import load_audio | |
| processor, model = self._load() | |
| # Normalize webm/m4a/mp3 β 16kHz mono WAV (load_audio can't decode webm). Clean up | |
| # any temp file we created (but never the caller's original .wav). | |
| wav = to_wav_16k_mono(path) | |
| try: | |
| audio = load_audio(wav, sampling_rate=16000) | |
| finally: | |
| if wav != path: | |
| try: | |
| os.unlink(wav) | |
| except OSError: | |
| pass | |
| inputs = processor(audio, sampling_rate=16000, return_tensors="pt", language=self._language) | |
| inputs.to(model.device, dtype=model.dtype) | |
| outputs = model.generate(**inputs, max_new_tokens=256) | |
| decoded = processor.decode(outputs, skip_special_tokens=True) | |
| # decode() returns a list (one string per batch item); we transcribe one clip. | |
| if isinstance(decoded, list): | |
| decoded = decoded[0] if decoded else "" | |
| return decoded | |