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>
269 lines
10 KiB
Python
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 = ""
|