add microphone capture and stable code signing for TCC persistence

Adds --capture-mic/--mic-output for a second, independently-captured audio
track (mic vs system, written to separate outputs to avoid interleaving
corruption). Embeds Info.plist at link time so the binary carries a stable
CFBundleIdentifier and the usage-description keys TCC requires, and adds
scripts/build-signed.sh + scripts/create-signing-identity.sh so a rebuilt
binary keeps the same signing identity instead of losing granted
permissions on every rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sttlab-tech
2026-08-09 13:10:08 +02:00
parent 56ac954369
commit 678557caf7
10 changed files with 678 additions and 9 deletions
+327
View File
@@ -0,0 +1,327 @@
# 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 18 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 <bundle-id>
# 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).