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:
+327
@@ -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 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 <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).
|
||||
Reference in New Issue
Block a user