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