Files
audiotee/CONTEXT.md
T
sttlab-tech 678557caf7 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>
2026-08-09 13:10:08 +02:00

19 KiB
Raw Blame History

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 (k8skubernetes, 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

  • Piste micro en parallèle (voir §6.1) — implémenté nativement dans audiotee (fork), pas via un process ffmpeg/AVAudioEngine séparé
  • 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.

# 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.plistCFBundleIdentifier (com.stephanetailland.audiotee), NSAudioCaptureUsageDescription, NSMicrophoneUsageDescription.
  • Package.swiftlinkerSettings 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

# 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).