diff --git a/engine.py b/engine.py index 806a873..d9f8375 100644 --- a/engine.py +++ b/engine.py @@ -80,11 +80,13 @@ class Recorder: (utilisé pour alimenter l'animation en temps réel). """ - def __init__(self, sample_rate, on_block=None): + 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() @@ -92,9 +94,29 @@ class Recorder: 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 = sd.InputStream( + self._stream = self._ouvrir_flux( samplerate=self.sample_rate, channels=1, dtype="float32", @@ -104,13 +126,11 @@ class Recorder: 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: + frames, self._frames = self._frames, [] + self._liberer() + if not frames: return np.zeros(0, dtype="float32") - return np.concatenate(self._frames, axis=0).flatten() + return np.concatenate(frames, axis=0).flatten() class Transcriber: diff --git a/tests/test_liberation_flux.py b/tests/test_liberation_flux.py new file mode 100644 index 0000000..9ded53c --- /dev/null +++ b/tests/test_liberation_flux.py @@ -0,0 +1,78 @@ +"""Liberation du flux audio. + +Bug du 2026-08-22 : trois dictees d'affilee sans le moindre son. Cause : +`Recorder.start()` ecrasait `self._stream` sans fermer le precedent. Quand un +`stop()` se bloquait (peripherique accapare), le flux restait ouvert POUR TOUJOURS +— et l'essai suivant en empilait un autre. Zonza finissait par tenir elle-meme le +micro qu'elle tentait d'ouvrir. + +On libere donc AVANT d'ouvrir, avec `abort()` (immediat) plutot que `stop()` (qui +draine les tampons et peut bloquer), et sans jamais lever. +""" +import numpy as np + +from engine import Recorder + + +class FauxFlux: + def __init__(self, casse_sur=()): + self.actions = [] + self.casse_sur = casse_sur + + def start(self): self.actions.append("start") + def abort(self): + self.actions.append("abort") + if "abort" in self.casse_sur: raise RuntimeError("peripherique occupe") + def close(self): + self.actions.append("close") + if "close" in self.casse_sur: raise RuntimeError("close impossible") + + +def _rec(flux_successifs): + it = iter(flux_successifs) + return Recorder(16000, ouvrir_flux=lambda **kw: next(it)) + + +def test_demarrer_libere_le_flux_precedent(): + a, b = FauxFlux(), FauxFlux() + r = _rec([a, b]) + r.start() + r.start() # second demarrage sans stop entre les deux + assert "abort" in a.actions and "close" in a.actions, "l'ancien flux doit etre libere" + assert b.actions == ["start"] + + +def test_on_prefere_abort_a_stop(): + a = FauxFlux() + r = _rec([a]) + r.start(); r.stop() + assert "abort" in a.actions + assert "stop" not in a.actions, "stop() draine les tampons et peut bloquer" + + +def test_un_flux_recalcitrant_ne_fait_pas_echouer_le_demarrage(): + a, b = FauxFlux(casse_sur=("abort", "close")), FauxFlux() + r = _rec([a, b]) + r.start() + r.start() # ne doit rien lever malgre les deux echecs + assert b.actions == ["start"] + + +def test_le_flux_est_detache_meme_si_la_liberation_echoue(): + a = FauxFlux(casse_sur=("abort", "close")) + r = _rec([a]) + r.start(); r.stop() + assert r._stream is None, "une reference morte empecherait le prochain demarrage" + + +def test_stop_rend_l_audio_capture(): + a = FauxFlux() + r = _rec([a]) + r.start() + r._callback(np.ones((512, 1), dtype="float32"), 512, None, None) + audio = r.stop() + assert audio.shape == (512,) + + +def test_stop_sans_demarrage_ne_leve_pas(): + assert _rec([]).stop().size == 0