commit 22080eb997a0f56a0065ae4bdaf9fa31826f5cde Author: Ralph Mayola Date: Fri Jun 12 23:57:59 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36c3d5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.DS_Store +assets/icon.iconset/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce9eaa8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ralph Mayola + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec93a06 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Zonza 🎙️ + +Dictée vocale **100% locale** pour macOS (Apple Silicon). Appuie sur un raccourci, un +cercle pulse au rythme de ta voix, et ton texte est transcrit puis collé dans l'app +active. Inspiré de Superwhisper — open source, hors-ligne, rien ne quitte ta machine. + +- **Transcription locale** : MLX-Whisper `medium` (français), aucun envoi réseau. +- **Cercle pulsant** : une fenêtre flottante visualise tes intonations en temps réel. +- **Barre de menu** : l'app vit dans la barre de menu macOS (jamais dans le Dock). +- **Toggle `Cmd+Ctrl+D`** : démarre / arrête la dictée. + +## Installation + +```bash +git clone ssh://git@git.mrtechlab.cloud:2222/rmayola/zonza.git +cd zonza +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +./build_app.sh # installe /Applications/Zonza.app (lanceur lié à ce dépôt) +``` + +`Zonza.app` est un **lanceur léger** : il exécute le code via le venv du dépôt (MLX y +tourne nativement, là où py2app/PyInstaller échouent sur les shaders Metal). Ne supprime +donc ni le dossier du dépôt ni `.venv`. C'est aussi l'**unique chemin de build** — on ne +construit jamais dans `/tmp` ni dans le dépôt, pour ne laisser aucune entrée fantôme dans +LaunchServices (cause historique d'icônes en double). + +Au tout premier lancement, l'app télécharge le modèle Whisper (~512 Mo, une seule fois). + +## Permissions macOS + +Réglages Système → Confidentialité et sécurité : +- **Microphone** — popup automatique au 1er enregistrement. +- **Surveillance de l'entrée / Accessibilité** — pour la hotkey globale et le collage + (`Cmd+V` simulé). Si non accordée, le texte reste dans le presse-papier : fais `Cmd+V` + à la main. + +## Usage + +- L'icône 🎙️ apparaît dans la barre de menu (🔴 enregistrement, ⏳ transcription). +- **`Cmd+Ctrl+D`** : le cercle apparaît, parle. +- **`Cmd+Ctrl+D`** : transcription + collage dans l'app active. +- Menu : activer/désactiver les sons, quitter. +- Sécurité : arrêt automatique après 2 minutes d'enregistrement. + +## Lancer depuis les sources (développement) + +```bash +source .venv/bin/activate +python3 app.py +``` + +## Configuration + +Édite `CONFIG` dans `core.py` : `model`, `language`, `hotkey`, `sounds`, +`min_duration_s`, `max_duration_s`, `fps`, `circle_base_radius`, `circle_amplitude`, +`circle_color_rgb`, `level_gain`, `level_attack`, `level_release`, `wave_rings`, +`wave_speed`. + +## Tests + +```bash +.venv/bin/python -m pytest tests/ -v +``` + +## Architecture + +- `core.py` — config + utilitaires purs (seuil durée, nettoyage texte). +- `audio_level.py` — niveau RMS du micro (alimente l'animation). +- `engine.py` — capture, transcription MLX-Whisper, collage, sons. +- `pulse_window.py` — fenêtre Cocoa + cercle pulsant + anneaux d'onde. +- `controller.py` — machine à états, branche audio → animation → transcription. +- `app.py` — app de barre de menu (point d'entrée). + +L'icône de l'app est générée par script : `python assets/make_icon.py && ./assets/make_icns.sh`. + +## Licence + +MIT — voir [LICENSE](LICENSE). diff --git a/app.py b/app.py new file mode 100644 index 0000000..378ea1f --- /dev/null +++ b/app.py @@ -0,0 +1,87 @@ +"""Point d'entrée : application macOS de barre de menu (rumps). + +Possède la boucle d'événements Cocoa. Enregistre la hotkey globale, gère l'état du menu, +lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de pulsation. + +La politique d'activation est forcée en « accessory » : Zonza vit dans la barre de menu, +jamais dans le Dock — même quand la fenêtre de pulsation Cocoa s'affiche. +""" +import threading + +import rumps +from AppKit import NSApplication, NSApplicationActivationPolicyAccessory +from PyObjCTools import AppHelper +from pynput import keyboard + +from controller import ZonzaController +from core import CONFIG, validate_config +from pulse_window import PulseWindow + +_STATE_TITLES = { + "idle": "🎙️", + "recording": "🔴", + "transcribing": "⏳", +} + + +class ZonzaApp(rumps.App): + def __init__(self): + super().__init__("🎙️", quit_button=None) + self.pulse = PulseWindow.alloc().init() + self.controller = ZonzaController( + CONFIG, self.pulse, on_state=self._set_state, + run_on_main=AppHelper.callAfter, + ) + self.menu = [ + rumps.MenuItem("Sons", callback=self._toggle_sounds), + None, + rumps.MenuItem("Quitter", callback=self._quit), + ] + self.menu["Sons"].state = CONFIG["sounds"] + self._start_hotkey() + self._warmup_async() + + def _set_state(self, state): + # appelé depuis des threads de fond (transcription) ; toucher le titre du menu + # (AppKit) doit se faire sur le thread principal → on marshale via callAfter. + AppHelper.callAfter(self._apply_title, state) + + def _apply_title(self, state): + self.title = _STATE_TITLES.get(state, "🎙️") + + def _toggle_sounds(self, sender): + CONFIG["sounds"] = not CONFIG["sounds"] + sender.state = CONFIG["sounds"] + + def _quit(self, _): + rumps.quit_application() + + def _start_hotkey(self): + # pynput exécute ce callback sur SON thread d'écoute, pas le thread principal. + # toggle() touche NSWindow/NSTimer (Cocoa) → on marshale sur le thread principal. + def on_activate(): + AppHelper.callAfter(self.controller.toggle) + self._listener = keyboard.GlobalHotKeys({CONFIG["hotkey"]: on_activate}) + self._listener.start() + + def _warmup_async(self): + def run(): + try: + self.controller.transcriber.warmup() + except Exception as e: + print(f"[erreur] warmup modèle : {e}") + rumps.notification("Zonza", "Modèle", f"Échec du chargement : {e}") + threading.Thread(target=run, daemon=True).start() + + +def main(): + validate_config(CONFIG) + # barre de menu uniquement, jamais d'icône Dock — même en lançant depuis les sources + NSApplication.sharedApplication().setActivationPolicy_( + NSApplicationActivationPolicyAccessory + ) + ZonzaApp().run() + + +if __name__ == "__main__": + main() diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 0000000..b952996 Binary files /dev/null and b/assets/icon.icns differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..546c4f5 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/make_icns.sh b/assets/make_icns.sh new file mode 100755 index 0000000..2146a55 --- /dev/null +++ b/assets/make_icns.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Convertit assets/icon.png (1024²) en assets/icon.icns (multi-résolution macOS). +# Utilise sips + iconutil (natifs macOS). Lancer : ./assets/make_icns.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +PNG="$HERE/icon.png" +ICONSET="$HERE/icon.iconset" +ICNS="$HERE/icon.icns" + +[ -f "$PNG" ] || { echo "icon.png manquant — lance d'abord make_icon.py"; exit 1; } +rm -rf "$ICONSET"; mkdir -p "$ICONSET" + +# tailles requises par iconutil (px et @2x) +for SZ in 16 32 64 128 256 512; do + sips -z $SZ $SZ "$PNG" --out "$ICONSET/icon_${SZ}x${SZ}.png" >/dev/null + DBL=$((SZ * 2)) + sips -z $DBL $DBL "$PNG" --out "$ICONSET/icon_${SZ}x${SZ}@2x.png" >/dev/null +done + +iconutil -c icns "$ICONSET" -o "$ICNS" +rm -rf "$ICONSET" +echo "icns écrit : $ICNS" diff --git a/assets/make_icon.py b/assets/make_icon.py new file mode 100644 index 0000000..24f8e58 --- /dev/null +++ b/assets/make_icon.py @@ -0,0 +1,64 @@ +"""Génère assets/icon.png (1024×1024) : un micro stylisé sur un carré violet dégradé. + +Reproductible et sans dépendance externe (Pillow seulement). Lancer : python assets/make_icon.py +""" +import os + +from PIL import Image, ImageDraw + +SIZE = 1024 +BG_TOP = (130, 80, 235) # violet +BG_BOT = (85, 50, 180) # violet plus foncé (dégradé vertical) +MIC = (255, 255, 255) + + +def _rounded_gradient(size, radius): + """Carré arrondi avec dégradé vertical.""" + base = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + grad = Image.new("RGBA", (size, size)) + for y in range(size): + t = y / (size - 1) + r = int(BG_TOP[0] * (1 - t) + BG_BOT[0] * t) + g = int(BG_TOP[1] * (1 - t) + BG_BOT[1] * t) + b = int(BG_TOP[2] * (1 - t) + BG_BOT[2] * t) + for x in range(size): + grad.putpixel((x, y), (r, g, b, 255)) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255) + base.paste(grad, (0, 0), mask) + return base + + +def make_icon(path): + img = _rounded_gradient(SIZE, radius=int(SIZE * 0.22)) + d = ImageDraw.Draw(img) + cx = SIZE / 2 + # corps du micro (capsule arrondie) + mic_w, mic_h = SIZE * 0.26, SIZE * 0.40 + top = SIZE * 0.20 + d.rounded_rectangle( + [cx - mic_w / 2, top, cx + mic_w / 2, top + mic_h], + radius=mic_w / 2, fill=MIC, + ) + # arceau (support en U sous le micro) + arc_r = SIZE * 0.20 + arc_cy = top + mic_h * 0.55 + d.arc( + [cx - arc_r, arc_cy - arc_r, cx + arc_r, arc_cy + arc_r], + start=20, end=160, fill=MIC, width=int(SIZE * 0.035), + ) + # pied du micro + foot_top = arc_cy + arc_r + d.line([cx, foot_top, cx, foot_top + SIZE * 0.10], fill=MIC, width=int(SIZE * 0.035)) + d.line( + [cx - SIZE * 0.10, foot_top + SIZE * 0.10, cx + SIZE * 0.10, foot_top + SIZE * 0.10], + fill=MIC, width=int(SIZE * 0.035), + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path) + print(f"icône écrite : {path}") + + +if __name__ == "__main__": + here = os.path.dirname(os.path.abspath(__file__)) + make_icon(os.path.join(here, "icon.png")) diff --git a/audio_level.py b/audio_level.py new file mode 100644 index 0000000..a22fc58 --- /dev/null +++ b/audio_level.py @@ -0,0 +1,30 @@ +"""Pont audio → animation : calcule un niveau de volume normalisé. + +Unité pure (numpy seulement) — aucune dépendance matérielle, testable sans micro. +""" +import numpy as np + + +def compute_rms(frames): + """Niveau RMS d'un bloc audio float32, normalisé et borné dans [0.0, 1.0]. + + frames : tableau numpy 1-D d'échantillons float32 (typiquement dans [-1, 1]). + Retourne 0.0 pour un bloc vide ou silencieux. + """ + if frames.size == 0: + return 0.0 + rms = float(np.sqrt(np.mean(np.square(frames, dtype="float64")))) + return min(1.0, max(0.0, rms)) + + +def shape_level(rms, gain): + """Met en forme un niveau RMS brut pour l'animation : amplifie et borne dans [0, 1]. + + Les RMS vocaux sont faibles (~0.02–0.15) ; appliqués tels quels, le cercle bouge à + peine. On applique un gain puis une courbe en racine carrée : les niveaux faibles sont + boostés (forte réactivité à voix normale) tout en restant bornés à 1.0 (pas de + saturation visuelle). gain=0 → toujours 0 (aucune réaction). + """ + boosted = max(0.0, rms) * gain + shaped = boosted ** 0.5 + return min(1.0, max(0.0, shaped)) diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..472edbf --- /dev/null +++ b/build_app.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Construit Zonza.app — un LANCEUR léger (pas un bundle autonome). +# +# Pourquoi un lanceur plutôt que PyInstaller/py2app : empaqueter MLX + multiprocessing + +# hotkey globale sans compte développeur Apple se bat en permanence contre macOS (bombe à +# fork, permissions TCC réinitialisées à chaque rebuild, shaders Metal non bundlés). Le +# lanceur exécute le code via le venv du dépôt, où TOUT fonctionne de façon stable. +# +# RÈGLE DE PROPRETÉ : ce script est l'UNIQUE chemin de build. Il construit DIRECTEMENT +# dans /Applications — jamais dans /tmp ni dans le dépôt. C'est ce qui garantit qu'une +# seule entrée existe dans LaunchServices (l'historique de builds éparpillés est ce qui +# avait créé des icônes en double avec l'ancienne app Dictée). +# +# Limite : non distribuable seul (l'utilisateur doit cloner le dépôt + créer le venv). +# +# Usage : ./build_app.sh → installe directement /Applications/Zonza.app +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +APP="/Applications/Zonza.app" +CONTENTS="$APP/Contents" +PYBIN="$HERE/.venv/bin/python3" + +[ -x "$PYBIN" ] || { echo "Erreur : venv introuvable ($PYBIN). Lance : python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"; exit 1; } + +# icône (générée si absente) +[ -f "$HERE/assets/icon.icns" ] || { "$PYBIN" "$HERE/assets/make_icon.py" && "$HERE/assets/make_icns.sh"; } + +echo "→ construction du lanceur $APP …" +rm -rf "$APP" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" + +# exécutable : un script qui lance l'app via le python du venv (PWD = dépôt) +cat > "$CONTENTS/MacOS/zonza" < "$CONTENTS/Info.plist" <<'PLIST' + + + + + CFBundleNameZonza + CFBundleDisplayNameZonza + CFBundleIdentifiercloud.mrtechlab.zonza + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutablezonza + CFBundleIconFileicon + LSUIElement + NSMicrophoneUsageDescription + Zonza a besoin du micro pour transcrire votre voix localement. + + +PLIST + +# enregistrement propre dans LaunchServices (remplace toute entrée précédente du même chemin) +LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister +"$LSREGISTER" -f "$APP" 2>/dev/null || true +touch "$APP" + +echo "✓ $APP installé." +echo " Lance Zonza depuis Spotlight, accorde Surveillance de l'entrée + Accessibilité une" +echo " fois (à 'python3' ou 'Zonza' selon ce que macOS propose), c'est définitif." diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..fd80bc4 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# présence à la racine : garantit que pytest ajoute le dossier du projet à sys.path, +# pour que les tests importent les modules (core, audio_level) sans installation. diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..f868409 --- /dev/null +++ b/controller.py @@ -0,0 +1,125 @@ +"""Chef d'orchestre : machine à états idle <-> recording. + +Branche le micro sur l'animation (niveau RMS → cercle) pendant l'enregistrement, puis +lance la transcription sur un thread de fond et colle le texte. Ne meurt jamais sur une +erreur : tout est attrapé et ramène à idle (fenêtre fermée). + +Les méthodes touchant l'UI (PulseWindow show/hide) doivent être invoquées sur le thread +principal — c'est le rôle de l'appelant (app via la hotkey, qui s'exécute déjà dans le +contexte de l'app). La transcription, elle, part sur un thread de fond. +""" +import threading + +from audio_level import compute_rms, shape_level +from core import is_long_enough +from engine import Recorder, Transcriber, inject_text, play_sound + + +class ZonzaController: + """Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu).""" + + def __init__(self, config, pulse_window, on_state=None, run_on_main=None): + self.config = config + self.pulse = pulse_window + self.on_state = on_state or (lambda s: None) + # run_on_main : marshale un appel sur le thread principal Cocoa (requis pour l'UI). + # Par défaut exécute directement (utile pour les tests sans boucle Cocoa). + self.run_on_main = run_on_main or (lambda fn: fn()) + self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block) + self.transcriber = Transcriber(config["model"], config["language"]) + self.recording = False + self._lock = threading.Lock() + self._smoothed_level = 0.0 + self._safety_timer = None # arrêt auto si l'enregistrement traîne trop + + def _on_audio_block(self, block): + # thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release + # → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée + # rapide quand on parle, retombée douce dans les silences. + target = shape_level(compute_rms(block), self.config["level_gain"]) + if target > self._smoothed_level: + coef = self.config["level_attack"] + else: + coef = self.config["level_release"] + self._smoothed_level += (target - self._smoothed_level) * coef + self.pulse.set_level(self._smoothed_level) + + def toggle(self): + with self._lock: + if not self.recording: + self._start() + else: + self._stop_and_process() + + def _start(self): + try: + self._smoothed_level = 0.0 + self.recorder.start() + self.recording = True + self.pulse.show() + play_sound("start") + self.on_state("recording") + self._arm_safety_timer() + except Exception as e: + print(f"[erreur] démarrage enregistrement : {e}") + play_sound("error") + self.recording = False + self.pulse.hide() + self.on_state("idle") + + def _arm_safety_timer(self): + # sécurité : si l'enregistrement n'est pas arrêté manuellement après max_duration_s, + # on l'arrête automatiquement → la fenêtre du cercle ne peut jamais rester figée. + self._cancel_safety_timer() + self._safety_timer = threading.Timer( + self.config["max_duration_s"], self._safety_stop + ) + self._safety_timer.daemon = True + self._safety_timer.start() + + def _cancel_safety_timer(self): + if self._safety_timer is not None: + self._safety_timer.cancel() + self._safety_timer = None + + def _safety_stop(self): + # déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc + # on marshale toggle() sur le thread principal Cocoa. + print("[sécurité] durée max atteinte → arrêt auto de l'enregistrement") + self.run_on_main(self.toggle) + + def _stop_and_process(self): + self.recording = False + 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.on_state("idle") + return + self.on_state("transcribing") + threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start() + except Exception as e: + print(f"[erreur] arrêt enregistrement : {e}") + play_sound("error") + self.pulse.hide() + self.on_state("idle") + + def _transcribe_and_inject(self, audio): + # thread de fond : transcription lente + collage + try: + text = self.transcriber.transcribe(audio) + if not text: + return + print(f"[texte] {text}") + try: + inject_text(text) + except Exception as e: + print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V") + play_sound("error") + except Exception as e: + print(f"[erreur] transcription : {e}") + play_sound("error") + finally: + self.on_state("idle") diff --git a/core.py b/core.py new file mode 100644 index 0000000..95bef4a --- /dev/null +++ b/core.py @@ -0,0 +1,46 @@ +"""Unités pures de Zonza — aucune dépendance matérielle. + +Tout ce qui est ici est testable sans micro, sans clavier, sans modèle. +""" + +import re + +CONFIG = { + "model": "mlx-community/whisper-medium", + "language": "fr", + "hotkey": "++d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent) + "sounds": True, + "min_duration_s": 0.3, + "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), + "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 +} + + +def validate_config(config): + """Valide la config ; lève ValueError si une valeur est aberrante.""" + if config["min_duration_s"] < 0: + raise ValueError("min_duration_s doit être >= 0") + if config["sample_rate"] <= 0: + raise ValueError("sample_rate doit être > 0") + if not config["hotkey"]: + raise ValueError("hotkey ne peut pas être vide") + + +def is_long_enough(num_samples, sample_rate, min_duration_s): + """Renvoie True si l'enregistrement dépasse le seuil de durée minimal.""" + duration_s = num_samples / sample_rate + return duration_s >= min_duration_s + + +def clean_transcript(text): + """Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés.""" + return re.sub(r"\s+", " ", text).strip() diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..4ee46a7 --- /dev/null +++ b/engine.py @@ -0,0 +1,112 @@ +"""Moteur de dictée — capture, transcription, collage, son. + +Aucune dépendance UI : importable par le contrôleur et l'app. +""" +import subprocess +import time + +import mlx_whisper +import numpy as np +import pyperclip +import sounddevice as sd +from pynput.keyboard import Controller, Key + +from core import CONFIG, clean_transcript + +SOUNDS = { + "start": "/System/Library/Sounds/Pop.aiff", + "done": "/System/Library/Sounds/Glass.aiff", + "error": "/System/Library/Sounds/Basso.aiff", +} + +_kbd = Controller() + + +def play_sound(name): + """Joue un son système macOS sans bloquer. Silencieux si CONFIG['sounds'] est False.""" + if not CONFIG["sounds"]: + return + path = SOUNDS.get(name) + if not path: + return + try: + subprocess.Popen(["afplay", path]) + except Exception: + pass # le son ne doit jamais casser le daemon + + +def inject_text(text): + """Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.""" + if not text: + return + pyperclip.copy(text) + time.sleep(0.05) # laisser le presse-papier se mettre à jour + with _kbd.pressed(Key.cmd): + _kbd.press("v") + _kbd.release("v") + + +class Recorder: + """Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur. + + on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc + (utilisé pour alimenter l'animation en temps réel). + """ + + def __init__(self, sample_rate, on_block=None): + self.sample_rate = sample_rate + self.on_block = on_block + self._frames = [] + self._stream = None + + def _callback(self, indata, frames, time_info, status): + block = indata.copy() + self._frames.append(block) + if self.on_block is not None: + self.on_block(block.flatten()) + + def start(self): + self._frames = [] + self._stream = sd.InputStream( + samplerate=self.sample_rate, + channels=1, + dtype="float32", + callback=self._callback, + ) + self._stream.start() + + def stop(self): + """Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32).""" + if self._stream is not None: + self._stream.stop() + self._stream.close() + self._stream = None + if not self._frames: + return np.zeros(0, dtype="float32") + return np.concatenate(self._frames, axis=0).flatten() + + +class Transcriber: + """Transcrit l'audio en français avec MLX-Whisper. + + Note : mlx_whisper.transcribe charge/met en cache le modèle au premier appel. + Le premier lancement télécharge le modèle depuis Hugging Face. + """ + + def __init__(self, model, language): + self.model = model + self.language = language + + def warmup(self): + """Force le téléchargement/chargement du modèle au démarrage (1 fois).""" + silence = np.zeros(16000, dtype="float32") + self.transcribe(silence) + + def transcribe(self, audio): + """Transcrit l'audio (numpy float32) et renvoie le texte nettoyé.""" + result = mlx_whisper.transcribe( + audio, + path_or_hf_repo=self.model, + language=self.language, + ) + return clean_transcript(result.get("text", "")) diff --git a/pulse_window.py b/pulse_window.py new file mode 100644 index 0000000..69642c9 --- /dev/null +++ b/pulse_window.py @@ -0,0 +1,165 @@ +"""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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8f9c81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +mlx-whisper>=0.4.3 +sounddevice>=0.5.5 +numpy>=2.4 +pynput>=1.8.2 +pyperclip>=1.11 +rumps>=0.4.0 +pillow>=11 +pytest>=8.0 diff --git a/tests/test_audio_level.py b/tests/test_audio_level.py new file mode 100644 index 0000000..145e8e5 --- /dev/null +++ b/tests/test_audio_level.py @@ -0,0 +1,56 @@ +import numpy as np + +from audio_level import compute_rms, shape_level + + +def test_silence_is_zero(): + frames = np.zeros(1000, dtype="float32") + assert compute_rms(frames) == 0.0 + + +def test_empty_is_zero(): + frames = np.zeros(0, dtype="float32") + assert compute_rms(frames) == 0.0 + + +def test_full_scale_is_one(): + # signal constant à l'amplitude max → RMS normalisé proche de 1.0 + frames = np.ones(1000, dtype="float32") + assert compute_rms(frames) == 1.0 + + +def test_result_is_bounded(): + # même un signal qui dépasse [-1, 1] reste borné à 1.0 + frames = np.full(1000, 5.0, dtype="float32") + level = compute_rms(frames) + assert 0.0 <= level <= 1.0 + + +def test_louder_gives_higher_level(): + quiet = np.full(1000, 0.1, dtype="float32") + loud = np.full(1000, 0.5, dtype="float32") + assert compute_rms(loud) > compute_rms(quiet) + + +def test_shape_level_zero_stays_zero(): + assert shape_level(0.0, gain=8.0) == 0.0 + + +def test_shape_level_boosts_quiet_voice(): + # un RMS vocal typique (~0.05) doit devenir nettement perceptible après mise en forme + shaped = shape_level(0.05, gain=8.0) + assert shaped > 0.3 + + +def test_shape_level_is_bounded(): + # un niveau déjà fort reste borné à 1.0 (pas de débordement) + assert shape_level(0.9, gain=8.0) == 1.0 + + +def test_shape_level_monotonic(): + assert shape_level(0.2, gain=8.0) > shape_level(0.05, gain=8.0) + + +def test_shape_level_gain_zero_is_zero(): + # gain nul → aucune réaction + assert shape_level(0.5, gain=0.0) == 0.0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..c0801f3 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,61 @@ +from core import CONFIG, validate_config, is_long_enough, clean_transcript + + +def test_config_has_required_keys(): + for key in ("model", "language", "hotkey", "sounds", "min_duration_s", "sample_rate"): + assert key in CONFIG + + +def test_config_defaults(): + assert CONFIG["language"] == "fr" + assert CONFIG["min_duration_s"] == 0.3 + assert CONFIG["sample_rate"] == 16000 + + +def test_validate_config_accepts_default(): + validate_config(CONFIG) # ne doit pas lever + + +def test_validate_config_rejects_bad_duration(): + import pytest + bad = dict(CONFIG, min_duration_s=-1) + with pytest.raises(ValueError): + validate_config(bad) + + +def test_is_long_enough_true(): + # 1 seconde d'audio à 16000 Hz = 16000 échantillons + assert is_long_enough(16000, sample_rate=16000, min_duration_s=0.3) is True + + +def test_is_long_enough_false_too_short(): + # 0.1 s = 1600 échantillons < seuil 0.3 s + assert is_long_enough(1600, sample_rate=16000, min_duration_s=0.3) is False + + +def test_is_long_enough_empty(): + assert is_long_enough(0, sample_rate=16000, min_duration_s=0.3) is False + + +def test_clean_transcript_trims(): + assert clean_transcript(" bonjour ") == "bonjour" + + +def test_clean_transcript_collapses_internal_whitespace(): + assert clean_transcript("crée moi un\noutil") == "crée moi un outil" + + +def test_clean_transcript_empty_stays_empty(): + assert clean_transcript(" ") == "" + + +def test_config_has_ui_keys(): + for key in ("fps", "circle_base_radius", "circle_amplitude", "circle_color_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