zonza/engine.py
Ralph Mayola 19afbe0369 Zonza ne tient plus elle-meme le micro qu'elle essaie d'ouvrir
Trois dictees d'affilee sans le moindre son, chacune terminee par la fermeture
forcee du halo. Cause : Recorder.start() ecrasait self._stream sans fermer le
precedent. Des qu'un stop() se bloquait sur un peripherique accapare, le flux
restait ouvert POUR TOUJOURS ; l'essai suivant en empilait un autre par-dessus, et
Zonza finissait par tenir le micro qu'elle tentait d'ouvrir. 25 threads dans le
process en temoignaient.

start() libere donc le flux precedent avant d'en ouvrir un nouveau. La liberation
DETACHE la reference d'abord — meme ratee, elle ne doit pas laisser un flux mort
barrer le prochain demarrage — et utilise abort() plutot que stop(), ce dernier
drainant les tampons et pouvant se bloquer. Elle ne leve jamais.

Le flux est desormais injectable, ce qui rend Recorder testable sans peripherique.

Prouve sur le vrai micro, en enchainant deux demarrages sans arret entre les deux :
1066 blocs au premier, 1070 au second. Avant, le second en recevait zero.

80 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:57:47 +02:00

161 lines
5.4 KiB
Python

"""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, attendre, 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
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
_MASQUE_MODIFICATEURS = 0x000F0000
def modificateurs_enfonces():
"""True si au moins un modificateur physique est actuellement enfoncé."""
try:
from Quartz import CGEventSourceFlagsState
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
except Exception:
return False # dans le doute, ne jamais bloquer le collage
def inject_text(text):
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
Cmd+Ctrl+D quand on envoie Cmd+V, le système reçoit Cmd+Ctrl+V et l'application
cible ne colle rien — sans la moindre erreur de notre côté (constaté le
2026-08-21 : deux dictées transcrites, jamais collées).
"""
if not text:
return
pyperclip.copy(text)
libre = attendre(
lambda: not modificateurs_enfonces(),
CONFIG["attente_modificateurs_s"],
0.03,
)
if not libre:
print("[collage] modificateurs encore enfoncés — collage tenté quand même")
time.sleep(CONFIG["delai_presse_papier_s"]) # laisser le presse-papier se mettre à jour
with _kbd.pressed(Key.cmd):
_kbd.press("v")
_kbd.release("v")
print("[collage] ok")
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, ouvrir_flux=None):
self.sample_rate = sample_rate
self.on_block = on_block
self._frames = []
self._stream = None
# ouvrir_flux : injectable pour les tests (aucun peripherique requis).
self._ouvrir_flux = ouvrir_flux or (lambda **kw: sd.InputStream(**kw))
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 _liberer(self):
"""Libère le flux courant. Ne lève jamais, ne bloque pas durablement.
On DÉTACHE la référence avant d'agir : même si la libération échoue, le
prochain démarrage ne doit pas retomber sur un flux mort. Et on utilise
`abort()` plutôt que `stop()` — ce dernier draine les tampons et peut se
bloquer sur un périphérique accaparé, ce qui laissait le micro tenu pour
toujours (2026-08-22 : trois dictées muettes d'affilée).
"""
flux, self._stream = self._stream, None
if flux is None:
return
for action in ("abort", "close"):
try:
getattr(flux, action)()
except Exception as exc:
print(f"[audio] {action}() du flux précédent a échoué : {exc}")
def start(self):
# un flux orphelin d'un essai precedent tiendrait le micro : on libere d'abord
self._liberer()
self._frames = []
self._stream = self._ouvrir_flux(
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)."""
frames, self._frames = self._frames, []
self._liberer()
if not frames:
return np.zeros(0, dtype="float32")
return np.concatenate(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,
initial_prompt=CONFIG["initial_prompt"],
)
return clean_transcript(result.get("text", ""))