Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 678557caf7 | |||
| 56ac954369 | |||
| 81436f42c6 | |||
| a1eb465142 | |||
| 65c2d58c82 | |||
| 85975d6cc3 | |||
| 1cd2e83060 | |||
| 4600e34bfb | |||
| 08f0bc8f6c | |||
| c4cf27553a | |||
| 087e4b1642 | |||
| 0b1ba5c8cd | |||
| b7455d26d1 | |||
| a311cc583f | |||
| 8b3de5918c | |||
| cf7bc88528 | |||
| 25f017f319 | |||
| 307eae147d | |||
| c9d11316ff | |||
| 71b0c45a5c | |||
| fd82e3d9d9 | |||
| d260c7824d | |||
| b3b11c8649 | |||
| 00985c0d33 | |||
| 1c94f55a9b | |||
| 2040510e9e | |||
| 3449a9bb9c | |||
| ac0ae46cfa | |||
| 8c3ee0f4e7 | |||
| abaa019bd2 | |||
| 98e33b7bcb | |||
| ee8968e2d6 | |||
| 4bc36019c2 | |||
| 1b537eb395 |
@@ -7,3 +7,5 @@ DerivedData/
|
|||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
*.pcm
|
||||||
|
*.wav
|
||||||
|
|||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
# Projet — Transcription de réunions 100% locale (macOS)
|
||||||
|
|
||||||
|
> Document de passation. Contient l'état du projet, les décisions prises,
|
||||||
|
> ce qui est déjà validé, et les prochaines étapes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Transcrire les réunions en local, sans aucun service SaaS, pour pouvoir rester
|
||||||
|
concentré sur l'écoute plutôt que sur la prise de notes.
|
||||||
|
|
||||||
|
Objectif secondaire (étape 2) : analyser le contenu en temps réel et suggérer
|
||||||
|
des questions à poser pendant la réunion.
|
||||||
|
|
||||||
|
## 2. Contraintes
|
||||||
|
|
||||||
|
| Contrainte | Détail |
|
||||||
|
|---|---|
|
||||||
|
| **Aucun cloud** | Tout doit tourner en local. Pas d'API externe, pas de SaaS. |
|
||||||
|
| **Pas d'outils natifs** | Les transcriptions Zoom / Meet / Teams sont exclues. |
|
||||||
|
| **Multilingue** | Réunions en français et anglais, souvent mélangés dans la même phrase (code-switching). |
|
||||||
|
| **Anglais approximatif** | Locuteurs non-natifs, accents marqués. Le modèle doit être robuste. |
|
||||||
|
| **Vocabulaire technique** | Jargon métier, acronymes internes, noms de produits. Doit être enrichissable. |
|
||||||
|
| **Temps réel** | Idéalement live, pour permettre l'analyse en cours de réunion. |
|
||||||
|
|
||||||
|
## 3. Matériel
|
||||||
|
|
||||||
|
- **MacBook Pro M4 Max, 128 Go RAM** — machine de dev (celle où tourne ce repo).
|
||||||
|
- **Cible réelle pour l'app de transcription (2026-08-07) : MacBook M3 simple, 24 Go RAM.**
|
||||||
|
Le dimensionnement des modèles (taille, quantization) doit être calé sur cette machine,
|
||||||
|
pas sur le M4 Max — voir §6.4/discussion architecture pour le budget mémoire revu en
|
||||||
|
conséquence (modèles 4-bit, éviter les gros modèles de relecture).
|
||||||
|
- **iPhone récent** — usage limité (voir §7).
|
||||||
|
- Plateformes de réunion : Google Meet, Zoom, MS Teams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture cible
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ CAPTURE — audiotee (fork), un process, 2 sorties │
|
||||||
|
│ ├─ Core Audio process tap → stdout = piste système │
|
||||||
|
│ └─ device d'entrée (mic) → --mic-output = piste micro│
|
||||||
|
│ → 2 pistes séparées = diarisation "moi vs eux" │
|
||||||
|
└──────────────┬──────────────────────────┬───────────────┘
|
||||||
|
│ PCM 16 kHz mono, 16-bit LE (système + micro)
|
||||||
|
┌───────────▼──────────────┐ ┌─────────▼─────────────────┐
|
||||||
|
│ SEGMENTATION (par piste)│ │ SEGMENTATION (par piste) │
|
||||||
|
│ VAD Silero → chunks │ │ VAD Silero → chunks │
|
||||||
|
│ 3-5 s avec recouvrement │ │ 3-5 s avec recouvrement │
|
||||||
|
└───────────┬──────────────┘ └─────────┬──────────────────┘
|
||||||
|
┌───────────▼──────────────┐ ┌─────────▼──────────────────┐
|
||||||
|
│ ASR — Qwen3-ASR-1.7B │ │ ASR — Qwen3-ASR-1.7B │
|
||||||
|
│ (--stream), 1 process │ │ (--stream), 1 process │
|
||||||
|
│ couche 1 : biasing │ │ couche 1 : biasing │
|
||||||
|
│ lexical (glossaire) │ │ lexical (glossaire) │
|
||||||
|
└───────────┬──────────────┘ └─────────┬──────────────────┘
|
||||||
|
│ segments horodatés (track, text, is_final, ts)
|
||||||
|
└──────────────┬───────────┘
|
||||||
|
┌─────────▼──────────────────────┐
|
||||||
|
│ MERGE + POST-TRAITEMENT │
|
||||||
|
│ ├─ couche 2 : fuzzy matching │
|
||||||
|
│ ├─ couche 3 : relecture LLM │
|
||||||
|
│ │ local (Qwen3 via MLX) │
|
||||||
|
│ └─ publie en SSE (API stream) │
|
||||||
|
└─────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
terminal (1er client SSE)
|
||||||
|
(page web / analyse phase 2 : autres
|
||||||
|
clients SSE possibles plus tard,
|
||||||
|
sans toucher au pipeline)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choix ASR : Qwen3-ASR plutôt que Whisper
|
||||||
|
|
||||||
|
**Pourquoi :**
|
||||||
|
- Code-switching natif sur 11+ langues — Whisper force à choisir une langue.
|
||||||
|
- Mode streaming natif (fenêtre d'attention dynamique 1–8 s) : le même modèle fait
|
||||||
|
offline et temps réel. WER 4.51 en streaming vs 3.38 en offline sur
|
||||||
|
LibriSpeech-other — dégradation acceptable.
|
||||||
|
- Biasing lexical par texte arbitraire, sans limite stricte. Whisper est plafonné à
|
||||||
|
224 tokens d'`initial_prompt`, avec un poids inégal entre termes (ceux placés en fin
|
||||||
|
de prompt comptent davantage).
|
||||||
|
- La version 1.7B tourne confortablement sur M4 Max.
|
||||||
|
|
||||||
|
**Runtimes possibles :**
|
||||||
|
- MLX
|
||||||
|
- Implémentation C d'antirez (`qwen-asr`) — expose déjà `--stream` (chunks avec
|
||||||
|
rollback de préfixe et fenêtre glissante) et `--prompt` pour le biasing.
|
||||||
|
|
||||||
|
**Fallback si la qualité déçoit sur l'audio réel :** Whisper large-v3-turbo via
|
||||||
|
whisper.cpp (Metal) ou mlx-whisper. Moins de biasing, à compenser par les couches 2 et 3.
|
||||||
|
|
||||||
|
### Stratégie vocabulaire technique — 3 couches
|
||||||
|
|
||||||
|
1. **Glossaire en prompt (soft).** Liste courte et *contextuelle à la réunion*, pas le
|
||||||
|
lexique entier. L'effet est probabiliste, pas déterministe : sur des sons proches,
|
||||||
|
le modèle peut dériver malgré le prompt. Sélectionner des termes pertinents rend
|
||||||
|
chaque mot plus efficace que d'en injecter beaucoup au hasard.
|
||||||
|
2. **Post-correction déterministe.** Fuzzy matching sur dictionnaire maison
|
||||||
|
(`k8s` → `kubernetes`, noms de projets, acronymes internes). Peu coûteux, très rentable.
|
||||||
|
3. **Relecture LLM local.** Qwen3 via MLX sur le transcript glissant, glossaire en
|
||||||
|
contexte. Corrige aussi la ponctuation et l'anglais approximatif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. État actuel
|
||||||
|
|
||||||
|
### ✅ Validé
|
||||||
|
|
||||||
|
- **audiotee** compilé et fonctionnel (`swift build -c release`).
|
||||||
|
- Capture de l'audio système confirmée depuis **Terminal.app**, après consentement TCC.
|
||||||
|
- Format de sortie retenu : `--sample-rate 16000` → PCM 16-bit signé LE, mono.
|
||||||
|
C'est exactement le format d'entrée attendu par Qwen3-ASR.
|
||||||
|
- Vérification par `ffplay -f s16le -ar 16000 test.pcm` et `volumedetect`.
|
||||||
|
|
||||||
|
### 🔲 À faire
|
||||||
|
|
||||||
|
- [x] Piste micro en parallèle (voir §6.1) — implémenté nativement dans audiotee (fork), pas
|
||||||
|
via un process ffmpeg/AVAudioEngine séparé
|
||||||
|
- [x] Bundle `.app` signé pour audiotee (voir §6.2) — certificat `sttlab-apps` créé en CLI,
|
||||||
|
`~/bin/audiotee` signé avec (`Authority=sttlab-apps`, plus d'ad-hoc), Info.plist embarqué
|
||||||
|
confirmé (`CFBundleIdentifier`, 6 entrées)
|
||||||
|
- [ ] Segmentation VAD
|
||||||
|
- [ ] Intégration Qwen3-ASR en streaming
|
||||||
|
- [ ] Benchmark Qwen3-ASR vs Whisper large-v3-turbo sur audio réel
|
||||||
|
- [ ] Couches de post-correction
|
||||||
|
- [ ] Agent d'analyse / suggestion de questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Prochaines étapes
|
||||||
|
|
||||||
|
### 6.1 Piste micro (priorité 1) — ✅ fait
|
||||||
|
|
||||||
|
audiotee (ce fork) capture maintenant aussi le micro, en plus de l'audio système.
|
||||||
|
|
||||||
|
Implémentation : `InputDeviceResolver.defaultInputDevice()` résout le device d'entrée par
|
||||||
|
défaut via `kAudioHardwarePropertyDefaultInputDevice` — pas besoin de `CATapDescription` ni
|
||||||
|
de device agrégé pour ça (contrairement à l'audio système), donc plus simple que le chemin
|
||||||
|
existant. `AudioRecorder` était déjà agnostique de la source (juste un `deviceID` +
|
||||||
|
`outputHandler`), donc réutilisé tel quel pour faire tourner un deuxième pipeline en
|
||||||
|
parallèle du premier.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Système sur stdout, micro dans un fichier séparé
|
||||||
|
audiotee --capture-mic --mic-output mic.pcm > system.pcm
|
||||||
|
```
|
||||||
|
|
||||||
|
Point de design important : les deux pistes sortent sur **deux flux séparés**, pas
|
||||||
|
multiplexées sur un seul stdout. Deux `AudioRecorder` tournent sur des threads IO Core Audio
|
||||||
|
temps réel indépendants ; les entrelacer sur un seul fd aurait risqué de corrompre les deux
|
||||||
|
flux (write() non garanti atomique au-delà de `PIPE_BUF`, largement dépassé au sample rate
|
||||||
|
natif). Chaque piste garde son écriture atomique par chunk telle qu'elle existait déjà.
|
||||||
|
|
||||||
|
Horodatage : chaque piste a son propre `stream_start` (timestamp mural, sur stderr, en JSON),
|
||||||
|
étiqueté `"audio"` ou `"mic"` pour les distinguer. Il n'y a pas encore de timestamp par chunk
|
||||||
|
audio (le flux stdout reste du PCM brut, sans framing, pour rester zero-copy) — le
|
||||||
|
réalignement précis en aval devra dériver le timestamp de chaque chunk à partir de
|
||||||
|
`stream_start` + position cumulée dans le flux (nb d'échantillons / sample rate).
|
||||||
|
|
||||||
|
Bug corrigé au passage : `SIGINT`/`SIGTERM` pouvaient arriver pendant la phase de setup
|
||||||
|
(avant que la run loop ne démarre), auquel cas `CFRunLoopStop` n'avait aucun effet durable et
|
||||||
|
le process restait bloqué indéfiniment. Le setup à deux pistes rend cette fenêtre bien plus
|
||||||
|
large qu'avant (fix : flag `shouldStop` vérifié avant d'entrer dans la boucle).
|
||||||
|
|
||||||
|
Permission TCC : `--capture-mic` déclenche le prompt micro standard (catégorie différente de
|
||||||
|
`NSAudioCaptureUsageDescription`, sans les pièges spécifiques aux process taps du §8).
|
||||||
|
|
||||||
|
### 6.2 Bundle `.app` signé (priorité 2)
|
||||||
|
|
||||||
|
**Décision d'architecture (2026-08-07) :** l'orchestrateur VAD/ASR sera en **Python**, pas en
|
||||||
|
Swift. audiotee sera donc consommé en **sous-process** (pipe stdout, comme décrit dans son
|
||||||
|
README), pas embarqué comme bibliothèque Swift (`AudioTeeCore` est bien exposée comme library
|
||||||
|
product, mais ça ne s'applique que si le consommateur est du code Swift — ce qui n'est pas le
|
||||||
|
cas ici).
|
||||||
|
|
||||||
|
Conséquence directe : c'est audiotee (le binaire réellement exécuté) qui appelle les API Core
|
||||||
|
Audio, donc c'est **son** identité de signature qui doit être stable pour TCC — pas celle de
|
||||||
|
l'app Python. Le travail ci-dessous reste donc scopé à audiotee seul, indépendant de tout
|
||||||
|
packaging que l'app de transcription Python devra faire de son côté plus tard (qui n'aura
|
||||||
|
probablement besoin d'aucune des deux clés `NSAudioCaptureUsageDescription` /
|
||||||
|
`NSMicrophoneUsageDescription`, puisqu'elle ne fait qu'orchestrer un sous-process).
|
||||||
|
|
||||||
|
**Problème actuel :** l'autorisation TCC est portée par Terminal.app, pas par audiotee.
|
||||||
|
Conséquence : tout ce qui est lancé depuis Terminal hérite de l'accès à l'audio système.
|
||||||
|
C'est trop large, et ça bloquera un lancement depuis un agent au login ou un raccourci.
|
||||||
|
|
||||||
|
**Implémenté (2026-08-07) :** pas de bundle `.app` complet — un simple binaire CLI avec
|
||||||
|
Info.plist embarqué au link, plus simple à consommer en sous-process (pas de résolution de
|
||||||
|
bundle nécessaire côté Python, juste le chemin du binaire).
|
||||||
|
|
||||||
|
- `Sources/AudioTeeCLI/Info.plist` — `CFBundleIdentifier` (`com.stephanetailland.audiotee`),
|
||||||
|
`NSAudioCaptureUsageDescription`, `NSMicrophoneUsageDescription`.
|
||||||
|
- `Package.swift` — `linkerSettings` sur la cible `AudioTeeCLI` embarque ce plist via
|
||||||
|
`-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist`. Vérifié avec
|
||||||
|
`strings .build/release/audiotee | grep CFBundleIdentifier`.
|
||||||
|
- `scripts/build-signed.sh` — build release, signe avec une identité stable (certificat
|
||||||
|
auto-signé du Trousseau, détection automatique via `security find-identity`, ou
|
||||||
|
`AUDIOTEE_SIGNING_IDENTITY` pour forcer), installe dans `~/bin/audiotee` (chemin fixe, cf.
|
||||||
|
piège §8). Flag `--reset-tcc` pour relancer les prompts après un changement d'Info.plist ou
|
||||||
|
d'identité (`tccutil reset SystemAudioCaptureRequests` + `Microphone`).
|
||||||
|
|
||||||
|
**Certificat créé (2026-08-07), en CLI :** `scripts/create-signing-identity.sh sttlab-apps`
|
||||||
|
(nom volontairement générique, pas spécifique à audiotee — un seul certificat sert pour
|
||||||
|
tous les projets perso, cf. note ci-dessous sur la portée d'un certificat). L'utilisateur l'a
|
||||||
|
exécuté lui-même (création de clé privée + import trousseau + confiance `codeSign` = actions
|
||||||
|
sensibles, pas automatisées silencieusement).
|
||||||
|
|
||||||
|
**Piège rencontré et corrigé :** `openssl pkcs12 -export` sans `-legacy` échoue à l'import
|
||||||
|
macOS avec `MAC verification failed during PKCS12 import (wrong password?)` — message
|
||||||
|
trompeur, ce n'est pas un problème de mot de passe. Cause : OpenSSL 3.x chiffre les PKCS12 en
|
||||||
|
AES-256/SHA-256 par défaut, que `SecKeychainItemImport` ne sait pas lire ; il faut l'encodage
|
||||||
|
RC2/3DES legacy (`-legacy` charge le provider OpenSSL correspondant). Déjà corrigé dans
|
||||||
|
`create-signing-identity.sh`.
|
||||||
|
|
||||||
|
**Vérifié fonctionnel :** `~/bin/audiotee` signé avec `Authority=sttlab-apps` (signature
|
||||||
|
réelle, `flags=0x0(none)`, plus `adhoc`), `Identifier=com.stephanetailland.audiotee`,
|
||||||
|
`Info.plist entries=6`. Capture réelle testée (système + micro, écoute via `ffplay`/`afplay`
|
||||||
|
après conversion). Reste formellement à confirmer : que la permission **survit** à un
|
||||||
|
rebuild+re-signature sans nouveau prompt TCC (attendu, vu la signature stable, mais pas
|
||||||
|
encore explicitement vérifié sur plusieurs cycles).
|
||||||
|
|
||||||
|
**Note (portée d'un certificat) :** un seul certificat de signature peut signer plusieurs
|
||||||
|
apps différentes — TCC distingue les apps par `CFBundleIdentifier`, pas par certificat. Pas
|
||||||
|
besoin d'un certificat dédié par projet ; `sttlab-apps` sera réutilisé pour les prochains
|
||||||
|
outils perso, avec un identifiant différent à chaque fois.
|
||||||
|
|
||||||
|
### 6.3 Protocole de benchmark ASR
|
||||||
|
|
||||||
|
Comparer Qwen3-ASR vs Whisper large-v3-turbo sur le **même** échantillon d'audio réel
|
||||||
|
de réunion. Métriques : WER global, WER sur les termes du glossaire, latence, RTF.
|
||||||
|
|
||||||
|
### 6.4 Affichage live du transcript (priorité immédiate)
|
||||||
|
|
||||||
|
**Décision (2026-08-07) :** priorité au **live** uniquement — l'analyse temps réel /
|
||||||
|
suggestions de questions reste explicitement phase 2 (§1), pas à mélanger dans cette étape.
|
||||||
|
|
||||||
|
**Cible d'affichage :** terminal pour commencer (le plus rapide à avoir, imprime les
|
||||||
|
segments au fil de l'eau). Mais le pipeline VAD→ASR→merge doit publier son résultat via une
|
||||||
|
**API de streaming** dès maintenant plutôt que d'écrire directement dans le terminal — le
|
||||||
|
terminal devient le premier client de cette API, pas une sortie câblée en dur. Ça évite de
|
||||||
|
re-architecturer le pipeline quand une page web (ou l'analyse phase 2) voudra s'y brancher.
|
||||||
|
|
||||||
|
**Choix technique : SSE (Server-Sent Events), pas WebSocket.** Le flux est unidirectionnel
|
||||||
|
(serveur → clients, aucun besoin de faire remonter des messages depuis un client pour
|
||||||
|
l'instant) — SSE suffit : HTTP simple, testable au `curl`, consommable nativement par un
|
||||||
|
navigateur (`EventSource`, zéro lib côté client) et par un client terminal Python basique.
|
||||||
|
WebSocket serait sur-dimensionné tant qu'aucun besoin bidirectionnel n'apparaît.
|
||||||
|
|
||||||
|
**Format des messages** (un par segment) : `{track: "system"|"mic", text, is_final, timestamp}`.
|
||||||
|
`is_final` distingue une hypothèse partielle (streaming ASR, peut encore changer) d'un
|
||||||
|
segment clos par la VAD.
|
||||||
|
|
||||||
|
Pas encore implémenté — c'est le prochain chantier, côté projet Python (hors de ce repo
|
||||||
|
audiotee).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Limite connue : iPhone
|
||||||
|
|
||||||
|
iOS ne permet pas de capturer l'audio d'un appel ou d'une app tierce. En mobilité, on est
|
||||||
|
limité au micro (réunion en présentiel, ou haut-parleur).
|
||||||
|
|
||||||
|
Apps locales possibles : Aiko, Hello Transcribe, ou toute app basée sur WhisperKit.
|
||||||
|
**Hors périmètre du développement actuel.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pièges connus (macOS / TCC)
|
||||||
|
|
||||||
|
| Piège | Détail |
|
||||||
|
|---|---|
|
||||||
|
| **Deux catégories TCC distinctes** | « Enregistrement de l'écran et des sons du système » = ScreenCaptureKit. « Enregistrement des sons du système **uniquement** » = Core Audio process taps (`NSAudioCaptureUsageDescription`). C'est la seconde qui compte pour audiotee. |
|
||||||
|
| **Signature obligatoire** | Les process taps exigent une identité de signature stable — TCC indexe dessus. Un binaire non signé compile mais ne capture rien : le prompt ne se déclenche jamais. Symptôme : tourne sans planter, enregistre du silence. |
|
||||||
|
| **iTerm ne prompte pas toujours** | Terminal.app déclenche le prompt de façon fiable, iTerm non. Utiliser Terminal.app pour la première autorisation. |
|
||||||
|
| **Impossible d'accorder TCC en CLI** | `tccutil` sait seulement **réinitialiser**, jamais accorder. La base TCC est protégée par SIP. |
|
||||||
|
| **Pas d'API publique de permission** | Aucun moyen officiel de vérifier ou demander l'autorisation. Soit on déclenche le prompt au premier enregistrement, soit on passe par le TCC privé (voir approche AudioCap). |
|
||||||
|
| **Chemin du binaire = identité** | TCC indexe sur le chemin. Laisser le binaire dans `.build/` risque de perdre l'autorisation à chaque rebuild. Copier dans `~/bin/`. |
|
||||||
|
| **Atténuation des taps** | Gain négatif variable selon le nombre de paires stéréo du périphérique de sortie. ~0 dB sur HP intégrés / AirPods, jusqu'à ~-12 dB sur interface multi-sorties. À vérifier avec `volumedetect` sur la config réelle de réunion — un signal faible dégrade l'ASR. |
|
||||||
|
| **Conversion = 16 bits** | Toute conversion de sample rate bascule la sortie en 16-bit signé (depuis 32-bit float). Sans importance pour l'ASR, mais comportement non évident. |
|
||||||
|
| **API audiotee instable** | L'auteur prévient explicitement que l'API peut changer sans préavis. Pinner un commit. |
|
||||||
|
| **Périphérique par défaut uniquement** | audiotee ne supporte que le périphérique de sortie par défaut. |
|
||||||
|
|
||||||
|
### Commandes de diagnostic utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check whether capture actually produced sound (mean_volume ≈ -90 dB means silence)
|
||||||
|
ffmpeg -f s16le -ar 16000 -ac 1 -i test.pcm -af volumedetect -f null -
|
||||||
|
|
||||||
|
# Convert raw PCM to WAV for inspection
|
||||||
|
ffmpeg -f s16le -ar 16000 -ac 1 -i test.pcm test.wav
|
||||||
|
|
||||||
|
# Force the TCC prompt to reappear
|
||||||
|
tccutil reset SystemAudioCaptureRequests <bundle-id>
|
||||||
|
|
||||||
|
# Open the right Settings pane directly
|
||||||
|
open "x-apple.systempreferences:com.apple.preference.security?Privacy_AudioCapture"
|
||||||
|
|
||||||
|
# Inspect current TCC state (requires Full Disk Access)
|
||||||
|
sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
|
||||||
|
"select service, client, auth_value from access where service like '%Audio%';"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Références
|
||||||
|
|
||||||
|
| Ressource | URL |
|
||||||
|
|---|---|
|
||||||
|
| audiotee | https://github.com/makeusabrew/audiotee |
|
||||||
|
| audiotee.js (wrapper Node) | https://github.com/makeusabrew/audioteejs |
|
||||||
|
| AudioCap (TCC probing) | https://github.com/insidegui/AudioCap |
|
||||||
|
| Apple — Core Audio taps | https://developer.apple.com/documentation/CoreAudio/capturing-system-audio-with-core-audio-taps |
|
||||||
|
| Apple — NSAudioCaptureUsageDescription | https://developer.apple.com/documentation/bundleresources/information-property-list/nsaudiocaptureusagedescription |
|
||||||
|
| talat (référence : même archi, produit fini) | https://talat.app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Conventions
|
||||||
|
|
||||||
|
- **Code et commentaires en anglais.**
|
||||||
|
- Réponses / documentation en français.
|
||||||
|
- Cible : macOS 14.4+ (requis pour la bonne catégorie TCC des process taps).
|
||||||
|
- Swift 5.9+ (Command Line Tools suffisent, pas besoin de Xcode complet).
|
||||||
+42
-4
@@ -8,11 +8,49 @@ let package = Package(
|
|||||||
platforms: [
|
platforms: [
|
||||||
.macOS("14.2")
|
.macOS("14.2")
|
||||||
],
|
],
|
||||||
targets: [
|
products: [
|
||||||
.executableTarget(
|
// Library that can be imported by other packages
|
||||||
|
.library(
|
||||||
|
name: "AudioTeeCore",
|
||||||
|
targets: ["AudioTeeCore"]
|
||||||
|
),
|
||||||
|
// CLI executable
|
||||||
|
.executable(
|
||||||
name: "audiotee",
|
name: "audiotee",
|
||||||
swiftSettings: [
|
targets: ["AudioTeeCLI"]
|
||||||
.define("ENABLE_TCC_SPI")
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Core library with all business logic
|
||||||
|
.target(
|
||||||
|
name: "AudioTeeCore",
|
||||||
|
path: "Sources/AudioTeeCore"
|
||||||
|
),
|
||||||
|
|
||||||
|
// CLI executable that uses the library
|
||||||
|
.executableTarget(
|
||||||
|
name: "AudioTeeCLI",
|
||||||
|
dependencies: ["AudioTeeCore"],
|
||||||
|
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
|
||||||
|
.testTarget(
|
||||||
|
name: "AudioTeeCoreTests",
|
||||||
|
dependencies: ["AudioTeeCore"],
|
||||||
|
path: "Tests/AudioTeeCoreTests"
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
# AudioTee
|
# AudioTee
|
||||||
|
|
||||||
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
|
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
|
||||||
|
|
||||||
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
|
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can capture whatever's playing through your speakers to a file like this:
|
||||||
|
|
||||||
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
|
```bash
|
||||||
|
/path/to/audiotee > output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
|
It's more likely you want to capture this output programmatically. Check out [AudioTee.js](https://github.com/makeusabrew/audioteejs) for a simple Node.js package which does this.
|
||||||
|
|
||||||
|
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - save it to disk, visualise it, transcribe it, etc.
|
||||||
|
|
||||||
|
By default, AudioTee captures audio output from **all** running processes. Tap output defaults to `mono` (configurable via the `--stereo` flag) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
|
||||||
|
|
||||||
|
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, frankly, improvements) of AudioTee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
|
||||||
|
|
||||||
|
## Why?
|
||||||
|
|
||||||
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
||||||
|
|
||||||
@@ -16,7 +28,7 @@ Recording system audio is harder than it should be on macOS, and folks often wre
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms:
|
The following will start capturing audio output from all running programs and write binary chunks of raw PCM audio data to your terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:makeusabrew/audiotee.git
|
git clone git@github.com:makeusabrew/audiotee.git
|
||||||
@@ -24,7 +36,17 @@ cd audiotee
|
|||||||
swift run
|
swift run
|
||||||
```
|
```
|
||||||
|
|
||||||
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes.
|
More usefully, you can redirect `stdout` to a file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift run audiotee --sample-rate 16000 > output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
|
Which you can play back using something like `ffplay`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffplay -f s16le -ar 16000 output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -40,23 +62,29 @@ swift build -c release
|
|||||||
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
|
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-detect output format (JSON in terminal, binary when piped)
|
# Write raw PCM audio to stdout (logs go to stderr)
|
||||||
./audiotee
|
./audiotee
|
||||||
|
|
||||||
# Always use JSON format (terminal-safe)
|
# Redirect audio to a file
|
||||||
./audiotee --format json
|
./audiotee > output.pcm
|
||||||
|
|
||||||
# Always use binary format (pipe-optimised)
|
# Pipe to another program
|
||||||
./audiotee --format binary
|
./audiotee | your_audio_processing_tool
|
||||||
|
|
||||||
|
# Redirect logs as well
|
||||||
|
./audiotee > captured_audio.pcm 2> audiotee.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audio conversion
|
### Audio conversion
|
||||||
|
|
||||||
Note that performing sample rate conversion will also convert the output bit depth to
|
Note that performing _any_ sample rate conversion will also convert the output bit depth to
|
||||||
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
|
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for a 50% reduction in output size. For ASR services, 16-bit is sufficient, but it's a non-obvious behaviour worth being aware of.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Convert to 16kHz mono (useful for ASR services)
|
# No sample rate preserves your device's default (probably 44.1 or 48kHz with 32-bit float bit depth)
|
||||||
|
./audiotee
|
||||||
|
|
||||||
|
# Any sample rate (even one matching your device default) converts to 16-bit signed integers (half the bandwidth)
|
||||||
./audiotee --sample-rate 16000
|
./audiotee --sample-rate 16000
|
||||||
|
|
||||||
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
|
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
|
||||||
@@ -94,184 +122,106 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output formats
|
### Microphone capture
|
||||||
|
|
||||||
AudioTee supports two output formats optimised for different use cases:
|
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.
|
||||||
|
|
||||||
### JSON format (`--format json` or auto in terminal)
|
```bash
|
||||||
|
# Capture system audio to stdout and mic audio to a separate file
|
||||||
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
|
./audiotee --capture-mic --mic-output mic.pcm > system.pcm
|
||||||
|
|
||||||
### Binary format (`--format binary` or auto when piped)
|
|
||||||
|
|
||||||
JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes.
|
|
||||||
|
|
||||||
## Protocol
|
|
||||||
|
|
||||||
### Message types
|
|
||||||
|
|
||||||
All messages (except raw binary audio chunks) follow this envelope structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "...",
|
|
||||||
"data": { ... }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1. Metadata
|
`--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.
|
||||||
|
|
||||||
Sent once at startup to describe the audio format:
|
## Output
|
||||||
|
|
||||||
```json
|
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
### Audio format
|
||||||
"message_type": "metadata",
|
|
||||||
"data": {
|
- **Format**: Raw PCM audio data
|
||||||
"sample_rate": 48000,
|
- **Channels**: 1 in Mono mode (default), 2 in stereo mode
|
||||||
"channels_per_frame": 1,
|
- **Sample rate**: Matches your output device's sample rate by default (configurable)
|
||||||
"bits_per_channel": 32,
|
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
|
||||||
"is_float": true,
|
- **Endianness**: Little-endian
|
||||||
"capture_mode": "audio",
|
- **Chunk duration**: 200ms by default (configurable)
|
||||||
"device_name": null,
|
|
||||||
"device_uid": null,
|
### Logs and monitoring
|
||||||
"encoding": "pcm_f32le"
|
|
||||||
}
|
All program logs are written to `stderr` and can be captured separately:
|
||||||
}
|
|
||||||
|
```bash
|
||||||
|
# Capture audio and logs separately
|
||||||
|
./audiotee > audio.pcm 2> audiotee.log
|
||||||
|
|
||||||
|
# View logs in real-time while capturing audio
|
||||||
|
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Stream start
|
|
||||||
|
|
||||||
Indicates audio data will follow:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "stream_start",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Audio data
|
|
||||||
|
|
||||||
**JSON format:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "audio",
|
|
||||||
"data": {
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"duration": 0.2,
|
|
||||||
"peak_amplitude": 0.45,
|
|
||||||
"audio_data": "base64_encoded_raw_audio..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Binary format:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "audio",
|
|
||||||
"data": {
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"duration": 0.2,
|
|
||||||
"peak_amplitude": 0.45,
|
|
||||||
"audio_length": 9600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
_Followed immediately by 9600 bytes of raw binary audio data_
|
|
||||||
|
|
||||||
#### 4. Stream stop
|
|
||||||
|
|
||||||
Sent when recording stops:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "stream_stop",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Log messages
|
|
||||||
|
|
||||||
Info, error, and debug messages (useful for monitoring):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "info",
|
|
||||||
"data": {
|
|
||||||
"message": "Starting AudioTee...",
|
|
||||||
"context": { "output_format": "auto" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Consuming output
|
|
||||||
|
|
||||||
**JSON format:**
|
|
||||||
|
|
||||||
1. Parse each line as JSON using the envelope structure
|
|
||||||
2. Use `metadata` message to understand the audio format
|
|
||||||
3. For `audio` messages, decode `audio_data` from base64 to get raw PCM data
|
|
||||||
4. Do something with each chunk of data
|
|
||||||
|
|
||||||
**Binary format:**
|
|
||||||
|
|
||||||
1. Parse JSON metadata lines using the envelope structure
|
|
||||||
2. Use `metadata` message to understand the audio format
|
|
||||||
3. For `audio` messages, read `audio_length` bytes of raw binary data after the JSON line
|
|
||||||
4. Do something with each chunk of data
|
|
||||||
|
|
||||||
**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk.
|
|
||||||
|
|
||||||
## Command Line options
|
## Command Line options
|
||||||
|
|
||||||
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
|
|
||||||
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
||||||
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
||||||
- `--mute`: Mute processes being tapped
|
- `--mute`: Mute processes being tapped
|
||||||
|
- `--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
|
||||||
|
|
||||||
AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
|
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.
|
||||||
|
|
||||||
### Lazy permissions (default approach)
|
`--capture-mic` requires the standard, separate Microphone TCC permission (not
|
||||||
|
`NSAudioCaptureUsageDescription`), and will trigger its own first-run prompt.
|
||||||
|
|
||||||
Simply run `./audiotee` and you'll be prompted for permissions the first time AudioTee tries to record audio from the tap. Note that some terminal emulators (at least `iTerm`) will **not** prompt at all, nor will the process fail: instead, AudioTee will happily run but will record a stream of empty data. The built in macOS terminal **does** prompt for permissions and blocks until granted.
|
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).
|
||||||
|
|
||||||
### Explicit permissions management
|
### Stable permissions across rebuilds
|
||||||
|
|
||||||
Use the `--permissions` flag to check or request permissions ahead of time:
|
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
|
```bash
|
||||||
# Check current permission status
|
scripts/build-signed.sh # build, sign, install to ~/bin/audiotee
|
||||||
./audiotee --permissions
|
scripts/build-signed.sh --reset-tcc # also reset TCC state — useful after changing
|
||||||
|
# Info.plist or the signing identity, to re-trigger
|
||||||
# Request permissions with user prompt
|
# the permission prompts
|
||||||
./audiotee --permissions --request
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that the same caveat as above exists here regarding terminal emulators. If you know why, or how to fix it, please help out.
|
## Built with AudioTee
|
||||||
|
|
||||||
**Exit codes** indicate permission status, making this approach ideal for scripting:
|
<a href="https://talat.app"><img src="https://talat.app/favicon.svg" alt="talat" width="28" height="28" /></a> **[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/).
|
||||||
- `0`: Permissions granted
|
|
||||||
- `1`: Permission status unknown
|
|
||||||
- `2`: Permissions denied
|
|
||||||
|
|
||||||
## References
|
## 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)
|
||||||
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
|
- [AudioCap Implementation](https://github.com/insidegui/AudioCap)
|
||||||
|
- [AudioTee.js](https://github.com/makeusabrew/audioteejs)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -189,11 +189,6 @@ class SimpleArgumentParser {
|
|||||||
throw ArgumentParserError.invalidValue(optionName, value)
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
}
|
}
|
||||||
return doubleValue as! T
|
return doubleValue as! T
|
||||||
} else if type == OutputFormat.self {
|
|
||||||
guard let format = OutputFormat(rawValue: value) else {
|
|
||||||
throw ArgumentParserError.invalidValue(optionName, value)
|
|
||||||
}
|
|
||||||
return format as! T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw ArgumentParserError.invalidValue(optionName, value)
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
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 format: OutputFormat = .auto
|
|
||||||
var includeProcesses: [Int32] = []
|
var includeProcesses: [Int32] = []
|
||||||
var excludeProcesses: [Int32] = []
|
var excludeProcesses: [Int32] = []
|
||||||
var mute: Bool = false
|
var mute: Bool = false
|
||||||
|
var stereo: Bool = false
|
||||||
var sampleRate: Double?
|
var sampleRate: Double?
|
||||||
var chunkDuration: Double = 0.2
|
var chunkDuration: Double = 0.2
|
||||||
var permissionsMode: Bool = false
|
var captureMic: Bool = false
|
||||||
var requestPermissions: Bool = false
|
var micOutputPath: String?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
@@ -20,50 +25,43 @@ struct AudioTee {
|
|||||||
discussion: """
|
discussion: """
|
||||||
AudioTee captures system audio using Core Audio taps and streams it as structured output.
|
AudioTee captures system audio using Core Audio taps and streams it as structured output.
|
||||||
|
|
||||||
Permission modes:
|
|
||||||
• --permissions: Check current audio recording permissions
|
|
||||||
• --permissions --request: Request audio recording permissions
|
|
||||||
|
|
||||||
Output formats:
|
|
||||||
• json: Base64-encoded audio in JSON messages (safe for terminals)
|
|
||||||
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
|
|
||||||
• auto: Automatically choose based on whether stdout is a terminal (default)
|
|
||||||
|
|
||||||
Process filtering:
|
Process filtering:
|
||||||
• include-processes: Only tap specified process IDs (empty = all processes)
|
• include-processes: Only tap specified process IDs (empty = all processes)
|
||||||
• exclude-processes: Tap all processes except specified ones
|
• exclude-processes: Tap all processes except specified ones
|
||||||
• mute: How to handle processes being tapped
|
• mute: How to handle processes being tapped
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
audiotee --permissions # Check audio recording permissions
|
|
||||||
audiotee --permissions --request # Request audio recording permissions
|
|
||||||
audiotee # Auto format, tap all processes
|
audiotee # Auto format, tap all processes
|
||||||
audiotee --format=json # Always use JSON format
|
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
|
||||||
audiotee --format=binary # Always use binary format
|
audiotee --sample-rate 8000 # Convert to 8kHz for telephony
|
||||||
audiotee --sample-rate=16000 # Convert to 16kHz mono for ASR
|
|
||||||
audiotee --sample-rate=8000 # Convert to 8kHz for telephony
|
|
||||||
audiotee --include-processes 1234 # Only tap process 1234
|
audiotee --include-processes 1234 # Only tap process 1234
|
||||||
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 --capture-mic --mic-output mic.pcm > system.pcm
|
||||||
|
# Capture system audio and mic to separate files
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configure arguments
|
// Configure arguments
|
||||||
parser.addFlag(name: "permissions", help: "Check audio recording permissions")
|
|
||||||
parser.addFlag(name: "request", help: "Request permissions (use with --permissions)")
|
|
||||||
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
|
|
||||||
parser.addArrayOption(
|
parser.addArrayOption(
|
||||||
name: "include-processes",
|
name: "include-processes",
|
||||||
help: "Process IDs to include (space-separated, empty = all processes)")
|
help: "Process IDs to include (space-separated, empty = all processes)")
|
||||||
parser.addArrayOption(
|
parser.addArrayOption(
|
||||||
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.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 {
|
||||||
@@ -72,14 +70,14 @@ struct AudioTee {
|
|||||||
var audioTee = AudioTee()
|
var audioTee = AudioTee()
|
||||||
|
|
||||||
// Extract values
|
// Extract values
|
||||||
audioTee.permissionsMode = parser.getFlag("permissions")
|
|
||||||
audioTee.requestPermissions = parser.getFlag("request")
|
|
||||||
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
|
|
||||||
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
||||||
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.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()
|
||||||
@@ -108,29 +106,23 @@ 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 {
|
||||||
if requestPermissions && !permissionsMode {
|
|
||||||
throw ArgumentParserError.validationFailed(
|
throw ArgumentParserError.validationFailed(
|
||||||
"--request can only be used with --permissions")
|
"--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 {
|
||||||
// Handle permissions mode
|
|
||||||
if permissionsMode {
|
|
||||||
let permissionsHandler = PermissionsHandler(shouldRequest: requestPermissions)
|
|
||||||
permissionsHandler.handle() // This will exit with appropriate code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with normal audio tapping functionality
|
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
||||||
Logger.debug("Using output format: \(format)")
|
|
||||||
|
|
||||||
// Validate chunk duration
|
// Validate chunk duration
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Invalid chunk duration",
|
"Invalid chunk duration",
|
||||||
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
@@ -142,14 +134,15 @@ struct AudioTee {
|
|||||||
let tapConfig = TapConfiguration(
|
let tapConfig = TapConfiguration(
|
||||||
processes: processes,
|
processes: processes,
|
||||||
muteBehavior: mute ? .muted : .unmuted,
|
muteBehavior: mute ? .muted : .unmuted,
|
||||||
isExclusive: isExclusive
|
isExclusive: isExclusive,
|
||||||
|
isMono: !stereo
|
||||||
)
|
)
|
||||||
|
|
||||||
let audioTapManager = AudioTapManager()
|
let audioTapManager = AudioTapManager()
|
||||||
do {
|
do {
|
||||||
try audioTapManager.setupAudioTap(with: tapConfig)
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
||||||
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to translate process IDs to audio objects",
|
"Failed to translate process IDs to audio objects",
|
||||||
context: [
|
context: [
|
||||||
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
||||||
@@ -157,56 +150,87 @@ struct AudioTee {
|
|||||||
])
|
])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let deviceID = audioTapManager.getDeviceID() else {
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
||||||
Logger.error("Failed to get device ID from audio tap manager")
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputHandler = createOutputHandler(for: format)
|
let outputHandler = BinaryAudioOutputHandler()
|
||||||
let recorder = AudioRecorder(
|
let recorder = try AudioRecorder(
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
chunkDuration: chunkDuration)
|
chunkDuration: chunkDuration)
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
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
|
||||||
Logger.info("Received SIGTERM, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
|
||||||
|
shouldStop = true
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
|
|
||||||
switch format {
|
|
||||||
case .json:
|
|
||||||
return JSONAudioOutputHandler()
|
|
||||||
case .binary:
|
|
||||||
return BinaryAudioOutputHandler()
|
|
||||||
case .auto:
|
|
||||||
return AutoAudioOutputHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func convertProcessFlags() -> ([Int32], Bool) {
|
private func convertProcessFlags() -> ([Int32], Bool) {
|
||||||
if !includeProcesses.isEmpty {
|
if !includeProcesses.isEmpty {
|
||||||
// Include specific processes only
|
// Include specific processes only
|
||||||
@@ -224,7 +248,7 @@ struct AudioTee {
|
|||||||
// Helper for stderr output
|
// Helper for stderr output
|
||||||
var standardError = FileHandle.standardError
|
var standardError = FileHandle.standardError
|
||||||
|
|
||||||
extension FileHandle: @retroactive TextOutputStream {
|
extension FileHandle: TextOutputStream {
|
||||||
public func write(_ string: String) {
|
public func write(_ string: String) {
|
||||||
let data = Data(string.utf8)
|
let data = Data(string.utf8)
|
||||||
self.write(data)
|
self.write(data)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import AudioTeeCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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: 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
|
||||||
|
while written < count {
|
||||||
|
let result = write(fd, pointer.advanced(by: written), count - written)
|
||||||
|
if result >= 0 {
|
||||||
|
written += result
|
||||||
|
} else if errno == EINTR {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
break // EPIPE, EIO, etc — consumer gone or real error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||||
|
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: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStreamStop() {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.streamStop, data: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AudioTeeCore
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import CoreAudio
|
||||||
|
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 {
|
||||||
|
/// 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 readIndex: Int = 0
|
||||||
|
private var availableBytes: Int = 0
|
||||||
|
private let maxBufferSize: Int
|
||||||
|
|
||||||
|
public let bytesPerChunk: Int
|
||||||
|
|
||||||
|
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||||
|
// Pre-calculate chunk parameters
|
||||||
|
let bytesPerFrame = Int(format.mBytesPerFrame)
|
||||||
|
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
||||||
|
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||||
|
|
||||||
|
// Calculate max buffer size to hold ~10 seconds of audio (safety limit)
|
||||||
|
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
||||||
|
self.maxBufferSize = bytesPerSecond * 10
|
||||||
|
|
||||||
|
// Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
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(
|
||||||
|
"Audio buffer overflow",
|
||||||
|
context: [
|
||||||
|
"requested": String(count),
|
||||||
|
"available": String(maxBufferSize - availableBytes),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if writeIndex + count <= maxBufferSize {
|
||||||
|
// Single contiguous write — no wrap-around needed
|
||||||
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
|
||||||
|
writeIndex = (writeIndex + count) % maxBufferSize
|
||||||
|
} else {
|
||||||
|
// Two writes needed due to wrap-around at the end of the ring buffer
|
||||||
|
let firstChunkSize = maxBufferSize - writeIndex
|
||||||
|
let secondChunkSize = count - firstChunkSize
|
||||||
|
|
||||||
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
|
||||||
|
buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
|
||||||
|
|
||||||
|
writeIndex = secondChunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
availableBytes += count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls `handler` once for each complete chunk available in the buffer.
|
||||||
|
/// The pointer passed to the handler is valid only for the duration of
|
||||||
|
/// that call. In the common (contiguous) case this points directly into
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
private let avConverter: AVAudioConverter
|
||||||
|
private let sourceFormat: 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)
|
||||||
|
throws
|
||||||
|
{
|
||||||
|
var mutableSourceFormat = sourceFormat
|
||||||
|
var mutableTargetFormat = targetFormat
|
||||||
|
|
||||||
|
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
||||||
|
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
||||||
|
else {
|
||||||
|
throw AudioConverterError.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
||||||
|
throw AudioConverterError.creationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sourceFormat = sourceAVFormat
|
||||||
|
self.targetFormat = targetAVFormat
|
||||||
|
self.avConverter = converter
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Audio converter created",
|
||||||
|
context: [
|
||||||
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||||
|
"target_sample_rate": String(targetAVFormat.sampleRate),
|
||||||
|
"source_channels": String(sourceAVFormat.channelCount),
|
||||||
|
"target_channels": String(targetAVFormat.channelCount),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Warn about upsampling once during initialization
|
||||||
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||||
|
AudioTeeLogging.logger.info(
|
||||||
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||||
|
context: [
|
||||||
|
"source_rate": String(sourceAVFormat.sampleRate),
|
||||||
|
"target_rate": String(targetAVFormat.sampleRate),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source format this converter reads from.
|
||||||
|
public var sourceFormatDescription: AudioStreamBasicDescription {
|
||||||
|
return sourceFormat.streamDescription.pointee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target format this converter produces.
|
||||||
|
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||||
|
return targetFormat.streamDescription.pointee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pre-allocated input and output buffers sized for the given
|
||||||
|
/// 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))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reuse cached buffers if they have sufficient capacity
|
||||||
|
if let inputBuf = cachedInputBuffer,
|
||||||
|
let outputBuf = cachedOutputBuffer,
|
||||||
|
inputBuf.frameCapacity >= inputFrameCount,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate new buffers (first call, or unexpected capacity increase)
|
||||||
|
guard
|
||||||
|
let inputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create input buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let outputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: targetFormat, frameCapacity: outputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create output buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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?
|
||||||
|
|
||||||
|
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
||||||
|
requestedPackets, outStatus in
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return inputBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
guard outputBuffer.frameLength > 0 else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Audio conversion produced no output",
|
||||||
|
context: [
|
||||||
|
"status": String(describing: status),
|
||||||
|
"error": String(describing: error),
|
||||||
|
"input_frames": String(inputBuffer.frameLength),
|
||||||
|
"output_capacity": String(outputBuffer.frameCapacity),
|
||||||
|
])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputCount = Int(
|
||||||
|
outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
|
handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func toSampleRate(
|
||||||
|
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
||||||
|
) throws -> AudioFormatConverter {
|
||||||
|
var targetFormat = AudioStreamBasicDescription()
|
||||||
|
targetFormat.mSampleRate = sampleRate
|
||||||
|
targetFormat.mFormatID = kAudioFormatLinearPCM
|
||||||
|
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
||||||
|
targetFormat.mFramesPerPacket = 1
|
||||||
|
targetFormat.mBitsPerChannel = 16
|
||||||
|
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
|
||||||
|
targetFormat.mBytesPerFrame =
|
||||||
|
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
|
||||||
|
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
|
||||||
|
|
||||||
|
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||||
|
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-18
@@ -3,25 +3,26 @@ import CoreAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class AudioFormatManager {
|
public class AudioFormatManager {
|
||||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> 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
|
||||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||||
|
|
||||||
// Poll device readiness
|
// Poll device readiness
|
||||||
for poll in 1...maxPolls {
|
for poll in 1...maxPolls {
|
||||||
if isAudioDeviceValid(deviceID) {
|
if isAudioDeviceValid(deviceID) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if poll == maxPolls {
|
if poll == maxPolls {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Device did not become ready within timeout, proceeding anyway",
|
"Device did not become ready within timeout, proceeding anyway",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -30,7 +31,7 @@ public class AudioFormatManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("------- not ready; retrying...")
|
AudioTeeLogging.logger.info("------- not ready; retrying...")
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: pollInterval)
|
Thread.sleep(forTimeInterval: pollInterval)
|
||||||
}
|
}
|
||||||
@@ -49,11 +50,12 @@ public class AudioFormatManager {
|
|||||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
if status == noErr {
|
if status == noErr {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"------- Failed to get stream format after device ready check, retrying...",
|
"------- Failed to get stream format after device ready check, retrying...",
|
||||||
context: [
|
context: [
|
||||||
"attempt": String(attempt),
|
"attempt": String(attempt),
|
||||||
@@ -69,16 +71,14 @@ public class AudioFormatManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to get device format after device readiness check and retries",
|
"Failed to get device format after device readiness check and retries",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
"device_was_ready": "true",
|
"device_was_ready": "true",
|
||||||
])
|
])
|
||||||
|
|
||||||
fatalError(
|
throw AudioTeeError.deviceFormatUnavailable(deviceID)
|
||||||
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||||
@@ -94,14 +94,8 @@ public class AudioFormatManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func writeMetadata(for format: AudioStreamBasicDescription) {
|
|
||||||
let metadata = createMetadata(for: format)
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Using device's native format",
|
"Using device's native format",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(format.mChannelsPerFrame),
|
"channels": String(format.mChannelsPerFrame),
|
||||||
@@ -5,29 +5,39 @@ import Foundation
|
|||||||
public class AudioRecorder {
|
public class AudioRecorder {
|
||||||
private var deviceID: AudioObjectID
|
private var deviceID: AudioObjectID
|
||||||
private var ioProcID: AudioDeviceIOProcID?
|
private var ioProcID: AudioDeviceIOProcID?
|
||||||
private var sourceFormat: AudioStreamBasicDescription?
|
private var finalFormat: AudioStreamBasicDescription!
|
||||||
private var finalFormat: AudioStreamBasicDescription?
|
|
||||||
private var audioBuffer: AudioBuffer?
|
private var audioBuffer: AudioBuffer?
|
||||||
private var outputHandler: AudioOutputHandler
|
private var outputHandler: AudioOutputHandler
|
||||||
private var converter: AudioFormatConverter?
|
private var converter: AudioFormatConverter?
|
||||||
private var chunkDuration: Double
|
|
||||||
|
|
||||||
init(
|
/// The audio format this recorder produces (after any conversion).
|
||||||
|
public var outputFormat: AudioStreamBasicDescription {
|
||||||
|
return finalFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this recorder is performing sample rate conversion.
|
||||||
|
public var isConverting: Bool {
|
||||||
|
return converter != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||||
chunkDuration: Double = 0.2
|
chunkDuration: Double = 0.2
|
||||||
) {
|
) throws {
|
||||||
self.deviceID = deviceID
|
self.deviceID = deviceID
|
||||||
self.outputHandler = outputHandler
|
self.outputHandler = outputHandler
|
||||||
self.chunkDuration = chunkDuration
|
|
||||||
|
|
||||||
// Get source format and set up conversion if requested
|
// Get source format and set up conversion if requested
|
||||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
let sourceFormat = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||||
self.sourceFormat = sourceFormat
|
|
||||||
|
// Set up the audio buffer using source format and configurable chunk duration
|
||||||
|
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||||
|
|
||||||
if let targetSampleRate = convertToSampleRate {
|
if let targetSampleRate = convertToSampleRate {
|
||||||
// Validate sample rate
|
// Validate sample rate
|
||||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
||||||
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
|
||||||
@@ -37,10 +47,10 @@ public class AudioRecorder {
|
|||||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.finalFormat = converter.targetFormatDescription
|
self.finalFormat = converter.targetFormatDescription
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to create audio converter, using original format",
|
"Failed to create audio converter, using original format",
|
||||||
context: ["error": String(describing: error)])
|
context: ["error": String(describing: error)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
@@ -52,31 +62,24 @@ public class AudioRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRecording() {
|
public func startRecording() throws {
|
||||||
Logger.debug("Starting audio recording")
|
AudioTeeLogging.logger.debug("Starting audio recording")
|
||||||
|
|
||||||
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
|
// Log format info and send metadata for final format
|
||||||
fatalError("Audio formats not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the audio buffer using source format and configurable chunk duration
|
|
||||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
|
||||||
|
|
||||||
// Log format info and send metadata for FINAL format
|
|
||||||
AudioFormatManager.logFormatInfo(finalFormat)
|
AudioFormatManager.logFormatInfo(finalFormat)
|
||||||
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
||||||
outputHandler.handleMetadata(metadata)
|
outputHandler.handleMetadata(metadata)
|
||||||
outputHandler.handleStreamStart()
|
outputHandler.handleStreamStart()
|
||||||
|
|
||||||
// Set up and start the IO proc
|
try setupAndStartIOProc()
|
||||||
setupAndStartIOProc()
|
|
||||||
|
|
||||||
Logger.info("Audio device started successfully")
|
AudioTeeLogging.logger.info("Audio device started successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: note to self, what about installTap? Would require audio engine and a node?
|
// Note to self, what about installTap? Would require audio engine and a node?
|
||||||
private func setupAndStartIOProc() {
|
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||||
Logger.debug("Creating IO proc")
|
private func setupAndStartIOProc() throws {
|
||||||
|
AudioTeeLogging.logger.debug("Creating IO proc")
|
||||||
var status = AudioDeviceCreateIOProcID(
|
var status = AudioDeviceCreateIOProcID(
|
||||||
deviceID,
|
deviceID,
|
||||||
{
|
{
|
||||||
@@ -90,15 +93,15 @@ public class AudioRecorder {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard status == noErr else {
|
guard status == noErr else {
|
||||||
fatalError("Failed to create IO proc: \(status)")
|
throw AudioTeeError.ioProcCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug("Starting audio device")
|
AudioTeeLogging.logger.debug("Starting audio device")
|
||||||
status = AudioDeviceStart(deviceID, ioProcID)
|
status = AudioDeviceStart(deviceID, ioProcID)
|
||||||
|
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
cleanupIOProc()
|
cleanupIOProc()
|
||||||
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
|
throw AudioTeeError.deviceStartFailed(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,35 +109,43 @@ 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 {
|
||||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
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))
|
||||||
|
|
||||||
// Process and send complete chunks, applying conversion if needed
|
processAudioBuffer()
|
||||||
audioBuffer?.processChunks().forEach { packet in
|
|
||||||
let processedPacket = converter?.transform(packet) ?? packet
|
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
return noErr
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
public func stopRecording() {
|
||||||
// Send any remaining buffered audio, applying conversion if needed
|
processAudioBuffer()
|
||||||
if let finalPacket = audioBuffer?.flushRemaining() {
|
|
||||||
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
|
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputHandler.handleStreamStop()
|
outputHandler.handleStreamStop()
|
||||||
cleanupIOProc()
|
cleanupIOProc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func processAudioBuffer() {
|
||||||
|
audioBuffer?.processChunks { pointer, count in
|
||||||
|
if let converter = self.converter {
|
||||||
|
if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func cleanupIOProc() {
|
private func cleanupIOProc() {
|
||||||
if let ioProcID = ioProcID {
|
if let ioProcID = ioProcID {
|
||||||
AudioDeviceStop(deviceID, ioProcID)
|
AudioDeviceStop(deviceID, ioProcID)
|
||||||
+18
-22
@@ -3,16 +3,14 @@ import AudioToolbox
|
|||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class AudioTapManager {
|
public class AudioTapManager {
|
||||||
private var tapID: AudioObjectID?
|
private var tapID: AudioObjectID?
|
||||||
private var deviceID: AudioObjectID?
|
private var deviceID: AudioObjectID?
|
||||||
|
|
||||||
init() {
|
public init() {}
|
||||||
// Empty init - setup happens in setupAudioTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Logger.debug("Cleaning up audio tap manager")
|
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
|
||||||
|
|
||||||
if let tapID = tapID {
|
if let tapID = tapID {
|
||||||
AudioHardwareDestroyProcessTap(tapID)
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
@@ -26,8 +24,8 @@ class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up the audio tap and aggregate device
|
/// Sets up the audio tap and aggregate device
|
||||||
func setupAudioTap(with config: TapConfiguration) throws {
|
public func setupAudioTap(with config: TapConfiguration) throws {
|
||||||
Logger.debug("Setting up audio tap manager")
|
AudioTeeLogging.logger.debug("Setting up audio tap manager")
|
||||||
|
|
||||||
tapID = try createSystemAudioTap(with: config)
|
tapID = try createSystemAudioTap(with: config)
|
||||||
deviceID = try createAggregateDevice()
|
deviceID = try createAggregateDevice()
|
||||||
@@ -38,51 +36,48 @@ class AudioTapManager {
|
|||||||
|
|
||||||
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
||||||
|
|
||||||
Logger.debug("Audio tap manager setup complete")
|
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the aggregate device ID for recording
|
/// Returns the aggregate device ID for recording
|
||||||
func getDeviceID() -> AudioObjectID? {
|
public func getDeviceID() -> AudioObjectID? {
|
||||||
return deviceID
|
return deviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||||
Logger.debug("Creating tap description")
|
AudioTeeLogging.logger.debug("Creating tap description")
|
||||||
// Create a tap description
|
|
||||||
let description = CATapDescription()
|
let description = CATapDescription()
|
||||||
|
|
||||||
// Configure the tap to capture all system audio
|
|
||||||
description.name = "audiotee-tap"
|
description.name = "audiotee-tap"
|
||||||
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
|
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
|
||||||
description.isPrivate = true
|
description.isPrivate = true
|
||||||
description.muteBehavior = config.muteBehavior.coreAudioValue
|
description.muteBehavior = config.muteBehavior.coreAudioValue
|
||||||
description.isMixdown = true
|
description.isMixdown = true
|
||||||
description.isMono = true
|
description.isMono = config.isMono
|
||||||
description.isExclusive = config.isExclusive
|
description.isExclusive = config.isExclusive
|
||||||
description.deviceUID = nil // system default
|
description.deviceUID = nil // system default
|
||||||
description.stream = 0 // first stream of output device
|
description.stream = 0 // first stream of output device
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap description configured",
|
"Tap description configured",
|
||||||
context: [
|
context: [
|
||||||
"name": description.name,
|
"name": description.name,
|
||||||
"processes": String(describing: config.processes),
|
"processes": String(describing: config.processes),
|
||||||
"private": String(description.isPrivate),
|
|
||||||
"mute": String(describing: description.muteBehavior),
|
"mute": String(describing: description.muteBehavior),
|
||||||
"mixdown": String(description.isMixdown),
|
|
||||||
"mono": String(description.isMono),
|
"mono": String(description.isMono),
|
||||||
"exclusive": String(description.isExclusive),
|
"exclusive": String(description.isExclusive),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Create the tap
|
// Create the tap
|
||||||
Logger.debug("Creating tap")
|
AudioTeeLogging.logger.debug("Creating tap")
|
||||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||||
|
|
||||||
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 {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +89,7 @@ class AudioTapManager {
|
|||||||
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
||||||
|
|
||||||
if formatStatus == noErr {
|
if formatStatus == noErr {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap format retrieved",
|
"Tap format retrieved",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(streamDescription.mChannelsPerFrame),
|
"channels": String(streamDescription.mChannelsPerFrame),
|
||||||
@@ -121,7 +116,8 @@ class AudioTapManager {
|
|||||||
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +144,7 @@ class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapAssignmentFailed(status)
|
throw AudioTeeError.tapAssignmentFailed(status)
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Core AudioTee Errors
|
// MARK: - Core AudioTee Errors
|
||||||
@@ -8,6 +9,10 @@ public enum AudioTeeError: Error {
|
|||||||
case aggregateDeviceCreationFailed(OSStatus)
|
case aggregateDeviceCreationFailed(OSStatus)
|
||||||
case tapAssignmentFailed(OSStatus)
|
case tapAssignmentFailed(OSStatus)
|
||||||
case pidTranslationFailed([Int32])
|
case pidTranslationFailed([Int32])
|
||||||
|
case deviceFormatUnavailable(AudioObjectID)
|
||||||
|
case ioProcCreationFailed(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -2,10 +2,12 @@ public struct TapConfiguration {
|
|||||||
public let processes: [Int32]
|
public let processes: [Int32]
|
||||||
public let muteBehavior: TapMuteBehavior
|
public let muteBehavior: TapMuteBehavior
|
||||||
public let isExclusive: Bool
|
public let isExclusive: Bool
|
||||||
|
public let isMono: Bool
|
||||||
|
|
||||||
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
|
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool, isMono: Bool) {
|
||||||
self.processes = processes
|
self.processes = processes
|
||||||
self.muteBehavior = muteBehavior
|
self.muteBehavior = muteBehavior
|
||||||
self.isExclusive = isExclusive
|
self.isExclusive = isExclusive
|
||||||
|
self.isMono = isMono
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
import Foundation
|
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()
|
||||||
@@ -7,9 +7,6 @@ public enum MessageType: String, Codable {
|
|||||||
case streamStart = "stream_start"
|
case streamStart = "stream_start"
|
||||||
case streamStop = "stream_stop"
|
case streamStop = "stream_stop"
|
||||||
|
|
||||||
// Audio data
|
|
||||||
case audio
|
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
case info
|
case info
|
||||||
case error
|
case error
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Logging protocol
|
||||||
|
|
||||||
|
/// Protocol that library consumers implement to receive log output.
|
||||||
|
/// The library never writes to stderr directly — it calls through this.
|
||||||
|
public protocol AudioTeeLogger {
|
||||||
|
func debug(_ message: String, context: [String: String]?)
|
||||||
|
func info(_ message: String, context: [String: String]?)
|
||||||
|
func error(_ message: String, context: [String: String]?)
|
||||||
|
|
||||||
|
/// Called for structured lifecycle messages (metadata, stream_start, stream_stop).
|
||||||
|
/// Default implementation is a no-op — pure library consumers get metadata
|
||||||
|
/// via AudioOutputHandler instead.
|
||||||
|
func writeMessage<T: Codable>(_ type: MessageType, data: T?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
extension AudioTeeLogger {
|
||||||
|
/// Library consumers typically don't need structured message output;
|
||||||
|
/// they receive metadata via the AudioOutputHandler protocol instead.
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {}
|
||||||
|
|
||||||
|
/// Convenience overloads so callers can omit context when it's nil.
|
||||||
|
public func debug(_ message: String) { debug(message, context: nil) }
|
||||||
|
public func info(_ message: String) { info(message, context: nil) }
|
||||||
|
public func error(_ message: String) { error(message, context: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global logging configuration
|
||||||
|
|
||||||
|
/// Global logger instance. Defaults to StderrJSONLogger (CLI behavior).
|
||||||
|
/// Library consumers can replace this before calling any AudioTeeCore API.
|
||||||
|
///
|
||||||
|
/// // Silence all logging:
|
||||||
|
/// AudioTeeLogging.logger = NullLogger()
|
||||||
|
///
|
||||||
|
/// // Custom logging:
|
||||||
|
/// AudioTeeLogging.logger = MyOSLogLogger()
|
||||||
|
///
|
||||||
|
public enum AudioTeeLogging {
|
||||||
|
nonisolated(unsafe) public static var logger: AudioTeeLogger = StderrJSONLogger()
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Default logger implementation that writes JSON messages to stderr.
|
||||||
|
/// This is the CLI-appropriate logger; library consumers can replace it
|
||||||
|
/// via AudioTeeLogging.logger.
|
||||||
|
public class StderrJSONLogger: AudioTeeLogger {
|
||||||
|
private let dateFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [
|
||||||
|
.withInternetDateTime,
|
||||||
|
.withFractionalSeconds,
|
||||||
|
]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let jsonEncoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
// Configured in init because stored property initializers can't
|
||||||
|
// reference other instance properties (self.dateFormatter).
|
||||||
|
jsonEncoder.dateEncodingStrategy = .custom { [dateFormatter] date, encoder in
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(dateFormatter.string(from: date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any message with the unified envelope to stderr
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {
|
||||||
|
let message = Message(type: type, data: data)
|
||||||
|
do {
|
||||||
|
let jsonData = try jsonEncoder.encode(message)
|
||||||
|
FileHandle.standardError.write(jsonData)
|
||||||
|
FileHandle.standardError.write("\n".data(using: .utf8)!)
|
||||||
|
} catch {
|
||||||
|
// TODO: handle at some point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods for different message types
|
||||||
|
public func info(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.info, data: logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.error, data: logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func debug(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.debug, data: logData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
|
|||||||
|
|
||||||
let valid = status == kAudioHardwareNoError && isAlive == 1
|
let valid = status == kAudioHardwareNoError && isAlive == 1
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Checked device validity",
|
"Checked device validity",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -63,7 +63,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
||||||
processObjects.append(processObject)
|
processObjects.append(processObject)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Translated PID to process object",
|
"Translated PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -71,7 +71,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
failedPIDs.append(pid)
|
failedPIDs.append(pid)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Failed to translate PID to process object",
|
"Failed to translate PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -87,11 +87,3 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
return processObjects
|
return processObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
|
||||||
func print(to fileHandle: FileHandle) {
|
|
||||||
if let data = (self + "\n").data(using: .utf8) {
|
|
||||||
fileHandle.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
enum OutputFormat: String, CaseIterable {
|
|
||||||
case json = "json"
|
|
||||||
case binary = "binary"
|
|
||||||
case auto = "auto"
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .json:
|
|
||||||
return "Base64-encoded JSON (terminal-safe)"
|
|
||||||
case .binary:
|
|
||||||
return "Binary with JSON headers (pipe-optimised)"
|
|
||||||
case .auto:
|
|
||||||
return "Auto-detect based on TTY (default)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import CoreFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Handles audio recording permissions for the CLI, including checking status and requesting permissions.
|
|
||||||
/// Uses exit codes to communicate permission status:
|
|
||||||
/// - 0: granted (authorized)
|
|
||||||
/// - 1: unknown
|
|
||||||
/// - 2: denied
|
|
||||||
struct PermissionsHandler {
|
|
||||||
private let shouldRequest: Bool
|
|
||||||
|
|
||||||
init(shouldRequest: Bool) {
|
|
||||||
self.shouldRequest = shouldRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the permissions workflow and exits with appropriate exit code
|
|
||||||
func handle() -> Never {
|
|
||||||
let permissionHandler = AudioRecordingPermission()
|
|
||||||
|
|
||||||
if shouldRequest {
|
|
||||||
print("Requesting audio recording permissions...")
|
|
||||||
permissionHandler.request()
|
|
||||||
|
|
||||||
// Wait for the permission request to complete
|
|
||||||
while permissionHandler.status == .unknown {
|
|
||||||
// Run the main run loop to allow DispatchQueue.main.async to execute
|
|
||||||
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, true)
|
|
||||||
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get final status and exit with appropriate code
|
|
||||||
let status = permissionHandler.status
|
|
||||||
print("Audio recording permission status: \(status.rawValue)")
|
|
||||||
|
|
||||||
switch status {
|
|
||||||
case .authorized:
|
|
||||||
exit(0) // granted
|
|
||||||
case .unknown:
|
|
||||||
exit(1) // unknown
|
|
||||||
case .denied:
|
|
||||||
exit(2) // denied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class AudioBuffer {
|
|
||||||
private var buffer = Data()
|
|
||||||
private let targetChunkDuration: Double
|
|
||||||
private let streamFormat: AudioStreamBasicDescription
|
|
||||||
|
|
||||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
|
||||||
self.streamFormat = format
|
|
||||||
self.targetChunkDuration = chunkDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
public func append(_ data: Data) {
|
|
||||||
buffer.append(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func processChunks() -> [AudioPacket] {
|
|
||||||
var packets: [AudioPacket] = []
|
|
||||||
|
|
||||||
while let packet = nextChunk() {
|
|
||||||
packets.append(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
return packets
|
|
||||||
}
|
|
||||||
|
|
||||||
public func flushRemaining() -> AudioPacket? {
|
|
||||||
guard !buffer.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: 0.0, // Unknown duration for final chunk
|
|
||||||
peakAmplitude: 0.0,
|
|
||||||
rawAudioData: buffer
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer.removeAll()
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nextChunk() -> AudioPacket? {
|
|
||||||
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
|
|
||||||
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
|
|
||||||
let bytesPerChunk = samplesPerChunk * bytesPerFrame
|
|
||||||
|
|
||||||
guard buffer.count >= bytesPerChunk else { return nil }
|
|
||||||
|
|
||||||
let chunkData = buffer.prefix(bytesPerChunk)
|
|
||||||
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
|
|
||||||
peakAmplitude: 0.0, // No analysis in raw mode
|
|
||||||
rawAudioData: Data(chunkData)
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer.removeFirst(bytesPerChunk)
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Simple audio format converter using AVFoundation
|
|
||||||
public class AudioFormatConverter {
|
|
||||||
private let avConverter: AVAudioConverter
|
|
||||||
private let sourceFormat: AVAudioFormat
|
|
||||||
private let targetFormat: AVAudioFormat
|
|
||||||
|
|
||||||
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
|
||||||
throws
|
|
||||||
{
|
|
||||||
var mutableSourceFormat = sourceFormat
|
|
||||||
var mutableTargetFormat = targetFormat
|
|
||||||
|
|
||||||
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
|
||||||
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
|
||||||
else {
|
|
||||||
throw AudioConverterError.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
|
||||||
throw AudioConverterError.creationFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sourceFormat = sourceAVFormat
|
|
||||||
self.targetFormat = targetAVFormat
|
|
||||||
self.avConverter = converter
|
|
||||||
|
|
||||||
Logger.debug(
|
|
||||||
"Audio converter created",
|
|
||||||
context: [
|
|
||||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
|
||||||
"target_sample_rate": String(targetAVFormat.sampleRate),
|
|
||||||
"source_channels": String(sourceAVFormat.channelCount),
|
|
||||||
"target_channels": String(targetAVFormat.channelCount),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Warn about upsampling once during initialization
|
|
||||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
|
||||||
Logger.info(
|
|
||||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
|
||||||
context: [
|
|
||||||
"source_rate": String(sourceAVFormat.sampleRate),
|
|
||||||
"target_rate": String(targetAVFormat.sampleRate),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the target format as AudioStreamBasicDescription
|
|
||||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
|
||||||
return targetFormat.streamDescription.pointee
|
|
||||||
}
|
|
||||||
|
|
||||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
|
||||||
let inputData = packet.rawAudioData
|
|
||||||
|
|
||||||
// Calculate frame counts
|
|
||||||
let inputFrameCount =
|
|
||||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
|
||||||
let outputFrameCount = Int(
|
|
||||||
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
|
||||||
|
|
||||||
// Create input buffer
|
|
||||||
guard
|
|
||||||
let inputBuffer = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
|
||||||
else {
|
|
||||||
Logger.error("Failed to create input buffer")
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let outputBuffer = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
|
||||||
else {
|
|
||||||
Logger.error("Failed to create output buffer")
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform conversion - simpler approach
|
|
||||||
var error: NSError?
|
|
||||||
|
|
||||||
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
|
||||||
requestedPackets, outStatus in
|
|
||||||
// Always provide our input buffer and let converter manage it
|
|
||||||
outStatus.pointee = .haveData
|
|
||||||
return inputBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if conversion produced output (regardless of status code)
|
|
||||||
guard outputBuffer.frameLength > 0 else {
|
|
||||||
Logger.error(
|
|
||||||
"Audio conversion produced no output",
|
|
||||||
context: [
|
|
||||||
"status": String(describing: status),
|
|
||||||
"error": String(describing: error),
|
|
||||||
"input_frames": String(inputBuffer.frameLength),
|
|
||||||
"output_capacity": String(outputBuffer.frameCapacity),
|
|
||||||
])
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract converted data
|
|
||||||
let outputData = Data(
|
|
||||||
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
|
|
||||||
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
|
|
||||||
|
|
||||||
// Return new packet with converted audio (keeping original metadata for simplicity)
|
|
||||||
return AudioPacket(
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
duration: packet.duration,
|
|
||||||
peakAmplitude: packet.peakAmplitude,
|
|
||||||
rawAudioData: outputData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Constructors
|
|
||||||
|
|
||||||
extension AudioFormatConverter {
|
|
||||||
/// Create a converter to a specific sample rate with mono PCM 16-bit output
|
|
||||||
/// Since the tap already converts to mono, we hardcode channels to 1
|
|
||||||
public static func toSampleRate(
|
|
||||||
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
|
||||||
) throws -> AudioFormatConverter {
|
|
||||||
var targetFormat = AudioStreamBasicDescription()
|
|
||||||
targetFormat.mSampleRate = sampleRate
|
|
||||||
targetFormat.mFormatID = kAudioFormatLinearPCM
|
|
||||||
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
|
||||||
targetFormat.mBytesPerPacket = 2
|
|
||||||
targetFormat.mFramesPerPacket = 1
|
|
||||||
targetFormat.mBytesPerFrame = 2
|
|
||||||
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
|
|
||||||
targetFormat.mBitsPerChannel = 16
|
|
||||||
|
|
||||||
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Common sample rates for validation
|
|
||||||
public static let supportedSampleRates: [Double] = [
|
|
||||||
8000, 16000, 22050, 24000, 32000, 44100, 48000,
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Validate if a sample rate is supported
|
|
||||||
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
|
||||||
return supportedSampleRates.contains(sampleRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error Types
|
|
||||||
|
|
||||||
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct AudioPacket {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float // useful for level monitoring
|
|
||||||
public let rawAudioData: Data
|
|
||||||
|
|
||||||
public init(
|
|
||||||
timestamp: Date,
|
|
||||||
duration: Double,
|
|
||||||
peakAmplitude: Float,
|
|
||||||
rawAudioData: Data
|
|
||||||
) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.duration = duration
|
|
||||||
self.peakAmplitude = peakAmplitude
|
|
||||||
self.rawAudioData = rawAudioData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Auto-detecting output handler based on TTY
|
|
||||||
public class AutoAudioOutputHandler: AudioOutputHandler {
|
|
||||||
private let handler: AudioOutputHandler
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
// Auto-detect based on whether stdout is a terminal
|
|
||||||
if isatty(STDOUT_FILENO) != 0 {
|
|
||||||
handler = JSONAudioOutputHandler()
|
|
||||||
} else {
|
|
||||||
handler = BinaryAudioOutputHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
handler.handleAudioPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
handler.handleMetadata(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
handler.handleStreamStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
handler.handleStreamStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Binary output with JSON headers (pipe-optimised)
|
|
||||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
// Create metadata without the audio data
|
|
||||||
let metadata = BinaryPacketHeader(from: packet)
|
|
||||||
|
|
||||||
// Write JSON metadata line
|
|
||||||
Logger.writeMessage(.audio, data: metadata)
|
|
||||||
|
|
||||||
// Write raw binary audio data directly to stdout
|
|
||||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Base64-encoded JSON output (terminal-safe)
|
|
||||||
public class JSONAudioOutputHandler: AudioOutputHandler {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
let jsonPacket = JSONAudioPacket(from: packet)
|
|
||||||
Logger.writeMessage(.audio, data: jsonPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// JSON-serializable version of AudioPacket with base64-encoded audio data
|
|
||||||
public struct JSONAudioPacket: Codable {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float
|
|
||||||
public let audioData: String // base64 encoded audio data
|
|
||||||
|
|
||||||
public enum CodingKeys: String, CodingKey {
|
|
||||||
case timestamp
|
|
||||||
case duration
|
|
||||||
case peakAmplitude = "peak_amplitude"
|
|
||||||
case audioData = "audio_data"
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from packet: AudioPacket) {
|
|
||||||
self.timestamp = packet.timestamp
|
|
||||||
self.duration = packet.duration
|
|
||||||
self.peakAmplitude = packet.peakAmplitude
|
|
||||||
self.audioData = packet.rawAudioData.base64EncodedString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata-only packet for binary output (without base64 audio data)
|
|
||||||
public struct BinaryPacketHeader: Codable {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float
|
|
||||||
public let audioLength: Int // Length of raw audio data in bytes
|
|
||||||
|
|
||||||
public enum CodingKeys: String, CodingKey {
|
|
||||||
case timestamp
|
|
||||||
case duration
|
|
||||||
case peakAmplitude = "peak_amplitude"
|
|
||||||
case audioLength = "audio_length"
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from packet: AudioPacket) {
|
|
||||||
self.timestamp = packet.timestamp
|
|
||||||
self.duration = packet.duration
|
|
||||||
self.peakAmplitude = packet.peakAmplitude
|
|
||||||
self.audioLength = packet.rawAudioData.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import OSLog
|
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// Adapted with a huge debt of gratitude from https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift
|
|
||||||
|
|
||||||
/// Uses TCC SPI in order to check/request system audio recording permission.
|
|
||||||
@Observable
|
|
||||||
final class AudioRecordingPermission {
|
|
||||||
// private let logger = Logger(subsystem: kAppSubsystem, category: String(describing: AudioRecordingPermission.self))
|
|
||||||
|
|
||||||
enum Status: String {
|
|
||||||
case unknown
|
|
||||||
case denied
|
|
||||||
case authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var status: Status = .unknown
|
|
||||||
|
|
||||||
init() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.updateStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus()
|
|
||||||
#else
|
|
||||||
status = .authorized
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
func request() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
// logger.debug(#function)
|
|
||||||
print("DEBUG: TCC SPI request called")
|
|
||||||
|
|
||||||
guard let request = Self.requestSPI else {
|
|
||||||
// logger.fault("Request SPI missing")
|
|
||||||
print("DEBUG: Request SPI is nil - TCC framework loading failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: Calling TCC request function...")
|
|
||||||
request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
// self.logger.info("Request finished with result: \(granted, privacy: .public)")
|
|
||||||
print("DEBUG: TCC request completed with result: \(granted)")
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
print("DEBUG: Updating status on main queue...")
|
|
||||||
if granted {
|
|
||||||
self.status = .authorized
|
|
||||||
print("DEBUG: Status set to authorized")
|
|
||||||
} else {
|
|
||||||
self.status = .denied
|
|
||||||
print("DEBUG: Status set to denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
print("DEBUG: ENABLE_TCC_SPI not defined")
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateStatus() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
// logger.debug(#function)
|
|
||||||
|
|
||||||
guard let preflight = Self.preflightSPI else {
|
|
||||||
// logger.fault("Preflight SPI missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = preflight("kTCCServiceAudioCapture" as CFString, nil)
|
|
||||||
|
|
||||||
if result == 1 {
|
|
||||||
status = .denied
|
|
||||||
} else if result == 0 {
|
|
||||||
status = .authorized
|
|
||||||
} else {
|
|
||||||
status = .unknown
|
|
||||||
}
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
private typealias PreflightFuncType = @convention(c) (CFString, CFDictionary?) -> Int
|
|
||||||
private typealias RequestFuncType = @convention(c) (
|
|
||||||
CFString, CFDictionary?, @escaping (Bool) -> Void
|
|
||||||
) -> Void
|
|
||||||
|
|
||||||
/// `dlopen` handle to the TCC framework.
|
|
||||||
private static let apiHandle: UnsafeMutableRawPointer? = {
|
|
||||||
let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC"
|
|
||||||
print("DEBUG: Attempting to load TCC framework from: \(tccPath)")
|
|
||||||
|
|
||||||
guard let handle = dlopen(tccPath, RTLD_NOW) else {
|
|
||||||
print("DEBUG: dlopen failed for TCC framework")
|
|
||||||
assertionFailure("dlopen failed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: TCC framework loaded successfully")
|
|
||||||
return handle
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// `dlsym` function handle for `TCCAccessPreflight`.
|
|
||||||
private static let preflightSPI: PreflightFuncType? = {
|
|
||||||
guard let apiHandle else { return nil }
|
|
||||||
|
|
||||||
let fnName = "TCCAccessPreflight"
|
|
||||||
|
|
||||||
guard let funcSym = dlsym(apiHandle, fnName) else {
|
|
||||||
assertionFailure("Couldn't find symbol")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fn = unsafeBitCast(funcSym, to: PreflightFuncType.self)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// `dlsym` function handle for `TCCAccessRequest`.
|
|
||||||
private static let requestSPI: RequestFuncType? = {
|
|
||||||
guard let apiHandle else {
|
|
||||||
print("DEBUG: No API handle for TCCAccessRequest")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fnName = "TCCAccessRequest"
|
|
||||||
print("DEBUG: Looking for symbol: \(fnName)")
|
|
||||||
|
|
||||||
guard let funcSym = dlsym(apiHandle, fnName) else {
|
|
||||||
print("DEBUG: Couldn't find symbol: \(fnName)")
|
|
||||||
assertionFailure("Couldn't find symbol")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: Found TCCAccessRequest symbol successfully")
|
|
||||||
let fn = unsafeBitCast(funcSym, to: RequestFuncType.self)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
}()
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public class Logger {
|
|
||||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [
|
|
||||||
.withInternetDateTime,
|
|
||||||
.withFractionalSeconds,
|
|
||||||
]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let jsonEncoder: JSONEncoder = {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(dateFormatter.string(from: date))
|
|
||||||
}
|
|
||||||
return encoder
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Write any message with the unified envelope
|
|
||||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
|
||||||
let message = Message(type: type, data: data)
|
|
||||||
do {
|
|
||||||
let jsonData = try jsonEncoder.encode(message)
|
|
||||||
FileHandle.standardOutput.write(jsonData)
|
|
||||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
|
||||||
} catch {
|
|
||||||
// TODO: handle at some point
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for different message types
|
|
||||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.info, data: logData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.error, data: logData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.debug, data: logData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+86
@@ -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
|
||||||
Executable
+60
@@ -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)."
|
||||||
Reference in New Issue
Block a user