From 55c860c5b9e06fef74a3c23fa9240aa8cde237c4 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Fri, 21 Aug 2026 00:46:56 +0200 Subject: [PATCH] L'overlay devient une barre de dictee au lieu d'un cercle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprend le design demande par Ralph, d'apres une capture : barre sombre arrondie posee en bas de l'ecran, et dedans, de gauche a droite, un bouton stop rond rouge-orange a carre blanc, une forme d'onde en barres verticales dont la traine retombe en pointilles, une icone de micro. Au-dessus, une bulle sombre a pointe. La forme d'onde demandait un HISTORIQUE de niveaux, la ou l'ancien cercle ne lisait que le niveau courant : push_level et bar_heights sont ajoutes a audio_level.py comme unites pures (le plus recent a gauche, les positions vides ou silencieuses retombent sur un plancher — c'est lui qui donne les pointilles). La bulle ne peut pas afficher le texte en direct comme la reference : Zonza transcrit APRES l'arret, il n'y a pas de flux continu. Elle porte donc l'etape en cours, et la fenetre reste affichee jusqu'au collage au lieu de disparaitre pendant les secondes de calcul — l'ecran ne se vide plus sans explication. Rendu verifie par capture d'ecran, pas seulement par les tests : le micro a ete redessine apres coup (il sortait en tache blanche) et une marge basse ajoutee, l'arrondi de la barre etant rogne par le bord de la fenetre. 45 tests passent. Le cdhash du stub est inchange, donc les autorisations d'Accessibilite survivent a cette mise a jour (verifie : accordee = True). Co-Authored-By: Claude Opus 5 (1M context) --- audio_level.py | 25 ++++ controller.py | 7 +- core.py | 19 +++- pulse_window.py | 226 ++++++++++++++++++++++++------------- tests/test_core.py | 11 +- tests/test_statut_bulle.py | 72 ++++++++++++ tests/test_waveform.py | 53 +++++++++ 7 files changed, 327 insertions(+), 86 deletions(-) create mode 100644 tests/test_statut_bulle.py create mode 100644 tests/test_waveform.py diff --git a/audio_level.py b/audio_level.py index a22fc58..a37f6d5 100644 --- a/audio_level.py +++ b/audio_level.py @@ -28,3 +28,28 @@ def shape_level(rms, gain): boosted = max(0.0, rms) * gain shaped = boosted ** 0.5 return min(1.0, max(0.0, shaped)) + + +def push_level(history, level, capacity): + """Empile un niveau EN TETE et renvoie un nouvel historique borne a `capacity`. + + L'historique recu n'est jamais modifie (le thread audio ecrit, le thread + principal lit : rendre une nouvelle liste evite d'exposer un etat a moitie + ecrit). Le niveau est borne dans [0, 1]. + """ + borne = min(1.0, max(0.0, float(level))) + return [borne] + list(history)[: capacity - 1] + + +def bar_heights(history, n_bars, floor): + """Convertit un historique de niveaux en `n_bars` hauteurs dans [floor, 1]. + + Le plus recent est a l'indice 0 (gauche de la forme d'onde). Les positions + sans donnee sont completees par `floor`, et les silences y retombent : c'est + ce plancher qui se dessine en petit point et donne la traine pointillee. + """ + if n_bars < 1: + raise ValueError("n_bars doit valoir au moins 1") + retenus = list(history)[:n_bars] + retenus += [0.0] * (n_bars - len(retenus)) + return [max(floor, h) for h in retenus] diff --git a/controller.py b/controller.py index f868409..ea35503 100644 --- a/controller.py +++ b/controller.py @@ -57,6 +57,7 @@ class ZonzaController: self.recorder.start() self.recording = True self.pulse.show() + self.pulse.set_status("Je vous écoute…") play_sound("start") self.on_state("recording") self._arm_safety_timer() @@ -93,11 +94,14 @@ class ZonzaController: self._cancel_safety_timer() try: audio = self.recorder.stop() - self.pulse.hide() play_sound("done") if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): + self.pulse.hide() self.on_state("idle") return + # la fenetre reste affichee : la bulle porte l'etape en cours, sinon + # l'ecran se vide pendant les secondes de calcul et on croit a un plantage. + self.pulse.set_status("Transcription…") self.on_state("transcribing") threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start() except Exception as e: @@ -122,4 +126,5 @@ class ZonzaController: print(f"[erreur] transcription : {e}") play_sound("error") finally: + self.run_on_main(self.pulse.hide) self.on_state("idle") diff --git a/core.py b/core.py index c71600a..1c87982 100644 --- a/core.py +++ b/core.py @@ -16,14 +16,23 @@ CONFIG = { "max_duration_s": 120.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) "sample_rate": 16000, "fps": 30, - "circle_base_radius": 20.0, - "circle_amplitude": 30.0, - "circle_color_rgb": (0.40, 0.50, 0.95), + # --- overlay « barre de dictee » (bas de l'ecran) --- + "bar_width": 440.0, + "bar_height": 52.0, + "bar_radius": 16.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_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, "level_gain": 8.0, # amplification du niveau brut pour l'animation "level_attack": 0.6, # lissage à la montée (0–1, proche de 1 = très réactif) "level_release": 0.15, # lissage à la descente (plus petit = retombée douce) - "wave_rings": 3, # nombre d'anneaux d'onde concentriques - "wave_speed": 0.04, # vitesse de propagation des anneaux par frame "initial_prompt": None, # rempli plus bas depuis VOCABULAIRE } diff --git a/pulse_window.py b/pulse_window.py index 69642c9..7fcb1f1 100644 --- a/pulse_window.py +++ b/pulse_window.py @@ -1,134 +1,199 @@ -"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio. +"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran. -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). +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, NSObject +from Foundation import NSMakeRect, NSMakeSize, NSObject +from audio_level import bar_heights, push_level 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) +_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 -def _oval(cx, cy, radius): - return NSBezierPath.bezierPathWithOvalInRect_( - NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius) +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): - """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).""" + """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._level = 0.0 - self._phase = 0.0 + self._bars = [] + self._status = "" return self - def setLevel_(self, level): - self._level = level + 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): + _rgba(CONFIG["stop_button_rgb"]).set() + NSBezierPath.bezierPathWithOvalInRect_( + NSMakeRect(cx - _STOP_D / 2, cy - _STOP_D / 2, _STOP_D, _STOP_D) + ).fill() + # carré arrondi blanc au centre = « arrêter » + side = _STOP_D * 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): + col = _rgba(CONFIG["mic_rgb"]) + col.set() + body_h = 13.0 + body_y = cy - body_h / 2 + 3.0 + # capsule (le micro lui-meme) + _rounded(cx - _MIC_W / 2, body_y, _MIC_W, body_h, _MIC_W / 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 + ) + arc.setLineWidth_(1.6) + 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() + + 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 + 24.0 # 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): - # 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"] + w = CONFIG["bar_width"] + h = CONFIG["bar_height"] + x = (rect.size.width - w) / 2.0 + y = _BOTTOM_INSET + cy = y + h / 2.0 - # 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 + 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_waveform(wave_x, cy, wave_w) + self._draw_bubble(x, y + h, w) class PulseWindow(NSObject): - """Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau.""" + """Fenêtre flottante : show(), hide(), set_level(), set_status(). Thread-safe.""" def init(self): self = super().init() if self is None: return None - self._level = 0.0 + self._history = [] + self._status = "" self._lock = threading.Lock() self._window = None self._view = None self._timer = None 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() - # 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) + 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 (NSStatusWindowLevel ≈ 25) + win.setLevel_(25) # au-dessus des fenêtres normales win.setIgnoresMouseEvents_(True) win.setHasShadow_(False) - view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE)) + view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, ww, wh)) win.setContentView_(view) self._window = win self._view = view @@ -136,30 +201,39 @@ class PulseWindow(NSObject): def set_level(self, level): """Pousse le dernier niveau audio (appelable depuis un thread de fond).""" with self._lock: - self._level = level + 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: - level = self._level + history, status = list(self._history), self._status if self._view is not None: - self._view.setLevel_(level) + 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. À appeler sur le thread principal.""" + """Affiche la fenêtre et démarre l'animation. Thread principal.""" self._ensure_window() with self._lock: - self._level = 0.0 + self._history = [] self._window.orderFrontRegardless() - interval = 1.0 / float(CONFIG["fps"]) self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( - interval, self, "_tick:", None, True + 1.0 / float(CONFIG["fps"]), self, "_tick:", None, True ) def hide(self): - """Arrête l'animation et masque la fenêtre. À appeler sur le thread principal.""" + """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._window is not None: self._window.orderOut_(None) + with self._lock: + self._status = "" diff --git a/tests/test_core.py b/tests/test_core.py index c0801f3..fb59e0d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -50,12 +50,15 @@ def test_clean_transcript_empty_stays_empty(): def test_config_has_ui_keys(): - for key in ("fps", "circle_base_radius", "circle_amplitude", "circle_color_rgb"): + for key in ("fps", "bar_width", "bar_height", "bar_radius", "bar_bg_rgba", + "stop_button_rgb", "waveform_rgb", "waveform_bars", "mic_rgb"): assert key in CONFIG def test_config_ui_defaults(): assert CONFIG["fps"] == 30 - assert CONFIG["circle_base_radius"] > 0 - assert CONFIG["circle_amplitude"] > 0 - assert len(CONFIG["circle_color_rgb"]) == 3 + assert CONFIG["bar_width"] > CONFIG["bar_height"] # barre couchee, pas carree + assert CONFIG["bar_radius"] <= CONFIG["bar_height"] / 2 + assert len(CONFIG["bar_bg_rgba"]) == 4 + assert CONFIG["waveform_bars"] >= 8 + assert 0.0 < CONFIG["waveform_floor"] < 1.0 diff --git a/tests/test_statut_bulle.py b/tests/test_statut_bulle.py new file mode 100644 index 0000000..805e588 --- /dev/null +++ b/tests/test_statut_bulle.py @@ -0,0 +1,72 @@ +"""La bulle de l'overlay porte l'etat en cours. + +Zonza ne transcrit pas en continu : le texte n'existe qu'apres l'arret. La bulle +affiche donc l'etape ou l'on en est — « Je vous ecoute… » pendant la capture, +« Transcription… » pendant le calcul — et la fenetre reste visible jusqu'au +collage, au lieu de disparaitre en laissant l'utilisateur sans retour. +""" +import numpy as np + +from controller import ZonzaController +from core import CONFIG + + +class FauxPulse: + def __init__(self): + self.statuts = [] + self.visible = False + + def show(self): + self.visible = True + + def hide(self): + self.visible = False + + def set_level(self, level): + pass + + def set_status(self, text): + self.statuts.append(text) + + +class FauxRecorder: + def __init__(self, audio): + self.audio = audio + + def start(self): + pass + + def stop(self): + return self.audio + + +def _ctrl(audio): + pulse = FauxPulse() + c = ZonzaController(CONFIG, pulse) + c.recorder = FauxRecorder(audio) + c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})() + return c, pulse + + +def test_la_bulle_annonce_l_ecoute_au_demarrage(): + c, pulse = _ctrl(np.zeros(16000, dtype="float32")) + c.toggle() + assert pulse.visible + assert any("écoute" in s.lower() for s in pulse.statuts) + + +def test_la_fenetre_reste_visible_pendant_la_transcription(): + audio = np.ones(16000, dtype="float32") * 0.1 # 1 s : au-dessus du seuil + c, pulse = _ctrl(audio) + c.toggle() + pulse.statuts.clear() + c._stop_and_process() + assert pulse.visible, "la fenetre ne doit pas disparaitre avant la transcription" + assert any("transcription" in s.lower() for s in pulse.statuts) + + +def test_un_enregistrement_trop_court_referme_tout_de_suite(): + c, pulse = _ctrl(np.zeros(10, dtype="float32")) # bien sous min_duration_s + c.toggle() + c._stop_and_process() + assert not pulse.visible diff --git a/tests/test_waveform.py b/tests/test_waveform.py new file mode 100644 index 0000000..13d4d81 --- /dev/null +++ b/tests/test_waveform.py @@ -0,0 +1,53 @@ +"""Historique de niveaux -> hauteurs de barres de la forme d'onde. + +L'overlay affiche une forme d'onde facon dictee : le niveau le plus recent a +GAUCHE, les plus anciens vers la droite. Les positions sans donnee (debut +d'enregistrement) ou silencieuses retombent sur un plancher, dessine comme un +petit point plutot qu'une barre — c'est ce qui donne la traine pointillee. +""" +import pytest + +from audio_level import bar_heights, push_level + + +def test_push_ajoute_en_tete(): + assert push_level([], 0.5, capacity=3) == [0.5] + assert push_level([0.5], 0.8, capacity=3) == [0.8, 0.5] + + +def test_push_borne_la_capacite_en_jetant_le_plus_ancien(): + h = [0.3, 0.2, 0.1] + assert push_level(h, 0.9, capacity=3) == [0.9, 0.3, 0.2] + + +def test_push_ne_modifie_pas_l_historique_recu(): + h = [0.3] + push_level(h, 0.9, capacity=3) + assert h == [0.3] + + +def test_push_borne_le_niveau_dans_zero_un(): + assert push_level([], 5.0, capacity=2) == [1.0] + assert push_level([], -3.0, capacity=2) == [0.0] + + +def test_bar_heights_complete_avec_le_plancher(): + b = bar_heights([1.0], n_bars=4, floor=0.08) + assert len(b) == 4 + assert b[0] == 1.0 + assert b[1:] == [0.08, 0.08, 0.08] + + +def test_bar_heights_tronque_si_historique_trop_long(): + b = bar_heights([0.9, 0.8, 0.7, 0.6, 0.5], n_bars=3, floor=0.05) + assert b == [0.9, 0.8, 0.7] + + +def test_bar_heights_applique_le_plancher_aux_silences(): + b = bar_heights([0.0, 0.5], n_bars=2, floor=0.1) + assert b == [0.1, 0.5] + + +def test_bar_heights_refuse_un_nombre_de_barres_absurde(): + with pytest.raises(ValueError): + bar_heights([0.5], n_bars=0, floor=0.1)