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
+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()