8 Commits

Author SHA1 Message Date
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
Nick Payne 56ac954369 Merge pull request #13 from makeusabrew/performance-optimisations
Performance optimisations
2026-03-31 14:12:49 +01:00
Nick Payne 81436f42c6 add talat feature 2026-03-31 14:10:10 +01:00
Nick Payne a1eb465142 zero-alloc audio pipeline: pointer-based IO from ring buffer to stdout
Replace Data/AudioPacket allocations with raw pointer callbacks through
the entire audio pipeline. Ring buffer hands out direct pointers (or
linearizes into a pre-allocated scratch buffer on wrap-around), converter
accepts/emits pointers via its cached buffers, and output handler writes
to stdout via write(2) with EINTR handling.

Remove AudioPacket (dead code), --flush flag (no-op with raw write(2)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:16 +00:00
Nick Payne 65c2d58c82 remove unused append(_ data: Data) overload from AudioBuffer
Only one append path exists now: append(from:count:), which is what the
IO proc callback uses. The Data-based overload had no callers in source
and added a dead code path to maintain.

Also resolves CoreAudio.AudioBuffer name collision in tests via typealias.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:38:14 +00:00
Nick Payne 85975d6cc3 optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers
- Replace Swift Array<UInt8> ring buffer with UnsafeMutableRawPointer to
  eliminate COW ref-count checks on every write/read
- Add append(from:count:) to copy directly from Core Audio buffer pointer
  into the ring buffer, removing the per-callback Data heap allocation
- Pre-allocate AVAudioPCMBuffer pair in AudioFormatConverter and reuse
  across transform() calls (lazy init, capacity-checked)
- Fix float-to-int truncation in output frame count calculation (ceil)
- Add comprehensive AudioBuffer test suite (12 tests) including proper
  wrap-around coverage for both append and read paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:32:42 +00:00
Nick Payne 1cd2e83060 Merge pull request #12 from makeusabrew/lib-improvements
library improvements
2026-03-01 11:09:09 +00:00
Nick Payne 4600e34bfb Merge pull request #11 from makeusabrew/lib-split-cli
Split source into library and CLI targets
2026-02-26 13:50:24 +00:00
19 changed files with 1112 additions and 189 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).
+13 -1
View File
@@ -31,7 +31,19 @@ let package = Package(
.executableTarget( .executableTarget(
name: "AudioTeeCLI", name: "AudioTeeCLI",
dependencies: ["AudioTeeCore"], 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 // Tests for the library
+55
View File
@@ -122,6 +122,23 @@ Note that trying to include or exclude a PID which isn't currently playing audio
./audiotee --chunk-duration 0.1 ./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 ## Output
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`. AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
@@ -155,13 +172,51 @@ All program logs are written to `stderr` and can be captured separately:
- `--stereo`: Record in stereo - `--stereo`: Record in stereo
- `--sample-rate`: Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) - `--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] - `--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 ## 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. 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). 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
<a href="https://talat.app"><img src="https://talat.app/favicon.svg" alt="talat" width="28" height="28" /></a>&ensp;**[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/).
## References / useful links ## References / useful links
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) - [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
+68 -7
View File
@@ -2,6 +2,10 @@ import AudioTeeCore
import CoreAudio import CoreAudio
import Foundation 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 { struct AudioTee {
var includeProcesses: [Int32] = [] var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = [] var excludeProcesses: [Int32] = []
@@ -9,7 +13,8 @@ struct AudioTee {
var stereo: Bool = false var stereo: Bool = false
var sampleRate: Double? var sampleRate: Double?
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
var flush: Bool = false var captureMic: Bool = false
var micOutputPath: String?
init() {} init() {}
@@ -33,7 +38,8 @@ struct AudioTee {
audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped audiotee --mute # Mute processes being tapped
audiotee --flush # Flush stdout after each chunk audiotee --capture-mic --mic-output mic.pcm > system.pcm
# Capture system audio and mic to separate files
""" """
) )
@@ -45,12 +51,17 @@ struct AudioTee {
name: "exclude-processes", help: "Process IDs to exclude (space-separated)") name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped") parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addFlag(name: "stereo", help: "Records in stereo") parser.addFlag(name: "stereo", help: "Records in stereo")
parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption( parser.addOption(
name: "sample-rate", name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
parser.addOption( parser.addOption(
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2") 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 // Parse arguments
do { do {
@@ -63,9 +74,10 @@ struct AudioTee {
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute") audioTee.mute = parser.getFlag("mute")
audioTee.stereo = parser.getFlag("stereo") audioTee.stereo = parser.getFlag("stereo")
audioTee.flush = parser.getFlag("flush")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", 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 // Validate
try audioTee.validate() try audioTee.validate()
@@ -94,6 +106,13 @@ struct AudioTee {
throw ArgumentParserError.validationFailed( throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes") "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 { func run() throws {
@@ -141,14 +160,20 @@ struct AudioTee {
throw ExitCode.failure throw ExitCode.failure
} }
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush) let outputHandler = BinaryAudioOutputHandler()
let recorder = try AudioRecorder( let recorder = try AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration) chunkDuration: chunkDuration)
try recorder.startRecording() try recorder.startRecording()
// Run until the run loop is stopped (by signal handler) let micRecorder = try setupMicRecorderIfNeeded()
while true { 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) let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished { if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
break break
@@ -157,15 +182,51 @@ struct AudioTee {
AudioTeeLogging.logger.info("Shutting down...") AudioTeeLogging.logger.info("Shutting down...")
recorder.stopRecording() 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() { private func setupSignalHandlers() {
signal(SIGINT) { _ in signal(SIGINT) { _ in
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...") AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
shouldStop = true
CFRunLoopStop(CFRunLoopGetMain()) CFRunLoopStop(CFRunLoopGetMain())
} }
signal(SIGTERM) { _ in signal(SIGTERM) { _ in
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...") AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
shouldStop = true
CFRunLoopStop(CFRunLoopGetMain()) CFRunLoopStop(CFRunLoopGetMain())
} }
} }
+35 -13
View File
@@ -1,32 +1,54 @@
import AudioTeeCore import AudioTeeCore
import Foundation import Foundation
/// CLI-specific output handler that writes raw PCM audio to stdout /// CLI-specific output handler that writes raw PCM audio to a file descriptor
/// and lifecycle messages to stderr via the logger. /// (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 { class BinaryAudioOutputHandler: AudioOutputHandler {
private let flushAfterWrite: Bool private let fd: Int32
private let source: String
init(flushAfterWrite: Bool = false) { init(fd: Int32 = STDOUT_FILENO, source: String = "audio") {
self.flushAfterWrite = flushAfterWrite self.fd = fd
self.source = source
} }
func handleAudioPacket(_ packet: AudioPacket) { func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
// Write raw binary audio data directly to stdout var written = 0
FileHandle.standardOutput.write(packet.data) while written < count {
if flushAfterWrite { let result = write(fd, pointer.advanced(by: written), count - written)
fflush(stdout) if result >= 0 {
written += result
} else if errno == EINTR {
continue
} else {
break // EPIPE, EIO, etc consumer gone or real error
}
} }
} }
func handleMetadata(_ metadata: AudioStreamMetadata) { 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() { func handleStreamStart() {
AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional<String>.none) AudioTeeLogging.logger.writeMessage(.streamStart, data: source)
} }
func handleStreamStop() { func handleStreamStop() {
AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional<String>.none) AudioTeeLogging.logger.writeMessage(.streamStop, data: source)
} }
} }
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.stephanetailland.audiotee</string>
<key>CFBundleName</key>
<string>audiotee</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSAudioCaptureUsageDescription</key>
<string>audiotee captures system audio for local, on-device meeting transcription.</string>
<key>NSMicrophoneUsageDescription</key>
<string>audiotee captures microphone audio for local, on-device meeting transcription.</string>
</dict>
</plist>
+84 -68
View File
@@ -1,105 +1,121 @@
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
///
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
/// copy-on-write reference-count checks on every mutation. This buffer
/// lives on the real-time audio IO thread and is never shared, so COW
/// semantics are pure overhead.
public class AudioBuffer { public class AudioBuffer {
private var buffer: [UInt8] /// Raw heap-allocated ring buffer backing store.
private let buffer: UnsafeMutableRawPointer
/// Pre-allocated buffer for linearizing chunks that straddle the ring
/// buffer boundary. Avoids a heap allocation on the wrap-around path.
private let linearizationBuffer: UnsafeMutableRawPointer
private var writeIndex: Int = 0 private var writeIndex: Int = 0
private var readIndex: Int = 0 private var readIndex: Int = 0
private var availableBytes: Int = 0 private var availableBytes: Int = 0
private let maxBufferSize: Int private let maxBufferSize: Int
private let bytesPerChunk: Int public let bytesPerChunk: Int
private let chunkDuration: Double
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
// Pre-calculate chunk parameters // Pre-calculate chunk parameters
let bytesPerFrame = Int(format.mBytesPerFrame) let bytesPerFrame = Int(format.mBytesPerFrame)
let samplesPerChunk = Int(format.mSampleRate * chunkDuration) let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
self.bytesPerChunk = samplesPerChunk * bytesPerFrame self.bytesPerChunk = samplesPerChunk * bytesPerFrame
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
// Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow // Calculate max buffer size to hold ~10 seconds of audio (safety limit)
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
self.maxBufferSize = bytesPerSecond * 10 self.maxBufferSize = bytesPerSecond * 10
// Pre-allocated ring buffer // Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
self.buffer = Array(repeating: 0, count: maxBufferSize) // to eliminate Swift Array's COW ref-count check on every write/read.
self.buffer = UnsafeMutableRawPointer.allocate(
byteCount: maxBufferSize,
alignment: MemoryLayout<UInt8>.alignment
)
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
byteCount: bytesPerChunk,
alignment: MemoryLayout<UInt8>.alignment
)
} }
public func append(_ data: Data) { deinit {
guard availableBytes + data.count <= maxBufferSize else { buffer.deallocate()
linearizationBuffer.deallocate()
}
/// Appends audio data directly from a raw pointer into the ring buffer.
/// This is the fast path used by the IO proc callback: one memcpy from
/// the Core Audio buffer into our ring buffer, with no intermediate
/// Data allocation.
public func append(from source: UnsafeRawPointer, count: Int) {
guard count >= 0 else {
AudioTeeLogging.logger.error(
"Audio buffer append called with negative count",
context: ["count": String(count)])
return
}
guard availableBytes + count <= maxBufferSize else {
AudioTeeLogging.logger.error( AudioTeeLogging.logger.error(
"Audio buffer overflow", "Audio buffer overflow",
context: [ context: [
"requested": String(data.count), "requested": String(count),
"available": String(maxBufferSize - availableBytes), "available": String(maxBufferSize - availableBytes),
]) ])
return return
} }
data.withUnsafeBytes { bytes in if writeIndex + count <= maxBufferSize {
let sourceBytes = bytes.bindMemory(to: UInt8.self) // Single contiguous write no wrap-around needed
let dataSize = sourceBytes.count buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
writeIndex = (writeIndex + count) % maxBufferSize
// Check if we can copy in one block (no wrap-around)
if writeIndex + dataSize <= maxBufferSize {
// only one write needed
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
writeIndex = (writeIndex + dataSize) % maxBufferSize
} else {
// two writes needed due to wrap-around
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = secondChunkSize
}
}
availableBytes += data.count
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
private func nextChunk() -> AudioPacket? {
// Check if we have enough data for a complete chunk
guard availableBytes >= bytesPerChunk else { return nil }
var chunkData = Data(capacity: bytesPerChunk)
// Check if we can copy in one block (no wrap-around)
if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else { } else {
// two copies needed due to wrap-around // Two writes needed due to wrap-around at the end of the ring buffer
let firstChunkSize = maxBufferSize - readIndex let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = bytesPerChunk - firstChunkSize let secondChunkSize = count - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize]) buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
chunkData.append(contentsOf: buffer[0..<secondChunkSize]) buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
readIndex = secondChunkSize writeIndex = secondChunkSize
} }
availableBytes -= bytesPerChunk availableBytes += count
}
return AudioPacket( /// Calls `handler` once for each complete chunk available in the buffer.
timestamp: Date(), /// The pointer passed to the handler is valid only for the duration of
duration: chunkDuration, /// that call. In the common (contiguous) case this points directly into
data: chunkData /// the ring buffer zero copies. In the wrap-around case the chunk is
) /// linearized into a pre-allocated scratch buffer one memcpy, zero
/// heap allocations.
public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) {
while availableBytes >= bytesPerChunk {
if readIndex + bytesPerChunk <= maxBufferSize {
// Contiguous: point directly into the ring buffer
handler(buffer.advanced(by: readIndex), bytesPerChunk)
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// Wrap-around: linearize into the pre-allocated scratch buffer
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
linearizationBuffer.copyMemory(
from: buffer.advanced(by: readIndex), byteCount: firstChunkSize)
linearizationBuffer.advanced(by: firstChunkSize).copyMemory(
from: buffer, byteCount: secondChunkSize)
handler(linearizationBuffer, bytesPerChunk)
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
}
} }
} }
@@ -2,12 +2,22 @@ import AVFoundation
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Simple audio format converter using AVFoundation /// Audio format converter using AVFoundation's AVAudioConverter.
///
/// Pre-allocates input/output buffers on first use and reuses them across
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
/// per chunk significant when chunks are small (50ms = 20 calls/sec).
public class AudioFormatConverter { public class AudioFormatConverter {
private let avConverter: AVAudioConverter private let avConverter: AVAudioConverter
private let sourceFormat: AVAudioFormat private let sourceFormat: AVAudioFormat
private let targetFormat: AVAudioFormat private let targetFormat: AVAudioFormat
/// Pre-allocated buffers reused across transform() calls. Lazily created
/// on first transform() since we need the actual input frame count to
/// size them correctly.
private var cachedInputBuffer: AVAudioPCMBuffer?
private var cachedOutputBuffer: AVAudioPCMBuffer?
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription) public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
throws throws
{ {
@@ -58,51 +68,96 @@ public class AudioFormatConverter {
return targetFormat.streamDescription.pointee return targetFormat.streamDescription.pointee
} }
public func transform(_ packet: AudioPacket) -> AudioPacket { /// Returns pre-allocated input and output buffers sized for the given
let inputData = packet.data /// input frame count. Allocates once on first call; reuses on subsequent
/// calls when capacity is sufficient. Re-allocates if a larger frame
/// count arrives (shouldn't happen with fixed chunk sizes, but handled
/// gracefully).
private func getBuffers(inputFrameCount: AVAudioFrameCount)
-> (input: AVAudioPCMBuffer, output: AVAudioPCMBuffer)?
{
// ceil() prevents float-to-int truncation from undersizing the buffer
// by one frame (e.g. 3199.9999 3199 instead of 3200).
let outputFrameCount = AVAudioFrameCount(
ceil(Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
)
// Calculate frame counts // Reuse cached buffers if they have sufficient capacity
let inputFrameCount = if let inputBuf = cachedInputBuffer,
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) let outputBuf = cachedOutputBuffer,
let outputFrameCount = Int( inputBuf.frameCapacity >= inputFrameCount,
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate)) outputBuf.frameCapacity >= outputFrameCount
{
// Reset frame lengths for reuse the underlying memory is retained,
// we just tell AVAudioPCMBuffer how many frames are valid this time.
inputBuf.frameLength = 0
outputBuf.frameLength = 0
return (inputBuf, outputBuf)
}
// Create input buffer // Allocate new buffers (first call, or unexpected capacity increase)
guard guard
let inputBuffer = AVAudioPCMBuffer( let inputBuf = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount)) pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
else { else {
AudioTeeLogging.logger.error("Failed to create input buffer") AudioTeeLogging.logger.error("Failed to create input buffer")
return packet return nil
} }
// Copy input data to buffer
inputData.withUnsafeBytes { bytes in
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
}
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
// Create output buffer
guard guard
let outputBuffer = AVAudioPCMBuffer( let outputBuf = AVAudioPCMBuffer(
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount)) pcmFormat: targetFormat, frameCapacity: outputFrameCount)
else { else {
AudioTeeLogging.logger.error("Failed to create output buffer") AudioTeeLogging.logger.error("Failed to create output buffer")
return packet return nil
} }
// Perform conversion - simpler approach // Cache for reuse on subsequent calls
cachedInputBuffer = inputBuf
cachedOutputBuffer = outputBuf
AudioTeeLogging.logger.debug(
"Allocated converter buffers",
context: [
"input_frame_capacity": String(inputFrameCount),
"output_frame_capacity": String(outputFrameCount),
])
return (inputBuf, outputBuf)
}
/// Converts audio data in-place through the pre-allocated converter buffers.
/// Calls `handler` with a pointer to the converted output, valid only for
/// the duration of that call. Returns false on failure (caller should
/// pass through the original data or drop it).
@discardableResult
public func transform(
from source: UnsafeRawPointer, count: Int,
handler: (UnsafeRawPointer, Int) -> Void
) -> Bool {
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame)
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
return false
}
// Copy source data into the reusable input buffer
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
dest.copyMemory(from: source, byteCount: count)
inputBuffer.frameLength = inputFrameCount
// Perform conversion we do NOT call avConverter.reset() between
// calls because the resampler maintains internal state for continuity
// across chunks (avoiding discontinuity artifacts).
var error: NSError? var error: NSError?
let status = avConverter.convert(to: outputBuffer, error: &error) { let status = avConverter.convert(to: outputBuffer, error: &error) {
requestedPackets, outStatus in requestedPackets, outStatus in
// Always provide our input buffer and let converter manage it
outStatus.pointee = .haveData outStatus.pointee = .haveData
return inputBuffer return inputBuffer
} }
// Check if conversion produced output (regardless of status code)
guard outputBuffer.frameLength > 0 else { guard outputBuffer.frameLength > 0 else {
AudioTeeLogging.logger.error( AudioTeeLogging.logger.error(
"Audio conversion produced no output", "Audio conversion produced no output",
@@ -112,20 +167,13 @@ public class AudioFormatConverter {
"input_frames": String(inputBuffer.frameLength), "input_frames": String(inputBuffer.frameLength),
"output_capacity": String(outputBuffer.frameCapacity), "output_capacity": String(outputBuffer.frameCapacity),
]) ])
return packet return false
} }
// Extract converted data let outputCount = Int(
let outputData = Data( outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!, handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)) return true
// Return new packet with converted audio (keeping original metadata for simplicity)
return AudioPacket(
timestamp: packet.timestamp,
duration: packet.duration,
data: outputData
)
} }
public static func toSampleRate( public static func toSampleRate(
@@ -3,7 +3,8 @@ import CoreAudio
import Foundation import Foundation
public class AudioFormatManager { public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription { public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription
{
// First, wait for the device to become alive/ready // First, wait for the device to become alive/ready
let deviceReadyTimeout = 2.0 // 2 seconds max wait let deviceReadyTimeout = 2.0 // 2 seconds max wait
let pollInterval = 0.1 // 100ms poll interval let pollInterval = 0.1 // 100ms poll interval
@@ -49,7 +50,8 @@ public class AudioFormatManager {
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat) deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr { if status == noErr {
AudioTeeLogging.logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)]) AudioTeeLogging.logger.debug(
"Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat return streamFormat
} }
@@ -1,17 +0,0 @@
import Foundation
public struct AudioPacket {
public let timestamp: Date
public let duration: Double
public let data: Data
public init(
timestamp: Date,
duration: Double,
data: Data
) {
self.timestamp = timestamp
self.duration = duration
self.data = data
}
}
+19 -9
View File
@@ -36,7 +36,8 @@ public class AudioRecorder {
if let targetSampleRate = convertToSampleRate { if let targetSampleRate = convertToSampleRate {
// Validate sample rate // Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else { guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
AudioTeeLogging.logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)]) AudioTeeLogging.logger.error(
"Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
self.converter = nil self.converter = nil
self.finalFormat = sourceFormat self.finalFormat = sourceFormat
return return
@@ -108,14 +109,16 @@ public class AudioRecorder {
let bufferList = inputData.pointee let bufferList = inputData.pointee
let firstBuffer = bufferList.mBuffers let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else { guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
AudioTeeLogging.logger.error("Received empty audio buffer") AudioTeeLogging.logger.error("Received empty audio buffer")
return noErr return noErr
} }
// Append raw audio data to buffer // Copy directly from the Core Audio buffer into our ring buffer.
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) // This avoids creating an intermediate Data object (heap alloc + memcpy)
audioBuffer?.append(audioData) // on every IO callback (~10ms). The pointer is valid for the duration
// of this callback, so this is safe.
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
processAudioBuffer() processAudioBuffer()
@@ -129,10 +132,17 @@ public class AudioRecorder {
} }
private func processAudioBuffer() { private func processAudioBuffer() {
// Process and send complete chunks, applying conversion if needed audioBuffer?.processChunks { pointer, count in
audioBuffer?.processChunks().forEach { packet in if let converter = self.converter {
let processedPacket = converter?.transform(packet) ?? packet if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
outputHandler.handleAudioPacket(processedPacket) self.outputHandler.handleAudioData(outPtr, count: outCount)
}) {
// Conversion failed pass through unconverted audio
self.outputHandler.handleAudioData(pointer, count: count)
}
} else {
self.outputHandler.handleAudioData(pointer, count: count)
}
} }
} }
@@ -76,7 +76,8 @@ public class AudioTapManager {
AudioTeeLogging.logger.debug( AudioTeeLogging.logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)]) "AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else { guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error("Failed to create audio tap", context: ["status": String(status)]) AudioTeeLogging.logger.error(
"Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status) throw AudioTeeError.tapCreationFailed(status)
} }
@@ -115,7 +116,8 @@ public class AudioTapManager {
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID) let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else { guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error("Failed to create aggregate device", context: ["status": String(status)]) AudioTeeLogging.logger.error(
"Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status) throw AudioTeeError.aggregateDeviceCreationFailed(status)
} }
@@ -12,6 +12,7 @@ public enum AudioTeeError: Error {
case deviceFormatUnavailable(AudioObjectID) case deviceFormatUnavailable(AudioObjectID)
case ioProcCreationFailed(OSStatus) case ioProcCreationFailed(OSStatus)
case deviceStartFailed(OSStatus) case deviceStartFailed(OSStatus)
case defaultInputDeviceUnavailable(OSStatus)
} }
// MARK: - Audio Format Conversion Errors // MARK: - Audio Format Conversion Errors
@@ -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<AudioObjectID>.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
}
}
@@ -2,7 +2,9 @@ import Foundation
/// Protocol for handling audio output in different formats /// Protocol for handling audio output in different formats
public protocol AudioOutputHandler { public protocol AudioOutputHandler {
func handleAudioPacket(_ packet: AudioPacket) /// Called with a pointer to raw PCM audio data. The pointer is only
/// valid for the duration of this call.
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int)
func handleMetadata(_ metadata: AudioStreamMetadata) func handleMetadata(_ metadata: AudioStreamMetadata)
func handleStreamStart() func handleStreamStart()
func handleStreamStop() func handleStreamStop()
@@ -0,0 +1,219 @@
import CoreAudio
import XCTest
@testable import AudioTeeCore
// CoreAudio defines its own AudioBuffer struct, which collides with ours.
// Explicit module qualification avoids ambiguity in tests that import both.
private typealias AudioBuffer = AudioTeeCore.AudioBuffer
final class AudioBufferTests: XCTestCase {
// MARK: - Helpers
/// Creates a minimal AudioStreamBasicDescription for testing.
/// 16kHz, 16-bit, mono = 2 bytes per frame, 32000 bytes/sec.
private func makeFormat(
sampleRate: Double = 16000,
bytesPerFrame: UInt32 = 2,
bitsPerChannel: UInt32 = 16
) -> AudioStreamBasicDescription {
return AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger,
mBytesPerPacket: bytesPerFrame,
mFramesPerPacket: 1,
mBytesPerFrame: bytesPerFrame,
mChannelsPerFrame: 1,
mBitsPerChannel: bitsPerChannel,
mReserved: 0
)
}
/// Creates a repeating byte pattern of the given length.
private func makeData(byte: UInt8, count: Int) -> Data {
return Data(repeating: byte, count: count)
}
/// Appends Data to an AudioBuffer via the raw pointer path,
/// matching how processAudio() calls append(from:count:).
private func appendData(_ data: Data, to buffer: AudioBuffer) {
data.withUnsafeBytes { bytes in
buffer.append(from: bytes.baseAddress!, count: bytes.count)
}
}
/// Collects chunks from the buffer as Data objects for test verification.
private func collectChunks(from buffer: AudioBuffer) -> [Data] {
var chunks: [Data] = []
buffer.processChunks { pointer, count in
chunks.append(Data(bytes: pointer, count: count))
}
return chunks
}
// MARK: - Basic append + processChunks
func testSingleChunkExtraction() {
// 16kHz, 2 bytes/frame, 0.1s chunk = 3200 bytes per chunk
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200 // 16000 * 0.1 * 2
let data = makeData(byte: 0xAB, count: chunkSize)
appendData(data, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[0], data)
}
func testMultipleChunksExtracted() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append 2.5 chunks worth
appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer)
let chunks = collectChunks(from: buffer)
// Should get 2 complete chunks, remainder stays in buffer
XCTAssertEqual(chunks.count, 2)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[1].count, chunkSize)
}
func testInsufficientDataReturnsNoChunks() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append less than one chunk
appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 0)
}
// MARK: - Wrap-around
func testWrapAroundWrite() {
// 8kHz, 2 bytes/frame, 0.3s chunks chunkSize = 4800, maxBuffer = 160000.
// 160000 / 4800 = 33.33 chunks do NOT divide evenly into the buffer,
// so after enough writes the writeIndex will straddle the boundary.
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800 // 8000 * 0.3 * 2
// Write 33 chunks (158400 bytes), drain them all.
// writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
let drained = collectChunks(from: buffer)
XCTAssertEqual(drained.count, 33)
// Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000.
// This MUST take the wrap-around else branch in append():
// firstChunkSize = 160000 - 158400 = 1600
// secondChunkSize = 4800 - 1600 = 3200
// Verify by using distinct byte patterns for the portion before and after the boundary.
var wrappingData = Data()
wrappingData.append(makeData(byte: 0xAA, count: 1600)) // fills to boundary
wrappingData.append(makeData(byte: 0xBB, count: 3200)) // wraps to start
XCTAssertEqual(wrappingData.count, chunkSize)
appendData(wrappingData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], wrappingData)
}
func testWrapAroundRead() {
// Same setup as above: position readIndex so that a chunk extraction
// straddles the ring buffer boundary, exercising the else branch in nextChunk().
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800
// Write and drain 33 chunks. Both indices land at 158400.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
_ = collectChunks(from: buffer)
// Write one chunk starting at 158400. The write itself wraps (tested above),
// but crucially the READ will also wrap: readIndex = 158400,
// 158400 + 4800 = 163200 > 160000 else branch in nextChunk():
// firstChunkSize = 160000 - 158400 = 1600 (read from end of buffer)
// secondChunkSize = 4800 - 1600 = 3200 (read from start of buffer)
var crossBoundaryData = Data()
crossBoundaryData.append(makeData(byte: 0xCC, count: 1600))
crossBoundaryData.append(makeData(byte: 0xDD, count: 3200))
appendData(crossBoundaryData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], crossBoundaryData)
}
// MARK: - Overflow guard
func testOverflowPreventsWrite() {
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let maxBuffer = 160000
// Fill the buffer completely
appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer)
// Try to append more should be silently rejected (overflow guard)
appendData(makeData(byte: 0x02, count: 100), to: buffer)
// Drain and verify we only got the original data
let chunks = collectChunks(from: buffer)
let totalBytes = chunks.reduce(0) { $0 + $1.count }
XCTAssertEqual(totalBytes, maxBuffer)
// Every byte should be 0x01, not 0x02
for chunk in chunks {
XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 })
}
}
// MARK: - Incremental appends accumulate correctly
func testIncrementalAppendsThenChunk() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Simulate many small IO callbacks building up to one chunk
let callbackSize = 320 // 10 callbacks to fill one chunk
for i in 0..<10 {
appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer)
}
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
// Verify the data is in the correct order
for i in 0..<10 {
let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize))
XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) })
}
}
// MARK: - Chunk size
func testBytesPerChunkIsCorrect() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
// 16kHz * 0.1s * 2 bytes/frame = 3200
XCTAssertEqual(buffer.bytesPerChunk, 3200)
}
}
@@ -1,30 +0,0 @@
import XCTest
@testable import AudioTeeCore
final class AudioPacketTests: XCTestCase {
func testPacketCreation() {
let timestamp = Date()
let duration = 1.0
let data = Data([0x01, 0x02, 0x03, 0x04])
let packet = AudioPacket(
timestamp: timestamp,
duration: duration,
data: data
)
XCTAssertEqual(packet.timestamp, timestamp)
XCTAssertEqual(packet.duration, duration)
XCTAssertEqual(packet.data, data)
}
func testPacketDataSize() {
let packet = AudioPacket(
timestamp: Date(),
duration: 0.5,
data: Data(repeating: 0xFF, count: 1024)
)
XCTAssertEqual(packet.data.count, 1024)
}
}
+86
View File
@@ -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) <hash> "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
+60
View File
@@ -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)."