"""Moteur de dictée — capture, transcription, collage, son. Aucune dépendance UI : importable par le contrôleur et l'app. """ import subprocess import time import mlx_whisper import numpy as np import pyperclip import sounddevice as sd from pynput.keyboard import Controller, Key from core import CONFIG, clean_transcript SOUNDS = { "start": "/System/Library/Sounds/Pop.aiff", "done": "/System/Library/Sounds/Glass.aiff", "error": "/System/Library/Sounds/Basso.aiff", } _kbd = Controller() def play_sound(name): """Joue un son système macOS sans bloquer. Silencieux si CONFIG['sounds'] est False.""" if not CONFIG["sounds"]: return path = SOUNDS.get(name) if not path: return try: subprocess.Popen(["afplay", path]) except Exception: pass # le son ne doit jamais casser le daemon def inject_text(text): """Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.""" if not text: return pyperclip.copy(text) time.sleep(0.05) # laisser le presse-papier se mettre à jour with _kbd.pressed(Key.cmd): _kbd.press("v") _kbd.release("v") class Recorder: """Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur. on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc (utilisé pour alimenter l'animation en temps réel). """ def __init__(self, sample_rate, on_block=None): self.sample_rate = sample_rate self.on_block = on_block self._frames = [] self._stream = None def _callback(self, indata, frames, time_info, status): block = indata.copy() self._frames.append(block) if self.on_block is not None: self.on_block(block.flatten()) def start(self): self._frames = [] self._stream = sd.InputStream( samplerate=self.sample_rate, channels=1, dtype="float32", callback=self._callback, ) self._stream.start() def stop(self): """Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32).""" if self._stream is not None: self._stream.stop() self._stream.close() self._stream = None if not self._frames: return np.zeros(0, dtype="float32") return np.concatenate(self._frames, axis=0).flatten() class Transcriber: """Transcrit l'audio en français avec MLX-Whisper. Note : mlx_whisper.transcribe charge/met en cache le modèle au premier appel. Le premier lancement télécharge le modèle depuis Hugging Face. """ def __init__(self, model, language): self.model = model self.language = language def warmup(self): """Force le téléchargement/chargement du modèle au démarrage (1 fois).""" silence = np.zeros(16000, dtype="float32") self.transcribe(silence) def transcribe(self, audio): """Transcrit l'audio (numpy float32) et renvoie le texte nettoyé.""" result = mlx_whisper.transcribe( audio, path_or_hf_repo=self.model, language=self.language, ) return clean_transcript(result.get("text", ""))