AudioTee Swift CLI repo

This commit is contained in:
Nick Payne
2025-06-11 15:33:40 +01:00
commit 4a8bc657e3
26 changed files with 1559 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
This project is called AudioTee: it is a Swift CLI executable which allows the user to record system audio (via a tap+aggregate device it programatically creates) and streams that audio to stdout.
It is designed to be executed as a child process by a host program which can stream its stdout. The original intended use case was to send system audio to a Streaming ASR service.g. AssemblyAI, Speechmatics, etc).
Some guidance on the Core Audio tap API from Apple:
You create a tap by passing a CATapDescription to AudioHardwareCreateProcessTap. This returns an AudioObjectID for the new tap object. You can destroy a tap using AudioHardwareDestroyProcessTap:
```
// Create a tap description.
let description = CATapDescription()
// Fill out the description properties with the tap configuration from the UI.
description.name = tapConfiguration.name
description.processes = Array(tapConfiguration.processes)
description.isPrivate = tapConfiguration.isPrivate
description.muteBehavior = CATapMuteBehavior(rawValue: tapConfiguration.mute.rawValue) ?? description.muteBehavior
description.isMixdown = tapConfiguration.mixdown == .mono || tapConfiguration.mixdown == .stereo
description.isMono = tapConfiguration.mixdown == .mono
description.isExclusive = tapConfiguration.exclusive
description.deviceUID = tapConfiguration.device
description.stream = tapConfiguration.streamIndex
// Ask the HAL to create a new tap and put the resulting `AudioObjectID` in `tapID`.
var tapID = AudioObjectID(kAudioObjectUnknown)
AudioHardwareCreateProcessTap(description, &tapID)
```
You similarly create an aggregate device by passing a CFDictionary to AudioHardwareCreateAggregateDevice, and destroy it using AudioHardwareDestroyAggregateDevice.
```
let description = [kAudioAggregateDeviceNameKey: "Sample Aggregate Audio Device", kAudioAggregateDeviceUIDKey: UUID().uuidString]
var id: AudioObjectID = 0
AudioHardwareCreateAggregateDevice(description as CFDictionary, &id)
```
To use a tap as an input source, add it to an aggregate device that you configure for playback. First get the taps unique identifier by passing the kAudioTapPropertyUID selector and the taps audio object ID to AudioObjectGetPropertyData:
```
// Get the UID of the audio tap.
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID)
var propertySize = UInt32(MemoryLayout<CFString>.stride)
var tapUID: CFString = "" as CFString
_ = withUnsafeMutablePointer(to: &tapUID) { tapUID in
AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, tapUID)
}
```
Then use the kAudioAggregateDevicePropertyTapList selector to get and set the list of taps in an aggregate device. To add a tap, pass the taps audio object ID and a CFArray of CFString objects containing the taps unique identifier to AudioObjectSetPropertyData:
```
var propertyAddress = getPropertyAddress(selector: kAudioAggregateDevicePropertyTapList)
var propertySize: UInt32 = 0
AudioObjectGetPropertyDataSize(self.id, &propertyAddress, 0, nil, &propertySize)
var list: CFArray? = nil
_ = withUnsafeMutablePointer(to: &list) { list in
AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, list)
}
if var listAsArray = list as? [CFString] {
// Add the new object ID if it's not already in the list.
if !listAsArray.contains(tapUID as CFString) {
listAsArray.append(tapUID as CFString)
propertySize += UInt32(MemoryLayout<CFString>.stride)
}
// Set the list back on the aggregate device.
list = listAsArray as CFArray
_ = withUnsafeMutablePointer(to: &list) { list in
AudioObjectSetPropertyData(tapID, &propertyAddress, 0, nil, propertySize, list)
}
}
```
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
.vscode/
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "5f2b81278809343fed36ed8c17e7d6930bfd5b85261cdf5dadb17ab7ffdfc0e3",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
"version" : "1.5.1"
}
}
],
"version" : 3
}
+24
View File
@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "audiotee",
platforms: [
.macOS("14.2")
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "audiotee",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
)
]
)
+249
View File
@@ -0,0 +1,249 @@
# AudioTee
AudioTee captures your Mac's system audio output and writes chunks of it to `stdout`, either in base64-encoded JSON (good for humans and 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.
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 configurable) and preserves your output device's sample rate unless you pass a `--convert-to` 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 changing them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
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` and `Core/AudioRecorder`. Everything's wired together in `CLI/AudioTee`. The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
## Requirements
- macOS 14.2 or later
- Swift 5.9 or later (no need for XCode)
- System audio recording permissions (see below)
## Quick start
```bash
git clone git@github.com:makeusabrew/audiotee.git
cd audiotee
swift run
```
## Build
```bash
# omit '-c release' to get a debug build
swift build -c release
```
## Usage
### Basic usage
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
# Auto-detect output format (JSON in terminal, binary when piped)
./audiotee
# Always use JSON format (terminal-safe)
./audiotee --format json
# Always use binary format (pipe-optimised)
./audiotee --format binary
```
### Audio conversion
```bash
# Convert to 16kHz mono (useful for ASR services)
./audiotee --convert-to 16000
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
./audiotee --convert-to 44100
```
### Tap configuration
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
```bash
# Tap all system audio (default)
./audiotee
# Tap everything *except* a specific process (by PID)
./audiotee --processes 1234
# Tap only a specific process (by PID)
./audiotee --processes 1234 --no-exclusive
# Exclude multiple specific processes
./audiotee --processes 1234 5678 9012
# Tap multiple specific processes
./audiotee --processes 1234 5678 9012 --no-exclusive
```
```bash
# Mute processes being tapped (so they don't play through speakers)
./audiotee --mute muted
# Custom chunk duration (default 0.2 seconds, max 5.0)
./audiotee --chunk-duration 0.1
```
## Output Formats
AudioTee supports two output formats optimised for different use cases:
### JSON Format (`--format json` or auto in terminal)
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
### 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 follow this envelope structure:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "...",
"data": { ... }
}
```
#### 1. Metadata Message
Sent once at startup to describe the audio format:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "metadata",
"data": {
"sample_rate": 48000,
"channels_per_frame": 1,
"bits_per_channel": 32,
"is_float": true,
"capture_mode": "audio",
"device_name": null,
"device_uid": null,
"encoding": "pcm_f32le"
}
}
```
#### 2. Stream Start Message
Indicates audio data will follow:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "stream_start",
"data": null
}
```
#### 3. Audio Data Messages
**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 Message
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. Handle the format specified in metadata (may be converted if `--convert-to` was used)
**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. Handle the format specified in metadata (may be converted if `--convert-to` was used)
**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk.
## Command Line Options
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
- `--processes`: Process IDs to tap (space-separated, empty = all processes)
- `--mute`: Mute behavior (`unmuted`, `muted`) [default: `unmuted`]
- `--exclusive/--no-exclusive`: Use exclusive mode [default: `--exclusive`]
- `--convert-to`: Convert to sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)
- `--chunk-duration`: Audio chunk duration in seconds [default: 0.2, max: 5.0]
## Permissions
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
so you'll be prompted the first time AudioTee tries to record anything. If you want to probe and request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
## References
- [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)
## License
### The MIT License
Copyright (C) 2025 Nick Payne.
+138
View File
@@ -0,0 +1,138 @@
import ArgumentParser
import CoreAudio
import Foundation
struct AudioTee: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Capture system audio and stream to stdout",
discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output.
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)
Tap configuration:
• processes: List of process IDs to tap (empty = all processes)
• mute: How to handle processes being tapped
• exclusive: Whether to use exclusive mode
Examples:
audiotee # Auto format (JSON in terminal, binary when piped)
audiotee --format=json # Always use JSON format
audiotee --format=binary # Always use binary format
audiotee --convert-to=16000 # Convert to 16kHz mono for ASR
audiotee --convert-to=8000 # Convert to 8kHz for telephony
audiotee --processes 1234 # Only tap process 1234
audiotee --processes 1234 5678 9012 # Tap multiple processes
audiotee --mute=muted # Mute processes being tapped
audiotee --no-exclusive # Don't use exclusive mode
"""
)
@Option(name: .shortAndLong, help: "Output format")
var format: OutputFormat = .auto
@Option(
name: .long, help: "Process IDs to tap (space-separated for multiple, empty = all processes)")
var processes: [Int32] = []
@Option(name: .long, help: "Mute behavior for tapped processes")
var mute: TapMuteBehavior = .unmuted
@Flag(name: .long, inversion: .prefixedNo, help: "Use exclusive mode to capture all processes")
var exclusive: Bool = true
@Option(
name: .long,
help: "Convert audio to specified sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
var convertTo: Double?
@Option(
name: .long,
help: "Audio chunk duration in seconds (default: 0.2)")
var chunkDuration: Double = 0.2
func run() throws {
setupSignalHandlers()
Logger.info("Starting AudioTee...")
Logger.debug("Using output format: \(format)")
// Validate chunk duration
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
Logger.error(
"Invalid chunk duration",
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
throw ExitCode.failure
}
let tapConfig = TapConfiguration(
processes: processes,
muteBehavior: mute,
isExclusive: exclusive
)
let audioTapManager = AudioTapManager()
do {
try audioTapManager.setupAudioTap(with: tapConfig)
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
Logger.error(
"Failed to translate process IDs to audio objects",
context: [
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
"suggestion": "Check that the process IDs exist and are running",
])
throw ExitCode.failure
} catch {
Logger.error(
"Failed to setup audio tap", context: ["error": String(describing: error)])
throw ExitCode.failure
}
guard let deviceID = audioTapManager.getDeviceID() else {
Logger.error("Failed to get device ID from audio tap manager")
throw ExitCode.failure
}
let outputHandler = createOutputHandler(for: format)
let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: convertTo,
chunkDuration: chunkDuration)
recorder.startRecording()
// Run until the run loop is stopped (by signal handler)
while true {
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
break
}
}
Logger.info("Shutting down...")
recorder.stopRecording()
}
private func setupSignalHandlers() {
signal(SIGINT) { _ in
Logger.info("Received SIGINT, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
signal(SIGTERM) { _ in
Logger.info("Received SIGTERM, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
}
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
switch format {
case .json:
return JSONAudioOutputHandler()
case .binary:
return BinaryAudioOutputHandler()
case .auto:
return AutoAudioOutputHandler()
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import ArgumentParser
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
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)"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
public struct TapConfiguration {
public let processes: [Int32]
public let muteBehavior: TapMuteBehavior
public let isExclusive: Bool
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
self.processes = processes
self.muteBehavior = muteBehavior
self.isExclusive = isExclusive
}
}
+25
View File
@@ -0,0 +1,25 @@
import ArgumentParser
import CoreAudio
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
case unmuted = "unmuted"
case muted = "muted"
public var description: String {
switch self {
case .unmuted:
return "Don't mute processes (default)"
case .muted:
return "Mute processes being tapped"
}
}
public var coreAudioValue: CATapMuteBehavior {
switch self {
case .unmuted:
return .unmuted
case .muted:
return .muted
}
}
}
+61
View File
@@ -0,0 +1,61 @@
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
}
}
+167
View File
@@ -0,0 +1,167 @@
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
// Short-circuit if no conversion needed
if sourceFormat.sampleRate == targetFormat.sampleRate {
return packet
}
// 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
+54
View File
@@ -0,0 +1,54 @@
import AudioToolbox
import CoreAudio
import Foundation
public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
var propertyAddress = getPropertyAddress(
selector: kAudioDevicePropertyStreamFormat,
scope: kAudioDevicePropertyScopeInput)
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
var streamFormat = AudioStreamBasicDescription()
let status = AudioObjectGetPropertyData(
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
guard status == noErr else {
fatalError("Failed to get stream format: \(status)")
}
return streamFormat
}
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
return AudioStreamMetadata(
sampleRate: format.mSampleRate,
channelsPerFrame: format.mChannelsPerFrame,
bitsPerChannel: format.mBitsPerChannel,
isFloat: format.mFormatFlags & kAudioFormatFlagIsFloat != 0,
captureMode: "audio",
deviceName: nil, // TODO: Get device name if needed
deviceUID: nil, // TODO: Get device UID if needed
encoding: format.mFormatFlags & kAudioFormatFlagIsFloat != 0 ? "pcm_f32le" : "pcm_s16le"
)
}
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) {
Logger.debug(
"Using device's native format",
context: [
"channels": String(format.mChannelsPerFrame),
"sample_rate": String(format.mSampleRate),
"bits_per_channel": String(format.mBitsPerChannel),
"format_id": String(format.mFormatID),
"format_flags": String(format: "0x%08x", format.mFormatFlags),
"bytes_per_frame": String(format.mBytesPerFrame),
]
)
}
}
+20
View File
@@ -0,0 +1,20 @@
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
}
}
+145
View File
@@ -0,0 +1,145 @@
import AudioToolbox
import CoreAudio
import Foundation
public class AudioRecorder {
private var deviceID: AudioObjectID
private var ioProcID: AudioDeviceIOProcID?
private var sourceFormat: AudioStreamBasicDescription?
private var finalFormat: AudioStreamBasicDescription?
private var audioBuffer: AudioBuffer?
private var outputHandler: AudioOutputHandler
private var converter: AudioFormatConverter?
private var chunkDuration: Double
init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2
) {
self.deviceID = deviceID
self.outputHandler = outputHandler
self.chunkDuration = chunkDuration
// Get source format and set up conversion if requested
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
self.sourceFormat = sourceFormat
if let targetSampleRate = convertToSampleRate {
// Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
self.converter = nil
self.finalFormat = sourceFormat
return
}
do {
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
self.converter = converter
self.finalFormat = converter.targetFormatDescription
Logger.info(
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
} catch {
Logger.error(
"Failed to create audio converter, using original format",
context: ["error": String(describing: error)])
self.converter = nil
self.finalFormat = sourceFormat
}
} else {
self.converter = nil
self.finalFormat = sourceFormat
}
}
func startRecording() {
Logger.debug("Starting audio recording")
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
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)
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
outputHandler.handleMetadata(metadata)
outputHandler.handleStreamStart()
// Set up and start the IO proc
setupAndStartIOProc()
Logger.info("Audio device started successfully")
}
// FIXME: note to self, what about installTap? Would require audio engine and a node?
private func setupAndStartIOProc() {
Logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID(
deviceID,
{
(inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime, inClientData)
-> OSStatus in
let recorder = Unmanaged<AudioRecorder>.fromOpaque(inClientData!).takeUnretainedValue()
return recorder.processAudio(inInputData)
},
Unmanaged.passUnretained(self).toOpaque(),
&ioProcID
)
guard status == noErr else {
fatalError("Failed to create IO proc: \(status)")
}
Logger.debug("Starting audio device")
status = AudioDeviceStart(deviceID, ioProcID)
if status != noErr {
cleanupIOProc()
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
}
}
private func processAudio(_ inputData: UnsafePointer<AudioBufferList>) -> OSStatus {
let bufferList = inputData.pointee
let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
"Warning: Received empty audio buffer".print(to: .standardError)
return noErr
}
// Append raw audio data to buffer
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
audioBuffer?.append(audioData)
// Process and send complete chunks, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket)
}
return noErr
}
func stopRecording() {
// Send any remaining buffered audio, applying conversion if needed
if let finalPacket = audioBuffer?.flushRemaining() {
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
outputHandler.handleAudioPacket(processedPacket)
}
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func cleanupIOProc() {
if let ioProcID = ioProcID {
AudioDeviceStop(deviceID, ioProcID)
AudioDeviceDestroyIOProcID(deviceID, ioProcID)
self.ioProcID = nil
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import Foundation
public struct AudioStreamMetadata: Codable {
public let sampleRate: Double
public let channelsPerFrame: UInt32
public let bitsPerChannel: UInt32
public let isFloat: Bool
public let captureMode: String
public let deviceName: String?
public let deviceUID: String?
public let encoding: String
public enum CodingKeys: String, CodingKey {
case sampleRate = "sample_rate"
case channelsPerFrame = "channels_per_frame"
case bitsPerChannel = "bits_per_channel"
case isFloat = "is_float"
case captureMode = "capture_mode"
case deviceName = "device_name"
case deviceUID = "device_uid"
case encoding
}
public init(
sampleRate: Double, channelsPerFrame: UInt32, bitsPerChannel: UInt32, isFloat: Bool,
captureMode: String, deviceName: String?, deviceUID: String?, encoding: String
) {
self.sampleRate = sampleRate
self.channelsPerFrame = channelsPerFrame
self.bitsPerChannel = bitsPerChannel
self.isFloat = isFloat
self.captureMode = captureMode
self.deviceName = deviceName
self.deviceUID = deviceUID
self.encoding = encoding
}
}
+156
View File
@@ -0,0 +1,156 @@
import AVFoundation
import AudioToolbox
import CoreAudio
import Foundation
class AudioTapManager {
private var tapID: AudioObjectID?
private var deviceID: AudioObjectID?
init() {
// Empty init - setup happens in setupAudioTap()
}
deinit {
Logger.debug("Cleaning up audio tap manager")
if let tapID = tapID {
AudioHardwareDestroyProcessTap(tapID)
self.tapID = nil
}
if let deviceID = deviceID {
AudioHardwareDestroyAggregateDevice(deviceID)
self.deviceID = nil
}
}
/// Sets up the audio tap and aggregate device
func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config)
deviceID = try createAggregateDevice()
guard let tapID = tapID, let deviceID = deviceID else {
throw AudioTeeError.setupFailed
}
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
Logger.debug("Audio tap manager setup complete")
}
/// Returns the aggregate device ID for recording
func getDeviceID() -> AudioObjectID? {
return deviceID
}
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
Logger.debug("Creating tap description")
// Create a tap description
let description = CATapDescription()
// Configure the tap to capture all system audio
description.name = "audiotee-tap"
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
description.isPrivate = true
description.muteBehavior = config.muteBehavior.coreAudioValue
description.isMixdown = true
description.isMono = true
description.isExclusive = config.isExclusive
description.deviceUID = nil // system default
description.stream = 0 // first stream of output device
Logger.debug(
"Tap description configured",
context: [
"name": description.name,
"processes": String(describing: config.processes),
"private": String(description.isPrivate),
"mute": String(describing: description.muteBehavior),
"mixdown": String(description.isMixdown),
"mono": String(description.isMono),
"exclusive": String(description.isExclusive),
])
// Create the tap
Logger.debug("Creating tap")
var tapID = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tapID)
Logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status)
}
// Get the format of the audio tap
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyFormat)
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
var streamDescription = AudioStreamBasicDescription()
let formatStatus = AudioObjectGetPropertyData(
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
if formatStatus == noErr {
Logger.debug(
"Tap format retrieved",
context: [
"channels": String(streamDescription.mChannelsPerFrame),
"sample_rate": String(Int(streamDescription.mSampleRate)),
])
}
return tapID
}
private func createAggregateDevice() throws -> AudioObjectID {
let uid = UUID().uuidString
let description =
[
kAudioAggregateDeviceNameKey: "audiotee-aggregate-device",
kAudioAggregateDeviceUIDKey: uid,
kAudioAggregateDeviceSubDeviceListKey: [] as CFArray,
kAudioAggregateDeviceMasterSubDeviceKey: 0,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
] as [String: Any]
var deviceID: AudioObjectID = 0
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status)
}
return deviceID
}
private func addTapToAggregateDevice(tapID: AudioObjectID, deviceID: AudioObjectID) throws {
// Get the tap's UID
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID)
var propertySize = UInt32(MemoryLayout<CFString>.stride)
var tapUID: CFString = "" as CFString
_ = withUnsafeMutablePointer(to: &tapUID) { tapUID in
AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, tapUID)
}
// Add the tap to the aggregate device
propertyAddress = getPropertyAddress(
selector: kAudioAggregateDevicePropertyTapList)
let tapArray = [tapUID] as CFArray
propertySize = UInt32(MemoryLayout<CFArray>.stride)
let status = withUnsafePointer(to: tapArray) { ptr in
AudioObjectSetPropertyData(deviceID, &propertyAddress, 0, nil, propertySize, ptr)
}
guard status == kAudioHardwareNoError else {
Logger.error(
"Failed to add tap to aggregate device", context: ["status": String(status)])
throw AudioTeeError.tapAssignmentFailed(status)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import Foundation
// MARK: - Core AudioTee Errors
public enum AudioTeeError: Error {
case setupFailed
case tapCreationFailed(OSStatus)
case aggregateDeviceCreationFailed(OSStatus)
case tapAssignmentFailed(OSStatus)
case pidTranslationFailed([Int32])
}
// MARK: - Audio Format Conversion Errors
public enum AudioConverterError: Error {
case invalidFormat
case creationFailed
}
+10
View File
@@ -0,0 +1,10 @@
import Foundation
/// Protocol for handling audio output in different formats
public protocol AudioOutputHandler {
func handleAudioPacket(_ packet: AudioPacket)
func handleMetadata(_ metadata: AudioStreamMetadata)
func handleStreamStart()
func handleStreamStop()
}
@@ -0,0 +1,31 @@
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()
}
}
@@ -0,0 +1,29 @@
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 = AudioPacketMetadata(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)
}
}
@@ -0,0 +1,23 @@
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)
}
}
+47
View File
@@ -0,0 +1,47 @@
import Foundation
// Unified message types for all AudioTee output
public enum MessageType: String, Codable {
// Stream lifecycle
case metadata
case streamStart = "stream_start"
case streamStop = "stream_stop"
// Audio data
case audio
// Logging
case info
case error
case debug
}
// Base message envelope that wraps all outputs
public struct Message<T: Codable>: Codable {
public let timestamp: Date
public let type: MessageType
public let data: T?
public enum CodingKeys: String, CodingKey {
case timestamp
case type = "message_type"
case data
}
public init(type: MessageType, data: T? = nil) {
self.timestamp = Date()
self.type = type
self.data = data
}
}
// Simple log data for logging messages
public struct LogData: Codable {
public let message: String
public let context: [String: String]?
public init(message: String, context: [String: String]? = nil) {
self.message = message
self.context = context
}
}
+45
View File
@@ -0,0 +1,45 @@
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 AudioPacketMetadata: 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
}
}
+49
View File
@@ -0,0 +1,49 @@
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)
}
}
+97
View File
@@ -0,0 +1,97 @@
import AVFoundation
import AudioToolbox
import CoreAudio
import Foundation
// MARK: - Audio Device Utilities
/// Checks if an audio device is valid and alive
func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
var address = getPropertyAddress(selector: kAudioDevicePropertyDeviceIsAlive)
var isAlive: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &isAlive)
let valid = status == kAudioHardwareNoError && isAlive == 1
Logger.debug(
"Checked device validity",
context: [
"device_id": String(deviceID),
"status": String(status),
"is_alive": String(isAlive),
"valid": String(valid),
])
return valid
}
/// Creates an AudioObjectPropertyAddress with the given selector and optional scope/element
func getPropertyAddress(
selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
) -> AudioObjectPropertyAddress {
return AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element)
}
/// Translates an array of process IDs to AudioObjectIDs using Core Audio
/// Returns an array of AudioObjectIDs for valid processes
/// Throws an error if any PIDs cannot be translated
func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
guard !pids.isEmpty else {
return []
}
var processObjects: [AudioObjectID] = []
var failedPIDs: [Int32] = []
for pid in pids {
var address = getPropertyAddress(selector: kAudioHardwarePropertyTranslatePIDToProcessObject)
var processObject: AudioObjectID = 0
var size = UInt32(MemoryLayout<AudioObjectID>.size)
var mutablePid = pid // Create mutable copy for the API call
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
UInt32(MemoryLayout<pid_t>.size),
&mutablePid,
&size,
&processObject
)
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
processObjects.append(processObject)
Logger.debug(
"Translated PID to process object",
context: [
"pid": String(pid),
"process_object": String(processObject),
])
} else {
failedPIDs.append(pid)
Logger.debug(
"Failed to translate PID to process object",
context: [
"pid": String(pid),
"status": String(status),
])
}
}
// Throw error if any PIDs failed to translate
if !failedPIDs.isEmpty {
throw AudioTeeError.pidTranslationFailed(failedPIDs)
}
return processObjects
}
extension String {
func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
import ArgumentParser
import AudioToolbox
import Foundation
AudioTee.main()