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>
This commit is contained in:
Ralph Mayola 2026-06-12 23:57:59 +02:00
commit 22080eb997
18 changed files with 955 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.DS_Store
assets/icon.iconset/

21
LICENSE Normal file
View File

@ -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.

79
README.md Normal file
View File

@ -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).

87
app.py Normal file
View File

@ -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()

BIN
assets/icon.icns Normal file

Binary file not shown.

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

22
assets/make_icns.sh Executable file
View File

@ -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"

64
assets/make_icon.py Normal file
View File

@ -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"))

30
audio_level.py Normal file
View File

@ -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.020.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))

71
build_app.sh Executable file
View File

@ -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" <<LAUNCHER
#!/bin/bash
cd "$HERE"
exec "$PYBIN" "$HERE/app.py"
LAUNCHER
chmod +x "$CONTENTS/MacOS/zonza"
# icône
cp "$HERE/assets/icon.icns" "$CONTENTS/Resources/icon.icns"
# Info.plist : app de barre de menu (pas d'icône Dock) + permission micro + icône
cat > "$CONTENTS/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Zonza</string>
<key>CFBundleDisplayName</key><string>Zonza</string>
<key>CFBundleIdentifier</key><string>cloud.mrtechlab.zonza</string>
<key>CFBundleVersion</key><string>1.0</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>zonza</string>
<key>CFBundleIconFile</key><string>icon</string>
<key>LSUIElement</key><true/>
<key>NSMicrophoneUsageDescription</key>
<string>Zonza a besoin du micro pour transcrire votre voix localement.</string>
</dict>
</plist>
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."

2
conftest.py Normal file
View File

@ -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.

125
controller.py Normal file
View File

@ -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")

46
core.py Normal file
View File

@ -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": "<cmd>+<ctrl>+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 (01, 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()

112
engine.py Normal file
View File

@ -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", ""))

165
pulse_window.py Normal file
View File

@ -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)

8
requirements.txt Normal file
View File

@ -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

56
tests/test_audio_level.py Normal file
View File

@ -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

61
tests/test_core.py Normal file
View File

@ -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