zonza/pulse_window.py
Ralph Mayola 22080eb997 Zonza : dictée vocale locale macOS (MLX-Whisper + cercle pulsant)
Reconstruction propre de l'ancien projet Dictée. Bundle id neuf
(cloud.mrtechlab.zonza), chemin de build unique vers /Applications
(plus jamais de builds /tmp enregistrés dans LaunchServices).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:57:59 +02:00

166 lines
6.0 KiB
Python

"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio.
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).
"""
import threading
from objc import super
from AppKit import (
NSBackingStoreBuffered,
NSBezierPath,
NSColor,
NSScreen,
NSTimer,
NSView,
NSWindow,
NSWindowStyleMaskBorderless,
)
from Foundation import NSMakeRect, NSObject
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)
def _oval(cx, cy, radius):
return NSBezierPath.bezierPathWithOvalInRect_(
NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius)
)
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)."""
def initWithFrame_(self, frame):
self = super().initWithFrame_(frame)
if self is None:
return None
self._level = 0.0
self._phase = 0.0
return self
def setLevel_(self, level):
self._level = level
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"]
# 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
class PulseWindow(NSObject):
"""Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau."""
def init(self):
self = super().init()
if self is None:
return None
self._level = 0.0
self._lock = threading.Lock()
self._window = None
self._view = None
self._timer = None
return self
def _ensure_window(self):
if self._window is not None:
return
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)
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.setIgnoresMouseEvents_(True)
win.setHasShadow_(False)
view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE))
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._level = level
def _tick_(self, timer):
with self._lock:
level = self._level
if self._view is not None:
self._view.setLevel_(level)
self._view.setNeedsDisplay_(True)
def show(self):
"""Affiche la fenêtre et démarre l'animation. À appeler sur le thread principal."""
self._ensure_window()
with self._lock:
self._level = 0.0
self._window.orderFrontRegardless()
interval = 1.0 / float(CONFIG["fps"])
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
interval, self, "_tick:", None, True
)
def hide(self):
"""Arrête l'animation et masque la fenêtre. À appeler sur le thread principal."""
if self._timer is not None:
self._timer.invalidate()
self._timer = None
if self._window is not None:
self._window.orderOut_(None)