diff --git a/audio_level.py b/audio_level.py index a22fc58..a37f6d5 100644 --- a/audio_level.py +++ b/audio_level.py @@ -28,3 +28,28 @@ def shape_level(rms, gain): boosted = max(0.0, rms) * gain shaped = boosted ** 0.5 return min(1.0, max(0.0, shaped)) + + +def push_level(history, level, capacity): + """Empile un niveau EN TETE et renvoie un nouvel historique borne a `capacity`. + + L'historique recu n'est jamais modifie (le thread audio ecrit, le thread + principal lit : rendre une nouvelle liste evite d'exposer un etat a moitie + ecrit). Le niveau est borne dans [0, 1]. + """ + borne = min(1.0, max(0.0, float(level))) + return [borne] + list(history)[: capacity - 1] + + +def bar_heights(history, n_bars, floor): + """Convertit un historique de niveaux en `n_bars` hauteurs dans [floor, 1]. + + Le plus recent est a l'indice 0 (gauche de la forme d'onde). Les positions + sans donnee sont completees par `floor`, et les silences y retombent : c'est + ce plancher qui se dessine en petit point et donne la traine pointillee. + """ + if n_bars < 1: + raise ValueError("n_bars doit valoir au moins 1") + retenus = list(history)[:n_bars] + retenus += [0.0] * (n_bars - len(retenus)) + return [max(floor, h) for h in retenus] diff --git a/controller.py b/controller.py index f868409..ea35503 100644 --- a/controller.py +++ b/controller.py @@ -57,6 +57,7 @@ class ZonzaController: self.recorder.start() self.recording = True self.pulse.show() + self.pulse.set_status("Je vous écoute…") play_sound("start") self.on_state("recording") self._arm_safety_timer() @@ -93,11 +94,14 @@ class ZonzaController: self._cancel_safety_timer() try: audio = self.recorder.stop() - self.pulse.hide() play_sound("done") if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): + self.pulse.hide() self.on_state("idle") return + # la fenetre reste affichee : la bulle porte l'etape en cours, sinon + # l'ecran se vide pendant les secondes de calcul et on croit a un plantage. + self.pulse.set_status("Transcription…") self.on_state("transcribing") threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start() except Exception as e: @@ -122,4 +126,5 @@ class ZonzaController: print(f"[erreur] transcription : {e}") play_sound("error") finally: + self.run_on_main(self.pulse.hide) self.on_state("idle") diff --git a/core.py b/core.py index c71600a..1c87982 100644 --- a/core.py +++ b/core.py @@ -16,14 +16,23 @@ CONFIG = { "max_duration_s": 120.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) "sample_rate": 16000, "fps": 30, - "circle_base_radius": 20.0, - "circle_amplitude": 30.0, - "circle_color_rgb": (0.40, 0.50, 0.95), + # --- overlay « barre de dictee » (bas de l'ecran) --- + "bar_width": 440.0, + "bar_height": 52.0, + "bar_radius": 16.0, + "bar_bg_rgba": (0.09, 0.09, 0.10, 0.95), + "stop_button_rgb": (0.89, 0.31, 0.23), # rond rouge-orange + "stop_glyph_rgb": (1.0, 1.0, 1.0), # carre arrondi blanc au centre + "waveform_rgb": (0.95, 0.95, 0.97), + "waveform_bars": 34, # nombre de barres de la forme d'onde + "waveform_floor": 0.07, # plancher : dessine en petit point (traine pointillee) + "mic_rgb": (0.78, 0.78, 0.82), + "bubble_text_rgb": (1.0, 1.0, 1.0), + "bubble_font_size": 13.0, + "bubble_max_width": 300.0, "level_gain": 8.0, # amplification du niveau brut pour l'animation "level_attack": 0.6, # lissage à la montée (0–1, proche de 1 = très réactif) "level_release": 0.15, # lissage à la descente (plus petit = retombée douce) - "wave_rings": 3, # nombre d'anneaux d'onde concentriques - "wave_speed": 0.04, # vitesse de propagation des anneaux par frame "initial_prompt": None, # rempli plus bas depuis VOCABULAIRE } diff --git a/pulse_window.py b/pulse_window.py index 69642c9..7fcb1f1 100644 --- a/pulse_window.py +++ b/pulse_window.py @@ -1,134 +1,199 @@ -"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio. +"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran. -Cocoa natif (pyobjc). Tout le dessin se fait sur le thread principal via un NSTimer. -Le niveau audio est poussé depuis un thread de fond par set_level() (écriture atomique -d'un float — sûr en CPython). +Trois zones, de gauche à droite : un bouton stop rond rouge-orange, une forme +d'onde en barres verticales, une icône de micro. Au-dessus, une bulle sombre +affiche l'état en cours. + +Cocoa natif (pyobjc). Tout le dessin a lieu sur le thread principal via un +NSTimer ; les niveaux audio arrivent d'un thread de fond par set_level(), et +l'historique qui alimente la forme d'onde est protégé par un verrou. """ import threading from objc import super from AppKit import ( + NSAttributedString, NSBackingStoreBuffered, NSBezierPath, NSColor, + NSFont, + NSFontAttributeName, + NSForegroundColorAttributeName, NSScreen, NSTimer, NSView, NSWindow, NSWindowStyleMaskBorderless, ) -from Foundation import NSMakeRect, NSObject +from Foundation import NSMakeRect, NSMakeSize, NSObject +from audio_level import bar_heights, push_level from core import CONFIG -_WINDOW_SIZE = 200.0 # côté de la fenêtre carrée, en points (contient cercle + anneaux) -_BOTTOM_MARGIN = 80.0 # marge au-dessus du bas de l'écran (passe au-dessus du Dock) +_BOTTOM_MARGIN = 80.0 # au-dessus du Dock +_BUBBLE_GAP = 12.0 # espace entre la bulle et la barre +_BUBBLE_PAD = 12.0 # marge intérieure de la bulle +_TAIL = 7.0 # demi-largeur de la pointe de la bulle +_SIDE_PAD = 22.0 # marge intérieure gauche/droite de la barre +_STOP_D = 28.0 # diamètre du bouton stop +_MIC_W = 9.0 # largeur de la capsule du micro +_BOTTOM_INSET = 10.0 # evite que l'arrondi bas de la barre touche le bord -def _oval(cx, cy, radius): - return NSBezierPath.bezierPathWithOvalInRect_( - NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius) +def _rounded(x, y, w, h, radius): + return NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_( + NSMakeRect(x, y, w, h), radius, radius ) +def _rgba(triplet, alpha=1.0): + r, g, b = triplet[:3] + return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) + + class PulseView(NSView): - """Vue centrée : cercle plein qui pulse + anneaux d'onde qui se propagent vers - l'extérieur. Le rayon du cercle suit le niveau audio ; les anneaux avancent d'une - phase à chaque frame et leur intensité dépend du niveau (plus on parle fort, plus - les ondes sont visibles).""" + """Dessine la barre, la forme d'onde et la bulle d'état.""" def initWithFrame_(self, frame): self = super().initWithFrame_(frame) if self is None: return None - self._level = 0.0 - self._phase = 0.0 + self._bars = [] + self._status = "" return self - def setLevel_(self, level): - self._level = level + def setBars_(self, bars): + self._bars = bars + + def setStatus_(self, text): + self._status = text or "" + + # --- morceaux de dessin ------------------------------------------------ + def _draw_bar_background(self, x, y, w, h): + _rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set() + _rounded(x, y, w, h, CONFIG["bar_radius"]).fill() + + def _draw_stop_button(self, cx, cy): + _rgba(CONFIG["stop_button_rgb"]).set() + NSBezierPath.bezierPathWithOvalInRect_( + NSMakeRect(cx - _STOP_D / 2, cy - _STOP_D / 2, _STOP_D, _STOP_D) + ).fill() + # carré arrondi blanc au centre = « arrêter » + side = _STOP_D * 0.34 + _rgba(CONFIG["stop_glyph_rgb"]).set() + _rounded(cx - side / 2, cy - side / 2, side, side, 2.0).fill() + + def _draw_waveform(self, x0, cy, width): + bars = self._bars + if not bars: + return + n = len(bars) + step = width / float(n) + bw = max(2.0, step * 0.38) + half_max = CONFIG["bar_height"] * 0.30 # amplitude verticale max + _rgba(CONFIG["waveform_rgb"]).set() + for i, level in enumerate(bars): + cxi = x0 + step * (i + 0.5) + half = max(bw / 2.0, level * half_max) # jamais moins qu'un point rond + _rounded(cxi - bw / 2, cy - half, bw, half * 2, bw / 2).fill() + + def _draw_mic(self, cx, cy): + col = _rgba(CONFIG["mic_rgb"]) + col.set() + body_h = 13.0 + body_y = cy - body_h / 2 + 3.0 + # capsule (le micro lui-meme) + _rounded(cx - _MIC_W / 2, body_y, _MIC_W, body_h, _MIC_W / 2).fill() + # arceau en U qui passe SOUS la capsule + arc = NSBezierPath.bezierPath() + arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_( + (cx, body_y + 3.0), 7.0, 195.0, 345.0 + ) + arc.setLineWidth_(1.6) + arc.setLineCapStyle_(1) # bouts arrondis + arc.stroke() + # pied vertical sous l'arceau + _rounded(cx - 0.8, body_y - 8.0, 1.6, 4.0, 0.8).fill() + + def _draw_bubble(self, bar_x, bar_top, bar_w): + if not self._status: + return + attrs = { + NSFontAttributeName: NSFont.systemFontOfSize_(CONFIG["bubble_font_size"]), + NSForegroundColorAttributeName: _rgba(CONFIG["bubble_text_rgb"]), + } + text = NSAttributedString.alloc().initWithString_attributes_(self._status, attrs) + avail = NSMakeSize(CONFIG["bubble_max_width"], 400.0) + bounds = text.boundingRectWithSize_options_(avail, 1 << 0) # WordWrap + tw, th = bounds.size.width, bounds.size.height + w, h = tw + 2 * _BUBBLE_PAD, th + 2 * _BUBBLE_PAD + x = bar_x + 24.0 # alignée à gauche, comme la référence + y = bar_top + _BUBBLE_GAP + _rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set() + _rounded(x, y, w, h, 10.0).fill() + # petite pointe vers la barre, en bas à gauche + tail = NSBezierPath.bezierPath() + tail.moveToPoint_((x + 20.0 - _TAIL, y + 1.0)) + tail.lineToPoint_((x + 20.0 + _TAIL, y + 1.0)) + tail.lineToPoint_((x + 20.0, y - 7.0)) + tail.closePath() + tail.fill() + text.drawInRect_(NSMakeRect(x + _BUBBLE_PAD, y + _BUBBLE_PAD, tw, th)) def drawRect_(self, rect): - # fond transparent NSColor.clearColor().set() NSBezierPath.fillRect_(rect) - level = self._level - base = CONFIG["circle_base_radius"] - amp = CONFIG["circle_amplitude"] - radius = base + level * amp - cx = rect.size.width / 2.0 - cy = rect.size.height / 2.0 - r, g, b = CONFIG["circle_color_rgb"] + w = CONFIG["bar_width"] + h = CONFIG["bar_height"] + x = (rect.size.width - w) / 2.0 + y = _BOTTOM_INSET + cy = y + h / 2.0 - # anneaux d'onde : chacun part du cercle et grandit vers l'extérieur selon la - # phase ; l'opacité décroît avec la distance → effet d'ondes qui se propagent. - # la portée est bornée par l'espace dispo dans la fenêtre (rayon max + ondes ≤ moitié - # du côté) pour que les anneaux ne soient jamais coupés, quelle que soit la taille. - n_rings = CONFIG["wave_rings"] - half = min(rect.size.width, rect.size.height) / 2.0 - max_radius = base + amp # rayon du cercle au volume maximal - max_reach = max(0.0, half - max_radius - 4.0) # marge de 4px contre le bord - for i in range(n_rings): - # fraction de progression de cet anneau (décalé pour étaler les ondes) - frac = (self._phase + i / float(n_rings)) % 1.0 - ring_radius = radius + frac * max_reach - # opacité : forte près du cercle, nulle au bout, modulée par le volume - alpha = (1.0 - frac) * 0.5 * level - if alpha <= 0.01: - continue - ring = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) - ring.set() - path = _oval(cx, cy, ring_radius) - path.setLineWidth_(3.0) - path.stroke() - - # halo doux autour du cercle principal - halo = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.20) - halo.set() - _oval(cx, cy, radius + 14).fill() - - # cercle principal plein - main = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.9) - main.set() - _oval(cx, cy, radius).fill() - - # fait avancer la phase des ondes pour la frame suivante - self._phase = (self._phase + CONFIG["wave_speed"]) % 1.0 + self._draw_bar_background(x, y, w, h) + self._draw_stop_button(x + _SIDE_PAD + _STOP_D / 2, cy) + self._draw_mic(x + w - _SIDE_PAD - _MIC_W / 2, cy) + wave_x = x + _SIDE_PAD + _STOP_D + 18.0 + wave_w = (x + w - _SIDE_PAD - _MIC_W - 18.0) - wave_x + self._draw_waveform(wave_x, cy, wave_w) + self._draw_bubble(x, y + h, w) class PulseWindow(NSObject): - """Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau.""" + """Fenêtre flottante : show(), hide(), set_level(), set_status(). Thread-safe.""" def init(self): self = super().init() if self is None: return None - self._level = 0.0 + self._history = [] + self._status = "" self._lock = threading.Lock() self._window = None self._view = None self._timer = None return self + def _window_size(self): + return (CONFIG["bar_width"] + 80.0, + CONFIG["bar_height"] + _BOTTOM_INSET + 140.0) + def _ensure_window(self): if self._window is not None: return + ww, wh = self._window_size() screen = NSScreen.mainScreen().frame() - # bas-centre : centré horizontalement, posé en bas avec une marge (Cocoa : y=0 en bas) - x = (screen.size.width - _WINDOW_SIZE) / 2.0 - y = _BOTTOM_MARGIN - frame = NSMakeRect(x, y, _WINDOW_SIZE, _WINDOW_SIZE) + frame = NSMakeRect((screen.size.width - ww) / 2.0, _BOTTOM_MARGIN, ww, wh) win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False ) win.setOpaque_(False) win.setBackgroundColor_(NSColor.clearColor()) - win.setLevel_(25) # au-dessus des fenêtres normales (NSStatusWindowLevel ≈ 25) + win.setLevel_(25) # au-dessus des fenêtres normales win.setIgnoresMouseEvents_(True) win.setHasShadow_(False) - view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE)) + view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, ww, wh)) win.setContentView_(view) self._window = win self._view = view @@ -136,30 +201,39 @@ class PulseWindow(NSObject): def set_level(self, level): """Pousse le dernier niveau audio (appelable depuis un thread de fond).""" with self._lock: - self._level = level + self._history = push_level(self._history, level, CONFIG["waveform_bars"]) + + def set_status(self, text): + """Texte affiché dans la bulle (thread de fond autorisé).""" + with self._lock: + self._status = text or "" def _tick_(self, timer): with self._lock: - level = self._level + history, status = list(self._history), self._status if self._view is not None: - self._view.setLevel_(level) + self._view.setBars_( + bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"]) + ) + self._view.setStatus_(status) self._view.setNeedsDisplay_(True) def show(self): - """Affiche la fenêtre et démarre l'animation. À appeler sur le thread principal.""" + """Affiche la fenêtre et démarre l'animation. Thread principal.""" self._ensure_window() with self._lock: - self._level = 0.0 + self._history = [] self._window.orderFrontRegardless() - interval = 1.0 / float(CONFIG["fps"]) self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( - interval, self, "_tick:", None, True + 1.0 / float(CONFIG["fps"]), self, "_tick:", None, True ) def hide(self): - """Arrête l'animation et masque la fenêtre. À appeler sur le thread principal.""" + """Arrête l'animation et masque la fenêtre. Thread principal.""" if self._timer is not None: self._timer.invalidate() self._timer = None if self._window is not None: self._window.orderOut_(None) + with self._lock: + self._status = "" diff --git a/tests/test_core.py b/tests/test_core.py index c0801f3..fb59e0d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -50,12 +50,15 @@ def test_clean_transcript_empty_stays_empty(): def test_config_has_ui_keys(): - for key in ("fps", "circle_base_radius", "circle_amplitude", "circle_color_rgb"): + for key in ("fps", "bar_width", "bar_height", "bar_radius", "bar_bg_rgba", + "stop_button_rgb", "waveform_rgb", "waveform_bars", "mic_rgb"): assert key in CONFIG def test_config_ui_defaults(): assert CONFIG["fps"] == 30 - assert CONFIG["circle_base_radius"] > 0 - assert CONFIG["circle_amplitude"] > 0 - assert len(CONFIG["circle_color_rgb"]) == 3 + assert CONFIG["bar_width"] > CONFIG["bar_height"] # barre couchee, pas carree + assert CONFIG["bar_radius"] <= CONFIG["bar_height"] / 2 + assert len(CONFIG["bar_bg_rgba"]) == 4 + assert CONFIG["waveform_bars"] >= 8 + assert 0.0 < CONFIG["waveform_floor"] < 1.0 diff --git a/tests/test_statut_bulle.py b/tests/test_statut_bulle.py new file mode 100644 index 0000000..805e588 --- /dev/null +++ b/tests/test_statut_bulle.py @@ -0,0 +1,72 @@ +"""La bulle de l'overlay porte l'etat en cours. + +Zonza ne transcrit pas en continu : le texte n'existe qu'apres l'arret. La bulle +affiche donc l'etape ou l'on en est — « Je vous ecoute… » pendant la capture, +« Transcription… » pendant le calcul — et la fenetre reste visible jusqu'au +collage, au lieu de disparaitre en laissant l'utilisateur sans retour. +""" +import numpy as np + +from controller import ZonzaController +from core import CONFIG + + +class FauxPulse: + def __init__(self): + self.statuts = [] + self.visible = False + + def show(self): + self.visible = True + + def hide(self): + self.visible = False + + def set_level(self, level): + pass + + def set_status(self, text): + self.statuts.append(text) + + +class FauxRecorder: + def __init__(self, audio): + self.audio = audio + + def start(self): + pass + + def stop(self): + return self.audio + + +def _ctrl(audio): + pulse = FauxPulse() + c = ZonzaController(CONFIG, pulse) + c.recorder = FauxRecorder(audio) + c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})() + return c, pulse + + +def test_la_bulle_annonce_l_ecoute_au_demarrage(): + c, pulse = _ctrl(np.zeros(16000, dtype="float32")) + c.toggle() + assert pulse.visible + assert any("écoute" in s.lower() for s in pulse.statuts) + + +def test_la_fenetre_reste_visible_pendant_la_transcription(): + audio = np.ones(16000, dtype="float32") * 0.1 # 1 s : au-dessus du seuil + c, pulse = _ctrl(audio) + c.toggle() + pulse.statuts.clear() + c._stop_and_process() + assert pulse.visible, "la fenetre ne doit pas disparaitre avant la transcription" + assert any("transcription" in s.lower() for s in pulse.statuts) + + +def test_un_enregistrement_trop_court_referme_tout_de_suite(): + c, pulse = _ctrl(np.zeros(10, dtype="float32")) # bien sous min_duration_s + c.toggle() + c._stop_and_process() + assert not pulse.visible diff --git a/tests/test_waveform.py b/tests/test_waveform.py new file mode 100644 index 0000000..13d4d81 --- /dev/null +++ b/tests/test_waveform.py @@ -0,0 +1,53 @@ +"""Historique de niveaux -> hauteurs de barres de la forme d'onde. + +L'overlay affiche une forme d'onde facon dictee : le niveau le plus recent a +GAUCHE, les plus anciens vers la droite. Les positions sans donnee (debut +d'enregistrement) ou silencieuses retombent sur un plancher, dessine comme un +petit point plutot qu'une barre — c'est ce qui donne la traine pointillee. +""" +import pytest + +from audio_level import bar_heights, push_level + + +def test_push_ajoute_en_tete(): + assert push_level([], 0.5, capacity=3) == [0.5] + assert push_level([0.5], 0.8, capacity=3) == [0.8, 0.5] + + +def test_push_borne_la_capacite_en_jetant_le_plus_ancien(): + h = [0.3, 0.2, 0.1] + assert push_level(h, 0.9, capacity=3) == [0.9, 0.3, 0.2] + + +def test_push_ne_modifie_pas_l_historique_recu(): + h = [0.3] + push_level(h, 0.9, capacity=3) + assert h == [0.3] + + +def test_push_borne_le_niveau_dans_zero_un(): + assert push_level([], 5.0, capacity=2) == [1.0] + assert push_level([], -3.0, capacity=2) == [0.0] + + +def test_bar_heights_complete_avec_le_plancher(): + b = bar_heights([1.0], n_bars=4, floor=0.08) + assert len(b) == 4 + assert b[0] == 1.0 + assert b[1:] == [0.08, 0.08, 0.08] + + +def test_bar_heights_tronque_si_historique_trop_long(): + b = bar_heights([0.9, 0.8, 0.7, 0.6, 0.5], n_bars=3, floor=0.05) + assert b == [0.9, 0.8, 0.7] + + +def test_bar_heights_applique_le_plancher_aux_silences(): + b = bar_heights([0.0, 0.5], n_bars=2, floor=0.1) + assert b == [0.1, 0.5] + + +def test_bar_heights_refuse_un_nombre_de_barres_absurde(): + with pytest.raises(ValueError): + bar_heights([0.5], n_bars=0, floor=0.1)