# Projet — Transcription de réunions 100% locale (macOS) > Document de passation. Contient l'état du projet, les décisions prises, > ce qui est déjà validé, et les prochaines étapes. --- ## 1. Objectif Transcrire les réunions en local, sans aucun service SaaS, pour pouvoir rester concentré sur l'écoute plutôt que sur la prise de notes. Objectif secondaire (étape 2) : analyser le contenu en temps réel et suggérer des questions à poser pendant la réunion. ## 2. Contraintes | Contrainte | Détail | |---|---| | **Aucun cloud** | Tout doit tourner en local. Pas d'API externe, pas de SaaS. | | **Pas d'outils natifs** | Les transcriptions Zoom / Meet / Teams sont exclues. | | **Multilingue** | Réunions en français et anglais, souvent mélangés dans la même phrase (code-switching). | | **Anglais approximatif** | Locuteurs non-natifs, accents marqués. Le modèle doit être robuste. | | **Vocabulaire technique** | Jargon métier, acronymes internes, noms de produits. Doit être enrichissable. | | **Temps réel** | Idéalement live, pour permettre l'analyse en cours de réunion. | ## 3. Matériel - **MacBook Pro M4 Max, 128 Go RAM** — machine de dev (celle où tourne ce repo). - **Cible réelle pour l'app de transcription (2026-08-07) : MacBook M3 simple, 24 Go RAM.** Le dimensionnement des modèles (taille, quantization) doit être calé sur cette machine, pas sur le M4 Max — voir §6.4/discussion architecture pour le budget mémoire revu en conséquence (modèles 4-bit, éviter les gros modèles de relecture). - **iPhone récent** — usage limité (voir §7). - Plateformes de réunion : Google Meet, Zoom, MS Teams. --- ## 4. Architecture cible ``` ┌─────────────────────────────────────────────────────────┐ │ CAPTURE — audiotee (fork), un process, 2 sorties │ │ ├─ Core Audio process tap → stdout = piste système │ │ └─ device d'entrée (mic) → --mic-output = piste micro│ │ → 2 pistes séparées = diarisation "moi vs eux" │ └──────────────┬──────────────────────────┬───────────────┘ │ PCM 16 kHz mono, 16-bit LE (système + micro) ┌───────────▼──────────────┐ ┌─────────▼─────────────────┐ │ SEGMENTATION (par piste)│ │ SEGMENTATION (par piste) │ │ VAD Silero → chunks │ │ VAD Silero → chunks │ │ 3-5 s avec recouvrement │ │ 3-5 s avec recouvrement │ └───────────┬──────────────┘ └─────────┬──────────────────┘ ┌───────────▼──────────────┐ ┌─────────▼──────────────────┐ │ ASR — Qwen3-ASR-1.7B │ │ ASR — Qwen3-ASR-1.7B │ │ (--stream), 1 process │ │ (--stream), 1 process │ │ couche 1 : biasing │ │ couche 1 : biasing │ │ lexical (glossaire) │ │ lexical (glossaire) │ └───────────┬──────────────┘ └─────────┬──────────────────┘ │ segments horodatés (track, text, is_final, ts) └──────────────┬───────────┘ ┌─────────▼──────────────────────┐ │ MERGE + POST-TRAITEMENT │ │ ├─ couche 2 : fuzzy matching │ │ ├─ couche 3 : relecture LLM │ │ │ local (Qwen3 via MLX) │ │ └─ publie en SSE (API stream) │ └─────────┬────────────────────────┘ │ terminal (1er client SSE) (page web / analyse phase 2 : autres clients SSE possibles plus tard, sans toucher au pipeline) ``` ### Choix ASR : Qwen3-ASR plutôt que Whisper **Pourquoi :** - Code-switching natif sur 11+ langues — Whisper force à choisir une langue. - Mode streaming natif (fenêtre d'attention dynamique 1–8 s) : le même modèle fait offline et temps réel. WER 4.51 en streaming vs 3.38 en offline sur LibriSpeech-other — dégradation acceptable. - Biasing lexical par texte arbitraire, sans limite stricte. Whisper est plafonné à 224 tokens d'`initial_prompt`, avec un poids inégal entre termes (ceux placés en fin de prompt comptent davantage). - La version 1.7B tourne confortablement sur M4 Max. **Runtimes possibles :** - MLX - Implémentation C d'antirez (`qwen-asr`) — expose déjà `--stream` (chunks avec rollback de préfixe et fenêtre glissante) et `--prompt` pour le biasing. **Fallback si la qualité déçoit sur l'audio réel :** Whisper large-v3-turbo via whisper.cpp (Metal) ou mlx-whisper. Moins de biasing, à compenser par les couches 2 et 3. ### Stratégie vocabulaire technique — 3 couches 1. **Glossaire en prompt (soft).** Liste courte et *contextuelle à la réunion*, pas le lexique entier. L'effet est probabiliste, pas déterministe : sur des sons proches, le modèle peut dériver malgré le prompt. Sélectionner des termes pertinents rend chaque mot plus efficace que d'en injecter beaucoup au hasard. 2. **Post-correction déterministe.** Fuzzy matching sur dictionnaire maison (`k8s` → `kubernetes`, noms de projets, acronymes internes). Peu coûteux, très rentable. 3. **Relecture LLM local.** Qwen3 via MLX sur le transcript glissant, glossaire en contexte. Corrige aussi la ponctuation et l'anglais approximatif. --- ## 5. État actuel ### ✅ Validé - **audiotee** compilé et fonctionnel (`swift build -c release`). - Capture de l'audio système confirmée depuis **Terminal.app**, après consentement TCC. - Format de sortie retenu : `--sample-rate 16000` → PCM 16-bit signé LE, mono. C'est exactement le format d'entrée attendu par Qwen3-ASR. - Vérification par `ffplay -f s16le -ar 16000 test.pcm` et `volumedetect`. ### 🔲 À faire - [x] Piste micro en parallèle (voir §6.1) — implémenté nativement dans audiotee (fork), pas via un process ffmpeg/AVAudioEngine séparé - [x] Bundle `.app` signé pour audiotee (voir §6.2) — certificat `sttlab-apps` créé en CLI, `~/bin/audiotee` signé avec (`Authority=sttlab-apps`, plus d'ad-hoc), Info.plist embarqué confirmé (`CFBundleIdentifier`, 6 entrées) - [ ] Segmentation VAD - [ ] Intégration Qwen3-ASR en streaming - [ ] Benchmark Qwen3-ASR vs Whisper large-v3-turbo sur audio réel - [ ] Couches de post-correction - [ ] Agent d'analyse / suggestion de questions --- ## 6. Prochaines étapes ### 6.1 Piste micro (priorité 1) — ✅ fait audiotee (ce fork) capture maintenant aussi le micro, en plus de l'audio système. Implémentation : `InputDeviceResolver.defaultInputDevice()` résout le device d'entrée par défaut via `kAudioHardwarePropertyDefaultInputDevice` — pas besoin de `CATapDescription` ni de device agrégé pour ça (contrairement à l'audio système), donc plus simple que le chemin existant. `AudioRecorder` était déjà agnostique de la source (juste un `deviceID` + `outputHandler`), donc réutilisé tel quel pour faire tourner un deuxième pipeline en parallèle du premier. ```bash # Système sur stdout, micro dans un fichier séparé audiotee --capture-mic --mic-output mic.pcm > system.pcm ``` Point de design important : les deux pistes sortent sur **deux flux séparés**, pas multiplexées sur un seul stdout. Deux `AudioRecorder` tournent sur des threads IO Core Audio temps réel indépendants ; les entrelacer sur un seul fd aurait risqué de corrompre les deux flux (write() non garanti atomique au-delà de `PIPE_BUF`, largement dépassé au sample rate natif). Chaque piste garde son écriture atomique par chunk telle qu'elle existait déjà. Horodatage : chaque piste a son propre `stream_start` (timestamp mural, sur stderr, en JSON), étiqueté `"audio"` ou `"mic"` pour les distinguer. Il n'y a pas encore de timestamp par chunk audio (le flux stdout reste du PCM brut, sans framing, pour rester zero-copy) — le réalignement précis en aval devra dériver le timestamp de chaque chunk à partir de `stream_start` + position cumulée dans le flux (nb d'échantillons / sample rate). Bug corrigé au passage : `SIGINT`/`SIGTERM` pouvaient arriver pendant la phase de setup (avant que la run loop ne démarre), auquel cas `CFRunLoopStop` n'avait aucun effet durable et le process restait bloqué indéfiniment. Le setup à deux pistes rend cette fenêtre bien plus large qu'avant (fix : flag `shouldStop` vérifié avant d'entrer dans la boucle). Permission TCC : `--capture-mic` déclenche le prompt micro standard (catégorie différente de `NSAudioCaptureUsageDescription`, sans les pièges spécifiques aux process taps du §8). ### 6.2 Bundle `.app` signé (priorité 2) **Décision d'architecture (2026-08-07) :** l'orchestrateur VAD/ASR sera en **Python**, pas en Swift. audiotee sera donc consommé en **sous-process** (pipe stdout, comme décrit dans son README), pas embarqué comme bibliothèque Swift (`AudioTeeCore` est bien exposée comme library product, mais ça ne s'applique que si le consommateur est du code Swift — ce qui n'est pas le cas ici). Conséquence directe : c'est audiotee (le binaire réellement exécuté) qui appelle les API Core Audio, donc c'est **son** identité de signature qui doit être stable pour TCC — pas celle de l'app Python. Le travail ci-dessous reste donc scopé à audiotee seul, indépendant de tout packaging que l'app de transcription Python devra faire de son côté plus tard (qui n'aura probablement besoin d'aucune des deux clés `NSAudioCaptureUsageDescription` / `NSMicrophoneUsageDescription`, puisqu'elle ne fait qu'orchestrer un sous-process). **Problème actuel :** l'autorisation TCC est portée par Terminal.app, pas par audiotee. Conséquence : tout ce qui est lancé depuis Terminal hérite de l'accès à l'audio système. C'est trop large, et ça bloquera un lancement depuis un agent au login ou un raccourci. **Implémenté (2026-08-07) :** pas de bundle `.app` complet — un simple binaire CLI avec Info.plist embarqué au link, plus simple à consommer en sous-process (pas de résolution de bundle nécessaire côté Python, juste le chemin du binaire). - `Sources/AudioTeeCLI/Info.plist` — `CFBundleIdentifier` (`com.stephanetailland.audiotee`), `NSAudioCaptureUsageDescription`, `NSMicrophoneUsageDescription`. - `Package.swift` — `linkerSettings` sur la cible `AudioTeeCLI` embarque ce plist via `-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist`. Vérifié avec `strings .build/release/audiotee | grep CFBundleIdentifier`. - `scripts/build-signed.sh` — build release, signe avec une identité stable (certificat auto-signé du Trousseau, détection automatique via `security find-identity`, ou `AUDIOTEE_SIGNING_IDENTITY` pour forcer), installe dans `~/bin/audiotee` (chemin fixe, cf. piège §8). Flag `--reset-tcc` pour relancer les prompts après un changement d'Info.plist ou d'identité (`tccutil reset SystemAudioCaptureRequests` + `Microphone`). **Certificat créé (2026-08-07), en CLI :** `scripts/create-signing-identity.sh sttlab-apps` (nom volontairement générique, pas spécifique à audiotee — un seul certificat sert pour tous les projets perso, cf. note ci-dessous sur la portée d'un certificat). L'utilisateur l'a exécuté lui-même (création de clé privée + import trousseau + confiance `codeSign` = actions sensibles, pas automatisées silencieusement). **Piège rencontré et corrigé :** `openssl pkcs12 -export` sans `-legacy` échoue à l'import macOS avec `MAC verification failed during PKCS12 import (wrong password?)` — message trompeur, ce n'est pas un problème de mot de passe. Cause : OpenSSL 3.x chiffre les PKCS12 en AES-256/SHA-256 par défaut, que `SecKeychainItemImport` ne sait pas lire ; il faut l'encodage RC2/3DES legacy (`-legacy` charge le provider OpenSSL correspondant). Déjà corrigé dans `create-signing-identity.sh`. **Vérifié fonctionnel :** `~/bin/audiotee` signé avec `Authority=sttlab-apps` (signature réelle, `flags=0x0(none)`, plus `adhoc`), `Identifier=com.stephanetailland.audiotee`, `Info.plist entries=6`. Capture réelle testée (système + micro, écoute via `ffplay`/`afplay` après conversion). Reste formellement à confirmer : que la permission **survit** à un rebuild+re-signature sans nouveau prompt TCC (attendu, vu la signature stable, mais pas encore explicitement vérifié sur plusieurs cycles). **Note (portée d'un certificat) :** un seul certificat de signature peut signer plusieurs apps différentes — TCC distingue les apps par `CFBundleIdentifier`, pas par certificat. Pas besoin d'un certificat dédié par projet ; `sttlab-apps` sera réutilisé pour les prochains outils perso, avec un identifiant différent à chaque fois. ### 6.3 Protocole de benchmark ASR Comparer Qwen3-ASR vs Whisper large-v3-turbo sur le **même** échantillon d'audio réel de réunion. Métriques : WER global, WER sur les termes du glossaire, latence, RTF. ### 6.4 Affichage live du transcript (priorité immédiate) **Décision (2026-08-07) :** priorité au **live** uniquement — l'analyse temps réel / suggestions de questions reste explicitement phase 2 (§1), pas à mélanger dans cette étape. **Cible d'affichage :** terminal pour commencer (le plus rapide à avoir, imprime les segments au fil de l'eau). Mais le pipeline VAD→ASR→merge doit publier son résultat via une **API de streaming** dès maintenant plutôt que d'écrire directement dans le terminal — le terminal devient le premier client de cette API, pas une sortie câblée en dur. Ça évite de re-architecturer le pipeline quand une page web (ou l'analyse phase 2) voudra s'y brancher. **Choix technique : SSE (Server-Sent Events), pas WebSocket.** Le flux est unidirectionnel (serveur → clients, aucun besoin de faire remonter des messages depuis un client pour l'instant) — SSE suffit : HTTP simple, testable au `curl`, consommable nativement par un navigateur (`EventSource`, zéro lib côté client) et par un client terminal Python basique. WebSocket serait sur-dimensionné tant qu'aucun besoin bidirectionnel n'apparaît. **Format des messages** (un par segment) : `{track: "system"|"mic", text, is_final, timestamp}`. `is_final` distingue une hypothèse partielle (streaming ASR, peut encore changer) d'un segment clos par la VAD. Pas encore implémenté — c'est le prochain chantier, côté projet Python (hors de ce repo audiotee). --- ## 7. Limite connue : iPhone iOS ne permet pas de capturer l'audio d'un appel ou d'une app tierce. En mobilité, on est limité au micro (réunion en présentiel, ou haut-parleur). Apps locales possibles : Aiko, Hello Transcribe, ou toute app basée sur WhisperKit. **Hors périmètre du développement actuel.** --- ## 8. Pièges connus (macOS / TCC) | Piège | Détail | |---|---| | **Deux catégories TCC distinctes** | « Enregistrement de l'écran et des sons du système » = ScreenCaptureKit. « Enregistrement des sons du système **uniquement** » = Core Audio process taps (`NSAudioCaptureUsageDescription`). C'est la seconde qui compte pour audiotee. | | **Signature obligatoire** | Les process taps exigent une identité de signature stable — TCC indexe dessus. Un binaire non signé compile mais ne capture rien : le prompt ne se déclenche jamais. Symptôme : tourne sans planter, enregistre du silence. | | **iTerm ne prompte pas toujours** | Terminal.app déclenche le prompt de façon fiable, iTerm non. Utiliser Terminal.app pour la première autorisation. | | **Impossible d'accorder TCC en CLI** | `tccutil` sait seulement **réinitialiser**, jamais accorder. La base TCC est protégée par SIP. | | **Pas d'API publique de permission** | Aucun moyen officiel de vérifier ou demander l'autorisation. Soit on déclenche le prompt au premier enregistrement, soit on passe par le TCC privé (voir approche AudioCap). | | **Chemin du binaire = identité** | TCC indexe sur le chemin. Laisser le binaire dans `.build/` risque de perdre l'autorisation à chaque rebuild. Copier dans `~/bin/`. | | **Atténuation des taps** | Gain négatif variable selon le nombre de paires stéréo du périphérique de sortie. ~0 dB sur HP intégrés / AirPods, jusqu'à ~-12 dB sur interface multi-sorties. À vérifier avec `volumedetect` sur la config réelle de réunion — un signal faible dégrade l'ASR. | | **Conversion = 16 bits** | Toute conversion de sample rate bascule la sortie en 16-bit signé (depuis 32-bit float). Sans importance pour l'ASR, mais comportement non évident. | | **API audiotee instable** | L'auteur prévient explicitement que l'API peut changer sans préavis. Pinner un commit. | | **Périphérique par défaut uniquement** | audiotee ne supporte que le périphérique de sortie par défaut. | ### Commandes de diagnostic utiles ```bash # Check whether capture actually produced sound (mean_volume ≈ -90 dB means silence) ffmpeg -f s16le -ar 16000 -ac 1 -i test.pcm -af volumedetect -f null - # Convert raw PCM to WAV for inspection ffmpeg -f s16le -ar 16000 -ac 1 -i test.pcm test.wav # Force the TCC prompt to reappear tccutil reset SystemAudioCaptureRequests # Open the right Settings pane directly open "x-apple.systempreferences:com.apple.preference.security?Privacy_AudioCapture" # Inspect current TCC state (requires Full Disk Access) sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \ "select service, client, auth_value from access where service like '%Audio%';" ``` --- ## 9. Références | Ressource | URL | |---|---| | audiotee | https://github.com/makeusabrew/audiotee | | audiotee.js (wrapper Node) | https://github.com/makeusabrew/audioteejs | | AudioCap (TCC probing) | https://github.com/insidegui/AudioCap | | Apple — Core Audio taps | https://developer.apple.com/documentation/CoreAudio/capturing-system-audio-with-core-audio-taps | | Apple — NSAudioCaptureUsageDescription | https://developer.apple.com/documentation/bundleresources/information-property-list/nsaudiocaptureusagedescription | | talat (référence : même archi, produit fini) | https://talat.app | --- ## 10. Conventions - **Code et commentaires en anglais.** - Réponses / documentation en français. - Cible : macOS 14.4+ (requis pour la bonne catégorie TCC des process taps). - Swift 5.9+ (Command Line Tools suffisent, pas besoin de Xcode complet).