Le controleur passe par les contrats, la duplication disparait
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
"""Capture audio et collage du texte transcrit.
|
||||
|
||||
Aucune dépendance UI : importable par le contrôleur et l'app. La transcription
|
||||
et les appels système propres à une plateforme vivent désormais ailleurs
|
||||
(`moteur/`, `systeme/`) — ce module ne garde que ce qui n'a pas de frontière
|
||||
de plateforme : la capture du micro et le collage du résultat.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pyperclip
|
||||
import sounddevice as sd
|
||||
from pynput.keyboard import Controller, Key
|
||||
|
||||
from core import CONFIG, attendre
|
||||
|
||||
_kbd = Controller()
|
||||
|
||||
|
||||
def inject_text(text, systeme):
|
||||
"""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
|
||||
la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
|
||||
l'application cible ne colle rien — sans la moindre erreur de notre côté
|
||||
(constaté le 2026-08-21).
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
pyperclip.copy(text)
|
||||
libre = attendre(
|
||||
lambda: not systeme.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()
|
||||
Reference in New Issue
Block a user