diff --git a/README.md b/README.md index ef465db..f1d73f6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # AudioTee -AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc. +**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.** -By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported. +AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can +capture system audio to a file like this: -My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. +```bash +/path/to/audiotee > output.pcm +``` + +System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualise it, etc. + +By default, audiotee captures audio output from **all** running processes. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported. + +My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, franky, improvements) of audiotee which allow recording mic input as well as system audio, and I'm open to making that part of the main API. Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without. @@ -16,7 +25,7 @@ Recording system audio is harder than it should be on macOS, and folks often wre ## Quick start -The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms: +The following will start capturing audio output from all running programs and write raw PCM audio data to your terminal: ```bash git clone git@github.com:makeusabrew/audiotee.git @@ -24,7 +33,17 @@ cd audiotee swift run ``` -If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes. +More usefully, you can redirect `stdout` to a file: + +```bash +swift run audiotee --sample-rate 16000 > output.pcm +``` + +Which you can play back using something like `ffplay`: + +```bash +ffplay -f s16le -ar 16000 output.pcm +``` ## Build @@ -40,20 +59,23 @@ swift build -c release 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) +# Write raw PCM audio to stdout (logs go to stderr) ./audiotee -# Always use JSON format (terminal-safe) -./audiotee --format json +# Redirect audio to a file +./audiotee > output.pcm -# Always use binary format (pipe-optimised) -./audiotee --format binary +# Pipe to another program +./audiotee | your_audio_processing_tool + +# Redirect logs as well +./audiotee > captured_audio.pcm 2> audiotee.log ``` ### Audio conversion Note that performing sample rate conversion will also convert the output bit depth to -16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of. +16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but it's a behaviour worth being aware of. ```bash # Convert to 16kHz mono (useful for ASR services) @@ -94,147 +116,33 @@ Note that trying to include or exclude a PID which isn't currently playing audio ./audiotee --chunk-duration 0.1 ``` -## Output formats +## Output -AudioTee supports two output formats optimised for different use cases: +AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`, allowing for clean separation of audio data from program output. -### JSON format (`--format json` or auto in terminal) +### Audio format -JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety. +- **Format**: Raw PCM audio data +- **Channels**: Mono (1 channel) +- **Sample rate**: Matches your output device's sample rate by default (configurable) +- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed +- **Endianness**: Little-endian +- **Chunk duration**: 200ms by default (configurable) -### Binary format (`--format binary` or auto when piped) +### Logs and monitoring -JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes. +All program logs are written to `stderr` and can be captured separately: -## Protocol +```bash +# Capture audio and logs separately +./audiotee > audio.pcm 2> audiotee.log -### Message types - -All messages (except raw binary audio chunks) follow this envelope structure: - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "...", - "data": { ... } -} +# View logs in real-time while capturing audio +./audiotee > audio.pcm 2>&1 | grep "AudioTee" ``` -#### 1. Metadata - -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 - -Indicates audio data will follow: - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "stream_start", - "data": null -} -``` - -#### 3. Audio data - -**JSON format:** - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "audio", - "data": { - "timestamp": "2024-03-21T15:30:45.123Z", - "duration": 0.2, - "peak_amplitude": 0.45, - "audio_data": "base64_encoded_raw_audio..." - } -} -``` - -**Binary format:** - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "audio", - "data": { - "timestamp": "2024-03-21T15:30:45.123Z", - "duration": 0.2, - "peak_amplitude": 0.45, - "audio_length": 9600 - } -} -``` - -_Followed immediately by 9600 bytes of raw binary audio data_ - -#### 4. Stream stop - -Sent when recording stops: - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "stream_stop", - "data": null -} -``` - -#### 5. Log messages - -Info, error, and debug messages (useful for monitoring): - -```json -{ - "timestamp": "2024-03-21T15:30:45.123Z", - "message_type": "info", - "data": { - "message": "Starting AudioTee...", - "context": { "output_format": "auto" } - } -} -``` - -### Consuming output - -**JSON format:** - -1. Parse each line as JSON using the envelope structure -2. Use `metadata` message to understand the audio format -3. For `audio` messages, decode `audio_data` from base64 to get raw PCM data -4. Do something with each chunk of data - -**Binary format:** - -1. Parse JSON metadata lines using the envelope structure -2. Use `metadata` message to understand the audio format -3. For `audio` messages, read `audio_length` bytes of raw binary data after the JSON line -4. Do something with each chunk of data - -**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk. - ## Command Line options -- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`] - `--include-processes`: Process IDs to tap (space-separated, empty = all processes) - `--exclude-processes`: Process IDs to exclude (space-separated, empty = none) - `--mute`: Mute processes being tapped @@ -244,7 +152,9 @@ Info, error, and debug messages (useful for monitoring): ## 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 check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). +so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). Note that some terminal emulators like +iTerm don't always prompt for these permissions (the macOS builtin terminal definitely does), so you +might need to grant them ahead of time if audiotee looks like it's running but never records anything. ## References diff --git a/Sources/CLI/ArgumentParser.swift b/Sources/CLI/ArgumentParser.swift index 5917463..260e164 100644 --- a/Sources/CLI/ArgumentParser.swift +++ b/Sources/CLI/ArgumentParser.swift @@ -189,11 +189,6 @@ class SimpleArgumentParser { throw ArgumentParserError.invalidValue(optionName, value) } return doubleValue as! T - } else if type == OutputFormat.self { - guard let format = OutputFormat(rawValue: value) else { - throw ArgumentParserError.invalidValue(optionName, value) - } - return format as! T } throw ArgumentParserError.invalidValue(optionName, value) diff --git a/Sources/CLI/AudioTee.swift b/Sources/CLI/AudioTee.swift index 51da1d7..1dea18b 100644 --- a/Sources/CLI/AudioTee.swift +++ b/Sources/CLI/AudioTee.swift @@ -2,7 +2,6 @@ import CoreAudio import Foundation struct AudioTee { - var format: OutputFormat = .auto var includeProcesses: [Int32] = [] var excludeProcesses: [Int32] = [] var mute: Bool = false @@ -18,11 +17,6 @@ struct AudioTee { 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) - Process filtering: • include-processes: Only tap specified process IDs (empty = all processes) • exclude-processes: Tap all processes except specified ones @@ -30,10 +24,8 @@ struct AudioTee { Examples: audiotee # Auto format, tap all processes - audiotee --format=json # Always use JSON format - audiotee --format=binary # Always use binary format - audiotee --sample-rate=16000 # Convert to 16kHz mono for ASR - audiotee --sample-rate=8000 # Convert to 8kHz for telephony + audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR + audiotee --sample-rate 8000 # Convert to 8kHz for telephony audiotee --include-processes 1234 # Only tap process 1234 audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --exclude-processes 1234 5678 # Tap everything except these @@ -42,7 +34,6 @@ struct AudioTee { ) // Configure arguments - parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto") parser.addArrayOption( name: "include-processes", help: "Process IDs to include (space-separated, empty = all processes)") @@ -62,7 +53,6 @@ struct AudioTee { var audioTee = AudioTee() // Extract values - audioTee.format = try parser.getValue("format", as: OutputFormat.self) audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.mute = parser.getFlag("mute") @@ -102,7 +92,6 @@ struct AudioTee { setupSignalHandlers() Logger.info("Starting AudioTee...") - Logger.debug("Using output format: \(format)") // Validate chunk duration guard chunkDuration > 0 && chunkDuration <= 5.0 else { @@ -143,7 +132,7 @@ struct AudioTee { throw ExitCode.failure } - let outputHandler = createOutputHandler(for: format) + let outputHandler = BinaryAudioOutputHandler() let recorder = AudioRecorder( deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, chunkDuration: chunkDuration) @@ -172,17 +161,6 @@ struct AudioTee { } } - private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler { - switch format { - case .json: - return JSONAudioOutputHandler() - case .binary: - return BinaryAudioOutputHandler() - case .auto: - return AutoAudioOutputHandler() - } - } - private func convertProcessFlags() -> ([Int32], Bool) { if !includeProcesses.isEmpty { // Include specific processes only diff --git a/Sources/CLI/OutputFormat.swift b/Sources/CLI/OutputFormat.swift deleted file mode 100644 index a9646bc..0000000 --- a/Sources/CLI/OutputFormat.swift +++ /dev/null @@ -1,16 +0,0 @@ -enum OutputFormat: String, CaseIterable { - case json = "json" - case binary = "binary" - case auto = "auto" - - var description: String { - switch self { - case .json: - return "Base64-encoded JSON (terminal-safe)" - case .binary: - return "Binary with JSON headers (pipe-optimised)" - case .auto: - return "Auto-detect based on TTY (default)" - } - } -} diff --git a/Sources/Core/AudioBuffer.swift b/Sources/Core/AudioBuffer.swift index 4d4bfc7..80f8f4e 100644 --- a/Sources/Core/AudioBuffer.swift +++ b/Sources/Core/AudioBuffer.swift @@ -7,12 +7,12 @@ public class AudioBuffer { private var readIndex: Int = 0 private var availableBytes: Int = 0 private let maxBufferSize: Int - + private let bytesPerChunk: Int private let chunkDuration: Double public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { - + // Pre-calculate chunk parameters let bytesPerFrame = Int(format.mBytesPerFrame) let samplesPerChunk = Int(format.mSampleRate * chunkDuration) @@ -22,24 +22,26 @@ public class AudioBuffer { // Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame self.maxBufferSize = bytesPerSecond * 10 - + // Pre-allocated ring buffer self.buffer = Array(repeating: 0, count: maxBufferSize) } public func append(_ data: Data) { guard availableBytes + data.count <= maxBufferSize else { - Logger.error("Audio buffer overflow", context: [ - "requested": String(data.count), - "available": String(maxBufferSize - availableBytes) - ]) + Logger.error( + "Audio buffer overflow", + context: [ + "requested": String(data.count), + "available": String(maxBufferSize - availableBytes), + ]) return } - + data.withUnsafeBytes { bytes in let sourceBytes = bytes.bindMemory(to: UInt8.self) let dataSize = sourceBytes.count - + // Check if we can copy in one block (no wrap-around) if writeIndex + dataSize <= maxBufferSize { // only one write needed @@ -49,14 +51,14 @@ public class AudioBuffer { // two writes needed due to wrap-around let firstChunkSize = maxBufferSize - writeIndex let secondChunkSize = dataSize - firstChunkSize - + buffer.replaceSubrange(writeIndex..= bytesPerChunk else { return nil } var chunkData = Data(capacity: bytesPerChunk) - + // Check if we can copy in one block (no wrap-around) if readIndex + bytesPerChunk <= maxBufferSize { // one copy needed @@ -85,19 +87,18 @@ public class AudioBuffer { // two copies needed due to wrap-around let firstChunkSize = maxBufferSize - readIndex let secondChunkSize = bytesPerChunk - firstChunkSize - + chunkData.append(contentsOf: buffer[readIndex...none) - } - - public func handleStreamStop() { - Logger.writeMessage(.streamStop, data: Optional.none) - } -} diff --git a/Sources/Output/OutputPacketTypes.swift b/Sources/Output/OutputPacketTypes.swift deleted file mode 100644 index 205a68b..0000000 --- a/Sources/Output/OutputPacketTypes.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -/// JSON-serializable version of AudioPacket with base64-encoded audio data -public struct JSONAudioPacket: Codable { - public let timestamp: Date - public let duration: Double - public let peakAmplitude: Float - public let audioData: String // base64 encoded audio data - - public enum CodingKeys: String, CodingKey { - case timestamp - case duration - case peakAmplitude = "peak_amplitude" - case audioData = "audio_data" - } - - public init(from packet: AudioPacket) { - self.timestamp = packet.timestamp - self.duration = packet.duration - self.peakAmplitude = packet.peakAmplitude - self.audioData = packet.rawAudioData.base64EncodedString() - } -} - -/// Metadata-only packet for binary output (without base64 audio data) -public struct BinaryPacketHeader: Codable { - public let timestamp: Date - public let duration: Double - public let peakAmplitude: Float - public let audioLength: Int // Length of raw audio data in bytes - - public enum CodingKeys: String, CodingKey { - case timestamp - case duration - case peakAmplitude = "peak_amplitude" - case audioLength = "audio_length" - } - - public init(from packet: AudioPacket) { - self.timestamp = packet.timestamp - self.duration = packet.duration - self.peakAmplitude = packet.peakAmplitude - self.audioLength = packet.rawAudioData.count - } -} diff --git a/Sources/Utils/Logger.swift b/Sources/Utils/Logger.swift index a89ff67..b19139e 100644 --- a/Sources/Utils/Logger.swift +++ b/Sources/Utils/Logger.swift @@ -24,8 +24,8 @@ public class Logger { 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)!) + FileHandle.standardError.write(jsonData) + FileHandle.standardError.write("\n".data(using: .utf8)!) } catch { // TODO: handle at some point } diff --git a/output.pcm b/output.pcm new file mode 100644 index 0000000..b2539f0 Binary files /dev/null and b/output.pcm differ