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>
This commit is contained in:
Ralph Mayola 2026-08-22 23:57:47 +02:00
parent ebf3c8e749
commit 19afbe0369
2 changed files with 106 additions and 8 deletions

View File

@ -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:

View File

@ -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