commit 4a8bc657e3e8802f560411190e3eb0816298261f Author: Nick Payne Date: Wed Jun 11 15:33:40 2025 +0100 AudioTee Swift CLI repo diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..1520027 --- /dev/null +++ b/.cursorrules @@ -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 tap’s unique identifier by passing the kAudioTapPropertyUID selector and the tap’s audio object ID to AudioObjectGetPropertyData: + +``` +// Get the UID of the audio tap. +var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID) +var propertySize = UInt32(MemoryLayout.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 tap’s audio object ID and a CFArray of CFString objects containing the tap’s 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.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) + } +} +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd558aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.vscode/ +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4ce6c02 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a212da7 --- /dev/null +++ b/Package.swift @@ -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") + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a15983 --- /dev/null +++ b/README.md @@ -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///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. diff --git a/Sources/CLI/AudioTee.swift b/Sources/CLI/AudioTee.swift new file mode 100644 index 0000000..cd41ecc --- /dev/null +++ b/Sources/CLI/AudioTee.swift @@ -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() + } + } +} diff --git a/Sources/CLI/OutputFormat.swift b/Sources/CLI/OutputFormat.swift new file mode 100644 index 0000000..cd48ab6 --- /dev/null +++ b/Sources/CLI/OutputFormat.swift @@ -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)" + } + } +} diff --git a/Sources/CLI/TapConfiguration.swift b/Sources/CLI/TapConfiguration.swift new file mode 100644 index 0000000..f5a0a93 --- /dev/null +++ b/Sources/CLI/TapConfiguration.swift @@ -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 + } +} diff --git a/Sources/CLI/TapMuteBehavior.swift b/Sources/CLI/TapMuteBehavior.swift new file mode 100644 index 0000000..1f126a2 --- /dev/null +++ b/Sources/CLI/TapMuteBehavior.swift @@ -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 + } + } +} diff --git a/Sources/Core/AudioBuffer.swift b/Sources/Core/AudioBuffer.swift new file mode 100644 index 0000000..a73c366 --- /dev/null +++ b/Sources/Core/AudioBuffer.swift @@ -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 + } +} diff --git a/Sources/Core/AudioFormatConverter.swift b/Sources/Core/AudioFormatConverter.swift new file mode 100644 index 0000000..8b95b0d --- /dev/null +++ b/Sources/Core/AudioFormatConverter.swift @@ -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 diff --git a/Sources/Core/AudioFormatManager.swift b/Sources/Core/AudioFormatManager.swift new file mode 100644 index 0000000..0d6e2fc --- /dev/null +++ b/Sources/Core/AudioFormatManager.swift @@ -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.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.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), + ] + ) + } +} diff --git a/Sources/Core/AudioPacket.swift b/Sources/Core/AudioPacket.swift new file mode 100644 index 0000000..0a703cc --- /dev/null +++ b/Sources/Core/AudioPacket.swift @@ -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 + } +} diff --git a/Sources/Core/AudioRecorder.swift b/Sources/Core/AudioRecorder.swift new file mode 100644 index 0000000..551144c --- /dev/null +++ b/Sources/Core/AudioRecorder.swift @@ -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.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) -> 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 + } + } +} diff --git a/Sources/Core/AudioStreamMetadata.swift b/Sources/Core/AudioStreamMetadata.swift new file mode 100644 index 0000000..2ce5027 --- /dev/null +++ b/Sources/Core/AudioStreamMetadata.swift @@ -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 + } +} diff --git a/Sources/Core/AudioTapManager.swift b/Sources/Core/AudioTapManager.swift new file mode 100644 index 0000000..ecc3bde --- /dev/null +++ b/Sources/Core/AudioTapManager.swift @@ -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.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.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.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) + } + } +} \ No newline at end of file diff --git a/Sources/Core/AudioTeeErrors.swift b/Sources/Core/AudioTeeErrors.swift new file mode 100644 index 0000000..0accec5 --- /dev/null +++ b/Sources/Core/AudioTeeErrors.swift @@ -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 +} diff --git a/Sources/Output/AudioOutputProtocol.swift b/Sources/Output/AudioOutputProtocol.swift new file mode 100644 index 0000000..2385a29 --- /dev/null +++ b/Sources/Output/AudioOutputProtocol.swift @@ -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() +} diff --git a/Sources/Output/Handlers/AutoOutputHandler.swift b/Sources/Output/Handlers/AutoOutputHandler.swift new file mode 100644 index 0000000..1b38abd --- /dev/null +++ b/Sources/Output/Handlers/AutoOutputHandler.swift @@ -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() + } +} diff --git a/Sources/Output/Handlers/BinaryOutputHandler.swift b/Sources/Output/Handlers/BinaryOutputHandler.swift new file mode 100644 index 0000000..78b6876 --- /dev/null +++ b/Sources/Output/Handlers/BinaryOutputHandler.swift @@ -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.none) + } + + public func handleStreamStop() { + Logger.writeMessage(.streamStop, data: Optional.none) + } +} diff --git a/Sources/Output/Handlers/JSONOutputHandler.swift b/Sources/Output/Handlers/JSONOutputHandler.swift new file mode 100644 index 0000000..cb43e9d --- /dev/null +++ b/Sources/Output/Handlers/JSONOutputHandler.swift @@ -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.none) + } + + public func handleStreamStop() { + Logger.writeMessage(.streamStop, data: Optional.none) + } +} diff --git a/Sources/Output/MessageType.swift b/Sources/Output/MessageType.swift new file mode 100644 index 0000000..7e016e3 --- /dev/null +++ b/Sources/Output/MessageType.swift @@ -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: 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 + } +} diff --git a/Sources/Output/OutputPacketTypes.swift b/Sources/Output/OutputPacketTypes.swift new file mode 100644 index 0000000..4c2bc7a --- /dev/null +++ b/Sources/Output/OutputPacketTypes.swift @@ -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 + } +} diff --git a/Sources/Utils/Logger.swift b/Sources/Utils/Logger.swift new file mode 100644 index 0000000..a89ff67 --- /dev/null +++ b/Sources/Utils/Logger.swift @@ -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(_ 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) + } +} diff --git a/Sources/Utils/Utils.swift b/Sources/Utils/Utils.swift new file mode 100644 index 0000000..8a23d8d --- /dev/null +++ b/Sources/Utils/Utils.swift @@ -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.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.size) + var mutablePid = pid // Create mutable copy for the API call + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + UInt32(MemoryLayout.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) + } + } +} \ No newline at end of file diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..99b3d5a --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,5 @@ +import ArgumentParser +import AudioToolbox +import Foundation + +AudioTee.main() \ No newline at end of file