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:
commit
22080eb997
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.DS_Store
|
||||
assets/icon.iconset/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
79
README.md
Normal 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
87
app.py
Normal 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
BIN
assets/icon.icns
Normal file
Binary file not shown.
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
22
assets/make_icns.sh
Executable file
22
assets/make_icns.sh
Executable 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
64
assets/make_icon.py
Normal 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
30
audio_level.py
Normal 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.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))
|
||||
71
build_app.sh
Executable file
71
build_app.sh
Executable 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
2
conftest.py
Normal 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
125
controller.py
Normal 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
46
core.py
Normal 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 (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()
|
||||
112
engine.py
Normal file
112
engine.py
Normal 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
165
pulse_window.py
Normal 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
8
requirements.txt
Normal 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
56
tests/test_audio_level.py
Normal 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
61
tests/test_core.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user