"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran. 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, NSMakeSize, NSObject from audio_level import bar_heights, push_level from core import CONFIG, bar_metrics _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 _BOTTOM_INSET = 8.0 # evite que l'arrondi bas de la barre touche le bord 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): """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._bars = [] self._status = "" return self 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, diametre): _rgba(CONFIG["stop_button_rgb"]).set() NSBezierPath.bezierPathWithOvalInRect_( NSMakeRect(cx - diametre / 2, cy - diametre / 2, diametre, diametre) ).fill() # carré arrondi blanc au centre = « arrêter » side = diametre * 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, m): col = _rgba(CONFIG["mic_rgb"]) col.set() largeur = m["mic_largeur"] body_h = m["mic_corps_hauteur"] body_y = cy - body_h / 2 + body_h * 0.23 # capsule (le micro lui-meme) _rounded(cx - largeur / 2, body_y, largeur, body_h, largeur / 2).fill() # arceau en U qui passe SOUS la capsule arc = NSBezierPath.bezierPath() arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_( (cx, body_y + body_h * 0.23), m["mic_arceau_rayon"], 195.0, 345.0 ) arc.setLineWidth_(max(1.0, body_h * 0.12)) arc.setLineCapStyle_(1) # bouts arrondis arc.stroke() # pied vertical sous l'arceau pied = max(1.2, largeur * 0.18) _rounded(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31, pied / 2).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 + CONFIG["bar_height"] * 0.46 # 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): NSColor.clearColor().set() NSBezierPath.fillRect_(rect) w = CONFIG["bar_width"] h = CONFIG["bar_height"] x = (rect.size.width - w) / 2.0 y = _BOTTOM_INSET cy = y + h / 2.0 m = bar_metrics(h) pad, stop_d, mic_w, ecart = (m["marge_laterale"], m["stop_diametre"], m["mic_largeur"], m["ecart_interne"]) self._draw_bar_background(x, y, w, h) self._draw_stop_button(x + pad + stop_d / 2, cy, stop_d) self._draw_mic(x + w - pad - mic_w / 2, cy, m) wave_x = x + pad + stop_d + ecart wave_w = (x + w - pad - mic_w - ecart) - wave_x self._draw_waveform(wave_x, cy, wave_w) self._draw_bubble(x, y + h, w) class PulseWindow(NSObject): """Fenêtre flottante : show(), hide(), set_level(), set_status(). Thread-safe.""" def init(self): self = super().init() if self is None: return None self._history = [] self._status = "" self._lock = threading.Lock() self._window = None self._view = None self._timer = None self._garde = None # ferme l'overlay quoi qu'il arrive (cf show) 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() 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 win.setIgnoresMouseEvents_(True) win.setHasShadow_(False) view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, ww, wh)) win.setContentView_(view) self._window = win self._view = view def set_level(self, level): """Pousse le dernier niveau audio (appelable depuis un thread de fond).""" with self._lock: 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: history, status = list(self._history), self._status if self._view is not None: 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. Thread principal.""" self._ensure_window() with self._lock: self._history = [] self._window.orderFrontRegardless() self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( 1.0 / float(CONFIG["fps"]), self, "_tick:", None, True ) # Garde-fou : la fenêtre porte sa propre durée de vie. Si le contrôleur # échoue à la refermer — transcription bloquée, thread mort, bug en amont — # elle se ferme seule plutôt que de rester figée à l'écran (2026-08-21). self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False ) def _expiration_(self, timer): print("[garde] overlay ouvert trop longtemps — fermeture forcée") self.hide() def hide(self): """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._garde is not None: self._garde.invalidate() self._garde = None if self._window is not None: self._window.orderOut_(None) with self._lock: self._status = ""