diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d48664a --- /dev/null +++ b/CONTEXT.md @@ -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 + +# 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). diff --git a/Package.swift b/Package.swift index a9a878a..063026d 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,19 @@ let package = Package( .executableTarget( name: "AudioTeeCLI", dependencies: ["AudioTeeCore"], - path: "Sources/AudioTeeCLI" + path: "Sources/AudioTeeCLI", + exclude: ["Info.plist"], + linkerSettings: [ + // Embeds Info.plist directly into the Mach-O binary so it carries a + // stable CFBundleIdentifier and the usage-description keys TCC needs, + // without requiring a full .app bundle — see scripts/build-signed.sh. + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Sources/AudioTeeCLI/Info.plist", + ]) + ] ), // Tests for the library diff --git a/README.md b/README.md index a7da0ee..e5d0687 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,23 @@ Note that trying to include or exclude a PID which isn't currently playing audio ./audiotee --chunk-duration 0.1 ``` +### Microphone capture + +AudioTee can optionally capture the default input device (microphone) as a second, +independent track alongside system audio — useful for "me vs them" diarization. Mic audio +is written to its own file rather than `stdout`, since interleaving two live PCM streams +from separate Core Audio IO threads onto one stream would corrupt both. + +```bash +# Capture system audio to stdout and mic audio to a separate file +./audiotee --capture-mic --mic-output mic.pcm > system.pcm +``` + +`--sample-rate` and `--chunk-duration` apply to both tracks. On `stderr`, each track's +`metadata` message carries `capture_mode: "audio"` or `"mic"`, and its `stream_start`/ +`stream_stop` messages carry `"audio"`/`"mic"` as their `data` value — so you can tell which +track a given message belongs to when both are interleaved in the same log. + ## Output AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`. @@ -155,13 +172,47 @@ All program logs are written to `stderr` and can be captured separately: - `--stereo`: Record in stereo - `--sample-rate`: Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) - `--chunk-duration`: Audio chunk duration in seconds [default: 0.2, max: 5.0] +- `--capture-mic`: Also capture the default input device (microphone) as a second track +- `--mic-output`: File path to write microphone PCM audio to (required with `--capture-mic`) ## Permissions There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission, so you'll be prompted the first time AudioTee tries to record anything. Note that some terminal emulators like iTerm don't always prompt for these permissions (though the macOS builtin terminal definitely does), so you might need to grant them ahead of time if audiotee runs but never records anything. +`--capture-mic` requires the standard, separate Microphone TCC permission (not +`NSAudioCaptureUsageDescription`), and will trigger its own first-run prompt. + If you want to check and/or request permissions ahead of time, check out [AudioCap's fantastic TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). +### Stable permissions across rebuilds + +By default, `swift build` ad-hoc-signs the binary, and ad-hoc signatures are keyed off the +binary's own hash — so every rebuild looks like a new, untrusted app to TCC and you get +re-prompted (or worse, silently record silence). `swift run` also invokes the binary from +inside `.build/`, and TCC has been observed keying on binary path too, which causes the same +problem across rebuilds even without touching signing. + +`scripts/build-signed.sh` builds a release binary, signs it with a **stable identity** (a +free self-signed certificate in your Keychain — no paid Developer ID needed for personal +use), and installs it to a fixed path (`~/bin/audiotee` by default). The binary also embeds +an `Info.plist` at link time (see `Package.swift`) carrying a fixed `CFBundleIdentifier` plus +`NSAudioCaptureUsageDescription`/`NSMicrophoneUsageDescription`, without needing a full +`.app` bundle — this matters if you invoke audiotee as a subprocess from another program +(e.g. a Python ASR orchestrator) rather than through Launch Services. + +One-time setup — either via the GUI (Keychain Access → `Certificate Assistant > Create a +Certificate...`, Identity Type "Self Signed Root", Certificate Type "Code Signing", then set +that certificate's Trust > Code Signing to "Always Trust"), or entirely via CLI with +`scripts/create-signing-identity.sh` (review it first — it generates a key, imports it into +your login keychain, and trusts it for the `codeSign` policy). Either way, after that: + +```bash +scripts/build-signed.sh # build, sign, install to ~/bin/audiotee +scripts/build-signed.sh --reset-tcc # also reset TCC state — useful after changing + # Info.plist or the signing identity, to re-trigger + # the permission prompts +``` + ## Built with AudioTee talat **[talat](https://talat.app)** — private, local-only meeting transcription for macOS. Captures system audio via AudioTee and runs real-time speech recognition, speaker diarization, and searchable notes entirely on-device. [As featured in TechCrunch](https://techcrunch.com/2026/03/24/talats-ai-meeting-notes-stay-on-your-machine-not-in-the-cloud/). diff --git a/Sources/AudioTeeCLI/AudioTee.swift b/Sources/AudioTeeCLI/AudioTee.swift index e7f8973..8e08dbd 100644 --- a/Sources/AudioTeeCLI/AudioTee.swift +++ b/Sources/AudioTeeCLI/AudioTee.swift @@ -2,6 +2,10 @@ import AudioTeeCore import CoreAudio import Foundation +// Set by the SIGINT/SIGTERM handlers, which — being passed to the C `signal()` +// API — cannot capture `self` and so can't touch instance state directly. +private var shouldStop = false + struct AudioTee { var includeProcesses: [Int32] = [] var excludeProcesses: [Int32] = [] @@ -9,6 +13,8 @@ struct AudioTee { var stereo: Bool = false var sampleRate: Double? var chunkDuration: Double = 0.2 + var captureMic: Bool = false + var micOutputPath: String? init() {} @@ -32,6 +38,8 @@ struct AudioTee { audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --mute # Mute processes being tapped + audiotee --capture-mic --mic-output mic.pcm > system.pcm + # Capture system audio and mic to separate files """ ) @@ -48,6 +56,12 @@ struct AudioTee { help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") parser.addOption( name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2") + parser.addFlag( + name: "capture-mic", + help: "Also capture the default input device (microphone) as a second track") + parser.addOption( + name: "mic-output", + help: "File path to write microphone PCM audio to (required with --capture-mic)") // Parse arguments do { @@ -62,6 +76,8 @@ struct AudioTee { audioTee.stereo = parser.getFlag("stereo") audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self) + audioTee.captureMic = parser.getFlag("capture-mic") + audioTee.micOutputPath = try parser.getOptionalValue("mic-output", as: String.self) // Validate try audioTee.validate() @@ -90,6 +106,13 @@ struct AudioTee { throw ArgumentParserError.validationFailed( "Cannot specify both --include-processes and --exclude-processes") } + if captureMic && micOutputPath == nil { + throw ArgumentParserError.validationFailed( + "--mic-output is required when --capture-mic is set") + } + if !captureMic && micOutputPath != nil { + throw ArgumentParserError.validationFailed("--mic-output requires --capture-mic") + } } func run() throws { @@ -143,8 +166,14 @@ struct AudioTee { chunkDuration: chunkDuration) try recorder.startRecording() - // Run until the run loop is stopped (by signal handler) - while true { + let micRecorder = try setupMicRecorderIfNeeded() + try micRecorder?.startRecording() + + // Run until the run loop is stopped (by signal handler). shouldStop is + // checked on every iteration (not just the CFRunLoopRun result) because + // a signal can arrive during setup, before this loop is ever entered — + // CFRunLoopStop has no lasting effect on a run loop that isn't running yet. + while !shouldStop { let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false) if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished { break @@ -153,15 +182,51 @@ struct AudioTee { AudioTeeLogging.logger.info("Shutting down...") recorder.stopRecording() + micRecorder?.stopRecording() + } + + /// Sets up a second, independent recording pipeline reading from the + /// default input device (microphone) when --capture-mic was requested. + /// Its audio is written to its own file rather than stdout: writes from + /// two concurrent Core Audio IO threads interleaved on one fd/stream + /// would otherwise corrupt both tracks. + private func setupMicRecorderIfNeeded() throws -> AudioRecorder? { + guard captureMic, let micOutputPath = micOutputPath else { + return nil + } + + let micDeviceID: AudioObjectID + do { + micDeviceID = try InputDeviceResolver.defaultInputDevice() + } catch { + AudioTeeLogging.logger.error( + "Failed to resolve default input device", context: ["error": String(describing: error)]) + throw ExitCode.failure + } + + let micFd = open(micOutputPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard micFd >= 0 else { + AudioTeeLogging.logger.error( + "Failed to open mic output file", + context: ["path": micOutputPath, "errno": String(errno)]) + throw ExitCode.failure + } + + let micOutputHandler = BinaryAudioOutputHandler(fd: micFd, source: "mic") + return try AudioRecorder( + deviceID: micDeviceID, outputHandler: micOutputHandler, convertToSampleRate: sampleRate, + chunkDuration: chunkDuration) } private func setupSignalHandlers() { signal(SIGINT) { _ in AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...") + shouldStop = true CFRunLoopStop(CFRunLoopGetMain()) } signal(SIGTERM) { _ in AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...") + shouldStop = true CFRunLoopStop(CFRunLoopGetMain()) } } diff --git a/Sources/AudioTeeCLI/BinaryOutputHandler.swift b/Sources/AudioTeeCLI/BinaryOutputHandler.swift index 732b837..46738d7 100644 --- a/Sources/AudioTeeCLI/BinaryOutputHandler.swift +++ b/Sources/AudioTeeCLI/BinaryOutputHandler.swift @@ -1,10 +1,20 @@ import AudioTeeCore import Foundation -/// CLI-specific output handler that writes raw PCM audio to stdout -/// and lifecycle messages to stderr via the logger. +/// CLI-specific output handler that writes raw PCM audio to a file descriptor +/// (stdout by default) and lifecycle messages to stderr via the logger. +/// +/// `source` tags every stderr message so a consumer running two tracks at +/// once (e.g. system audio + microphone, each on its own fd) can tell which +/// track a given metadata/lifecycle message belongs to. class BinaryAudioOutputHandler: AudioOutputHandler { - private let fd = STDOUT_FILENO + private let fd: Int32 + private let source: String + + init(fd: Int32 = STDOUT_FILENO, source: String = "audio") { + self.fd = fd + self.source = source + } func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) { var written = 0 @@ -21,14 +31,24 @@ class BinaryAudioOutputHandler: AudioOutputHandler { } func handleMetadata(_ metadata: AudioStreamMetadata) { - AudioTeeLogging.logger.writeMessage(.metadata, data: metadata) + let taggedMetadata = AudioStreamMetadata( + sampleRate: metadata.sampleRate, + channelsPerFrame: metadata.channelsPerFrame, + bitsPerChannel: metadata.bitsPerChannel, + isFloat: metadata.isFloat, + captureMode: source, + deviceName: metadata.deviceName, + deviceUID: metadata.deviceUID, + encoding: metadata.encoding + ) + AudioTeeLogging.logger.writeMessage(.metadata, data: taggedMetadata) } func handleStreamStart() { - AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional.none) + AudioTeeLogging.logger.writeMessage(.streamStart, data: source) } func handleStreamStop() { - AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional.none) + AudioTeeLogging.logger.writeMessage(.streamStop, data: source) } } diff --git a/Sources/AudioTeeCLI/Info.plist b/Sources/AudioTeeCLI/Info.plist new file mode 100644 index 0000000..2d59559 --- /dev/null +++ b/Sources/AudioTeeCLI/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleIdentifier + com.stephanetailland.audiotee + CFBundleName + audiotee + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + NSAudioCaptureUsageDescription + audiotee captures system audio for local, on-device meeting transcription. + NSMicrophoneUsageDescription + audiotee captures microphone audio for local, on-device meeting transcription. + + diff --git a/Sources/AudioTeeCore/Core/AudioTeeErrors.swift b/Sources/AudioTeeCore/Core/AudioTeeErrors.swift index acfbbfd..996e187 100644 --- a/Sources/AudioTeeCore/Core/AudioTeeErrors.swift +++ b/Sources/AudioTeeCore/Core/AudioTeeErrors.swift @@ -12,6 +12,7 @@ public enum AudioTeeError: Error { case deviceFormatUnavailable(AudioObjectID) case ioProcCreationFailed(OSStatus) case deviceStartFailed(OSStatus) + case defaultInputDeviceUnavailable(OSStatus) } // MARK: - Audio Format Conversion Errors diff --git a/Sources/AudioTeeCore/Core/InputDeviceResolver.swift b/Sources/AudioTeeCore/Core/InputDeviceResolver.swift new file mode 100644 index 0000000..a0356aa --- /dev/null +++ b/Sources/AudioTeeCore/Core/InputDeviceResolver.swift @@ -0,0 +1,29 @@ +import AudioToolbox +import CoreAudio +import Foundation + +/// Resolves hardware audio input devices, e.g. the built-in or currently +/// selected microphone. Unlike system audio capture, this talks to a real +/// input device directly and needs no process tap or aggregate device. +public class InputDeviceResolver { + /// Returns the system's current default audio input device. + public static func defaultInputDevice() throws -> AudioObjectID { + var address = getPropertyAddress(selector: kAudioHardwarePropertyDefaultInputDevice) + var deviceID = AudioObjectID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) + + guard status == kAudioHardwareNoError, deviceID != kAudioObjectUnknown else { + AudioTeeLogging.logger.error( + "Failed to resolve default input device", context: ["status": String(status)]) + throw AudioTeeError.defaultInputDeviceUnavailable(status) + } + + AudioTeeLogging.logger.debug( + "Resolved default input device", context: ["device_id": String(deviceID)]) + + return deviceID + } +} diff --git a/scripts/build-signed.sh b/scripts/build-signed.sh new file mode 100755 index 0000000..0519020 --- /dev/null +++ b/scripts/build-signed.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Builds audiotee, signs it with a stable identity, and installs it to a +# fixed path. Both steps matter for Core Audio process tap / microphone TCC +# permissions to survive across rebuilds — see CONTEXT.md §6.2 and §8: +# +# - SwiftPM ad-hoc-signs debug/release builds by default. Ad-hoc signatures +# are keyed off the binary's own hash, so every rebuild looks like a new +# app to TCC and permission has to be re-granted. +# - TCC has also been observed keying on binary path, so builds are installed +# to a fixed location outside .build/. +# +# Usage: +# scripts/build-signed.sh # build, sign, install to ~/bin +# scripts/build-signed.sh --reset-tcc # also reset TCC state for this +# # binary, useful after changing +# # Info.plist or the signing identity +# +# Requires a self-signed code-signing certificate in your keychain. If you +# don't have one yet: +# 1. Open Keychain Access +# 2. Keychain Access menu > Certificate Assistant > Create a Certificate... +# 3. Name it (e.g. "audiotee-dev"), Identity Type: Self Signed Root, +# Certificate Type: Code Signing +# 4. Create it, then in Keychain Access double-click it, expand "Trust", +# and set "Code Signing" to "Always Trust" +# Override auto-detection with: AUDIOTEE_SIGNING_IDENTITY="Your Cert Name" + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +BUNDLE_ID="com.stephanetailland.audiotee" +INSTALL_DIR="${AUDIOTEE_INSTALL_DIR:-$HOME/bin}" +INSTALL_PATH="$INSTALL_DIR/audiotee" +RESET_TCC=false + +for arg in "$@"; do + case "$arg" in + --reset-tcc) RESET_TCC=true ;; + *) + echo "Unknown argument: $arg" >&2 + exit 1 + ;; + esac +done + +if [[ -n "${AUDIOTEE_SIGNING_IDENTITY:-}" ]]; then + IDENTITY="$AUDIOTEE_SIGNING_IDENTITY" +else + # Real identity lines look like ` 1) "Name"`; the "N valid + # identities found" summary line has no ")" and must not be counted. + IDENTITY_LINES="$(security find-identity -v -p codesigning | grep '^ *[0-9]*)' || true)" + IDENTITY_COUNT="$(printf '%s\n' "$IDENTITY_LINES" | grep -c . || true)" + if [[ "$IDENTITY_COUNT" -eq 0 ]]; then + echo "Error: no code-signing identity found in your keychain." >&2 + echo "See the comment at the top of this script for how to create one." >&2 + exit 1 + elif [[ "$IDENTITY_COUNT" -gt 1 ]]; then + echo "Error: multiple code-signing identities found. Set AUDIOTEE_SIGNING_IDENTITY" >&2 + echo "to the one to use:" >&2 + echo "$IDENTITY_LINES" >&2 + exit 1 + fi + IDENTITY="$(printf '%s\n' "$IDENTITY_LINES" | sed -n 's/.*"\(.*\)"/\1/p')" +fi + +echo "Building (release)..." +swift build -c release + +BUILT_BINARY="$REPO_ROOT/.build/release/audiotee" + +echo "Signing with identity: $IDENTITY" +codesign --force --sign "$IDENTITY" --identifier "$BUNDLE_ID" "$BUILT_BINARY" + +mkdir -p "$INSTALL_DIR" +cp "$BUILT_BINARY" "$INSTALL_PATH" + +echo "Installed to $INSTALL_PATH" +codesign -dvvv "$INSTALL_PATH" + +if [[ "$RESET_TCC" == true ]]; then + echo "Resetting TCC state for $BUNDLE_ID..." + tccutil reset SystemAudioCaptureRequests "$BUNDLE_ID" || true + tccutil reset Microphone "$BUNDLE_ID" || true +fi diff --git a/scripts/create-signing-identity.sh b/scripts/create-signing-identity.sh new file mode 100755 index 0000000..c0921c4 --- /dev/null +++ b/scripts/create-signing-identity.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# One-time setup: creates a self-signed code-signing certificate and trusts +# it for the "codeSign" policy, entirely via CLI (no Keychain Access GUI). +# This is what scripts/build-signed.sh needs to sign audiotee with a stable +# identity — see CONTEXT.md §6.2 for why that matters. +# +# This script modifies your login keychain's trust settings. Read it before +# running it. macOS will likely prompt for your login password during the +# `security import` / `security add-trusted-cert` steps — that's expected, +# it's the OS asking permission to change keychain ACLs/trust, not this +# script asking for your password directly. +# +# Usage: +# scripts/create-signing-identity.sh [certificate-name] +# (default name: audiotee-dev) + +set -euo pipefail + +CERT_NAME="${1:-audiotee-dev}" +DAYS=3650 +KEYCHAIN="$HOME/Library/Keychains/login.keychain-db" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +EXISTING="$(security find-identity -v -p codesigning | grep -c "\"$CERT_NAME\"" || true)" +if [[ "$EXISTING" -gt 0 ]]; then + echo "A code-signing identity named \"$CERT_NAME\" already exists. Nothing to do." + security find-identity -v -p codesigning + exit 0 +fi + +echo "Generating a self-signed code-signing certificate: $CERT_NAME" +openssl req -x509 -newkey rsa:2048 \ + -keyout "$WORKDIR/key.pem" -out "$WORKDIR/cert.pem" \ + -days "$DAYS" -nodes -subj "/CN=$CERT_NAME" \ + -addext "extendedKeyUsage=critical,codeSigning" \ + -addext "basicConstraints=critical,CA:false" \ + -addext "keyUsage=critical,digitalSignature" + +# -legacy: OpenSSL 3.x defaults to AES-256/SHA-256 for PKCS12, which macOS's +# Security framework can't read (fails with a misleading "wrong password?"). +# It needs the older RC2/3DES-based encoding this flag produces. +openssl pkcs12 -export -out "$WORKDIR/cert.p12" \ + -inkey "$WORKDIR/key.pem" -in "$WORKDIR/cert.pem" -passout pass:temporary \ + -legacy + +echo "Importing into your login keychain (may prompt for your login password)..." +security import "$WORKDIR/cert.p12" -k "$KEYCHAIN" -P temporary \ + -T /usr/bin/codesign -T /usr/bin/security + +echo "Trusting it for code signing only, not as a general root CA" \ + "(may prompt for your login password)..." +security add-trusted-cert -r trustRoot -p codeSign -k "$KEYCHAIN" "$WORKDIR/cert.pem" + +echo "" +echo "Done. Verifying the identity is now usable by codesign:" +security find-identity -v -p codesigning + +echo "" +echo "Next: scripts/build-signed.sh (it auto-detects this identity)."