zonza/pulse_window.py
Ralph Mayola 68e1f336c8 Le peripherique audio ne peut plus geler l'interface
Le halo s'est fige une seconde fois, bloque sur « Je vous ecoute… », forme d'onde
plate, process a 0 % CPU. Une autre application (Loom) tenait le micro : le
stream.stop() de PortAudio s'est bloque. Or toggle() s'executait sur le thread
principal Cocoa — toute l'interface a gele avec lui.

Et le garde-fou pose le matin meme etait inoperant PAR CONSTRUCTION : c'est un
NSTimer, il vit sur la boucle d'evenements que le blocage venait justement d'arreter.
Un garde-fou qui depend de ce qu'il est cense sauver ne sauve rien.

Desormais, le chemin appele depuis le thread principal ne touche PLUS JAMAIS au
peripherique : ouverture et fermeture du flux partent en tache de fond, le thread
principal ne fait que changer l'etat et rafraichir l'overlay. Deux tests verrouillent
la regle avec un recorder qui leve si on l'appelle depuis le mauvais endroit.

S'ajoute une garde COURTE (20 s) armee des l'arret : passe ce point il ne reste que
la transcription, quelques secondes ; laisser 210 s de halo, c'est « fige » pour
l'utilisateur meme si le code finit par se rattraper.

Prouve en reproduisant la panne — un stop() qui ne rend jamais la main : l'interface
continue de battre (6, 12, 24 battements) et le halo se ferme seul. Avant, le
compteur serait reste fige.

69 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:14:47 +02:00

269 lines
10 KiB
Python

"""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 rearmer_garde(self, duree_s):
"""Remplace la garde en cours par une plus courte. Thread principal.
Appelee au passage en transcription : la garde longue couvre un
enregistrement de 2 min, mais une fois l'enregistrement fini il ne reste
que quelques secondes de calcul — inutile de laisser un halo trois minutes.
"""
if self._garde is not None:
self._garde.invalidate()
self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
float(duree_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 = ""