Overlay reduit d'un quart, et sa geometrie devient proportionnelle

Barre 440x52 -> 330x40. Les proportions internes (bouton stop, micro, marges,
ecarts) etaient en dur dans le dessin : les laisser telles quelles aurait donne
une grosse pastille dans une barre retrecie. Elles derivent desormais de la
hauteur de la barre via core.bar_metrics, donc une seule valeur commande toute
l'echelle — diviser bar_height par deux divise tout par deux (teste).

Rendu verifie par capture d'ecran apres reduction.

50 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ralph Mayola 2026-08-21 00:49:38 +02:00
parent 55c860c5b9
commit f8c30e0402
3 changed files with 89 additions and 26 deletions

32
core.py
View File

@ -17,19 +17,19 @@ CONFIG = {
"sample_rate": 16000,
"fps": 30,
# --- overlay « barre de dictee » (bas de l'ecran) ---
"bar_width": 440.0,
"bar_height": 52.0,
"bar_radius": 16.0,
"bar_width": 330.0,
"bar_height": 40.0,
"bar_radius": 12.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_bars": 28, # 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,
"bubble_font_size": 12.0,
"bubble_max_width": 240.0,
"level_gain": 8.0, # amplification du niveau brut pour l'animation
"level_attack": 0.6, # lissage à la montée (01, proche de 1 = très réactif)
"level_release": 0.15, # lissage à la descente (plus petit = retombée douce)
@ -92,6 +92,26 @@ def build_initial_prompt(termes=None):
return prefixe + ", ".join(retenus) + "."
# Proportions internes de la barre, exprimees en fraction de sa HAUTEUR : reduire
# `bar_height` reduit alors le bouton, le micro et les marges dans le meme
# mouvement, au lieu de laisser une grosse pastille dans une barre retrecie.
_PROPORTIONS = {
"stop_diametre": 0.538,
"mic_largeur": 0.173,
"marge_laterale": 0.423,
"mic_corps_hauteur": 0.250,
"mic_arceau_rayon": 0.135,
"ecart_interne": 0.346,
}
def bar_metrics(bar_height):
"""Mesures de l'overlay pour une hauteur de barre donnee, en points."""
if bar_height <= 0:
raise ValueError("bar_height doit etre > 0")
return {cle: bar_height * f for cle, f in _PROPORTIONS.items()}
def validate_config(config):
"""Valide la config ; lève ValueError si une valeur est aberrante."""
if config["min_duration_s"] < 0:

View File

@ -29,16 +29,13 @@ from AppKit import (
from Foundation import NSMakeRect, NSMakeSize, NSObject
from audio_level import bar_heights, push_level
from core import CONFIG
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
_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
_BOTTOM_INSET = 8.0 # evite que l'arrondi bas de la barre touche le bord
def _rounded(x, y, w, h, radius):
@ -74,13 +71,13 @@ class PulseView(NSView):
_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):
def _draw_stop_button(self, cx, cy, diametre):
_rgba(CONFIG["stop_button_rgb"]).set()
NSBezierPath.bezierPathWithOvalInRect_(
NSMakeRect(cx - _STOP_D / 2, cy - _STOP_D / 2, _STOP_D, _STOP_D)
NSMakeRect(cx - diametre / 2, cy - diametre / 2, diametre, diametre)
).fill()
# carré arrondi blanc au centre = « arrêter »
side = _STOP_D * 0.34
side = diametre * 0.34
_rgba(CONFIG["stop_glyph_rgb"]).set()
_rounded(cx - side / 2, cy - side / 2, side, side, 2.0).fill()
@ -98,23 +95,25 @@ class PulseView(NSView):
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):
def _draw_mic(self, cx, cy, m):
col = _rgba(CONFIG["mic_rgb"])
col.set()
body_h = 13.0
body_y = cy - body_h / 2 + 3.0
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 - _MIC_W / 2, body_y, _MIC_W, body_h, _MIC_W / 2).fill()
_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 + 3.0), 7.0, 195.0, 345.0
(cx, body_y + body_h * 0.23), m["mic_arceau_rayon"], 195.0, 345.0
)
arc.setLineWidth_(1.6)
arc.setLineWidth_(max(1.0, body_h * 0.12))
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()
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:
@ -128,7 +127,7 @@ class PulseView(NSView):
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
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()
@ -151,11 +150,14 @@ class PulseView(NSView):
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 + _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_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)

41
tests/test_geometrie.py Normal file
View File

@ -0,0 +1,41 @@
"""Geometrie de l'overlay : tout derive de la hauteur de la barre.
Sinon, reduire la barre laisse le bouton stop, le micro et les marges a leur
taille d'origine, et la composition se deseequilibre. Une seule valeur commande
l'echelle.
"""
import pytest
from core import CONFIG, bar_metrics
def test_toutes_les_mesures_sont_fournies():
m = bar_metrics(52.0)
for cle in ("stop_diametre", "mic_largeur", "marge_laterale",
"mic_corps_hauteur", "mic_arceau_rayon", "ecart_interne"):
assert cle in m
def test_les_mesures_tiennent_dans_la_barre():
h = 52.0
m = bar_metrics(h)
assert m["stop_diametre"] < h
assert m["mic_corps_hauteur"] < h
assert 2 * m["marge_laterale"] < CONFIG["bar_width"]
def test_diviser_la_hauteur_par_deux_divise_tout_par_deux():
grand, petit = bar_metrics(52.0), bar_metrics(26.0)
for cle, valeur in grand.items():
assert petit[cle] == pytest.approx(valeur / 2.0)
def test_une_hauteur_absurde_est_refusee():
with pytest.raises(ValueError):
bar_metrics(0)
def test_la_config_reste_coherente_avec_ses_mesures():
m = bar_metrics(CONFIG["bar_height"])
largeur_occupee = 2 * m["marge_laterale"] + m["stop_diametre"] + m["mic_largeur"]
assert largeur_occupee < CONFIG["bar_width"], "plus de place pour la forme d'onde"