25 Commits

Author SHA1 Message Date
Nick Payne c4cf27553a Merge branch 'main' into lib-split-cli 2026-02-25 16:14:04 +00:00
Nick Payne 087e4b1642 Merge pull request #10 from phritz/add-flush-option
Add --flush option to reduce stdout buffering latency
2026-02-25 14:25:46 +00:00
phritz 0b1ba5c8cd Add --flush option to reduce stdout buffering latency 2026-01-24 10:36:58 -06:00
Nick Payne b7455d26d1 potential split between CLI and core library 2025-08-19 08:51:14 +01:00
Nick Payne a311cc583f simplify recorder 2025-08-12 13:37:39 +01:00
Nick Payne 8b3de5918c update readme 2025-08-12 13:37:22 +01:00
Nick Payne cf7bc88528 update readme 2025-08-12 12:40:03 +01:00
Nick Payne 25f017f319 Merge pull request #6 from na-2n/feature/stereo
Add stereo recording option
2025-08-12 12:38:42 +01:00
Nick Payne 307eae147d remove output PCM 2025-08-12 12:36:24 +01:00
na-2n c9d11316ff add stereo recording option 2025-08-10 19:17:08 +02:00
Nick Payne 71b0c45a5c minor tidyup 2025-07-29 10:14:49 +01:00
Nick Payne fd82e3d9d9 expand readme details 2025-07-29 07:11:28 +01:00
Nick Payne d260c7824d swift format 2025-07-29 07:04:57 +01:00
Nick Payne b3b11c8649 readme improvements 2025-07-29 06:10:55 +01:00
Nick Payne 00985c0d33 add link to AudioTee.js 2025-07-27 19:12:13 +01:00
Nick Payne 1c94f55a9b Merge pull request #5 from makeusabrew/stdout-stream
Only ever write PCM data to stdout
2025-07-27 19:04:21 +01:00
Nick Payne 2040510e9e consuming output is already fairly well covered 2025-07-27 10:14:19 +01:00
Nick Payne 3449a9bb9c further README tweaks 2025-07-27 10:10:36 +01:00
Nick Payne ac0ae46cfa update readme 2025-07-27 10:06:01 +01:00
Nick Payne 8c3ee0f4e7 fix build error 2025-07-27 09:55:33 +01:00
Nick Payne abaa019bd2 only write PCM to stdout 2025-07-27 09:53:26 +01:00
Nick Payne 98e33b7bcb Merge pull request #3 from makeusabrew/memory-leak
Use ring buffer to avoid memory leak
2025-07-12 13:45:41 +01:00
Nick Payne ee8968e2d6 no need for buffer overflow error 2025-07-12 13:45:25 +01:00
Nick Payne 4bc36019c2 get rid of O(n) ops on hot audio packet path 2025-07-10 20:43:17 +01:00
Nick Payne 1b537eb395 use ring buffer to avoid memory leak 2025-07-10 14:08:52 +01:00
27 changed files with 312 additions and 458 deletions
+2
View File
@@ -7,3 +7,5 @@ DerivedData/
.swiftpm/configuration/registries.json .swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc .netrc
*.pcm
*.wav
+38 -8
View File
@@ -4,11 +4,41 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "audiotee", name: "audiotee",
platforms: [ platforms: [
.macOS("14.2") .macOS("14.2")
], ],
targets: [ products: [
.executableTarget(name: "audiotee") // Library that can be imported by other packages
] .library(
) name: "AudioTeeCore",
targets: ["AudioTeeCore"]
),
// CLI executable
.executable(
name: "audiotee",
targets: ["AudioTeeCLI"]
)
],
targets: [
// Core library with all business logic
.target(
name: "AudioTeeCore",
path: "Sources/AudioTeeCore"
),
// CLI executable that uses the library
.executableTarget(
name: "AudioTeeCLI",
dependencies: ["AudioTeeCore"],
path: "Sources/AudioTeeCLI"
),
// Tests for the library
.testTarget(
name: "AudioTeeCoreTests",
dependencies: ["AudioTeeCore"],
path: "Tests/AudioTeeCoreTests"
)
]
)
+63 -146
View File
@@ -1,10 +1,22 @@
# AudioTee # AudioTee
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc. **⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported. AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can capture whatever's playing through your speakers to a file like this:
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. ```bash
/path/to/audiotee > output.pcm
```
It's more likely you want to capture this output programmatically. Check out [AudioTee.js](https://github.com/makeusabrew/audioteejs) for a simple Node.js package which does this.
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - save it to disk, visualise it, transcribe it, etc.
By default, AudioTee captures audio output from **all** running processes. Tap output defaults to `mono` (configurable via the `--stereo` flag) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, frankly, improvements) of AudioTee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
## Why?
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without. Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
@@ -16,7 +28,7 @@ Recording system audio is harder than it should be on macOS, and folks often wre
## Quick start ## Quick start
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms: The following will start capturing audio output from all running programs and write binary chunks of raw PCM audio data to your terminal:
```bash ```bash
git clone git@github.com:makeusabrew/audiotee.git git clone git@github.com:makeusabrew/audiotee.git
@@ -24,7 +36,17 @@ cd audiotee
swift run swift run
``` ```
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes. More usefully, you can redirect `stdout` to a file:
```bash
swift run audiotee --sample-rate 16000 > output.pcm
```
Which you can play back using something like `ffplay`:
```bash
ffplay -f s16le -ar 16000 output.pcm
```
## Build ## Build
@@ -40,23 +62,29 @@ swift build -c release
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon. Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
```bash ```bash
# Auto-detect output format (JSON in terminal, binary when piped) # Write raw PCM audio to stdout (logs go to stderr)
./audiotee ./audiotee
# Always use JSON format (terminal-safe) # Redirect audio to a file
./audiotee --format json ./audiotee > output.pcm
# Always use binary format (pipe-optimised) # Pipe to another program
./audiotee --format binary ./audiotee | your_audio_processing_tool
# Redirect logs as well
./audiotee > captured_audio.pcm 2> audiotee.log
``` ```
### Audio conversion ### Audio conversion
Note that performing sample rate conversion will also convert the output bit depth to Note that performing _any_ sample rate conversion will also convert the output bit depth to
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of. 16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for a 50% reduction in output size. For ASR services, 16-bit is sufficient, but it's a non-obvious behaviour worth being aware of.
```bash ```bash
# Convert to 16kHz mono (useful for ASR services) # No sample rate preserves your device's default (probably 44.1 or 48kHz with 32-bit float bit depth)
./audiotee
# Any sample rate (even one matching your device default) converts to 16-bit signed integers (half the bandwidth)
./audiotee --sample-rate 16000 ./audiotee --sample-rate 16000
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000 # Other supported sample rates: 22050, 24000, 32000, 44100, 48000
@@ -94,162 +122,51 @@ Note that trying to include or exclude a PID which isn't currently playing audio
./audiotee --chunk-duration 0.1 ./audiotee --chunk-duration 0.1
``` ```
## Output formats ## 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`.
### 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**: 1 in Mono mode (default), 2 in stereo mode
- **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 # View logs in real-time while capturing audio
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
All messages (except raw binary audio chunks) follow this envelope structure:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "...",
"data": { ... }
}
``` ```
#### 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 ## Command Line options
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes) - `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none) - `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
- `--mute`: Mute processes being tapped - `--mute`: Mute processes being tapped
- `--stereo`: Record in stereo
- `--sample-rate`: Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) - `--sample-rate`: Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)
- `--chunk-duration`: Audio chunk duration in seconds [default: 0.2, max: 5.0] - `--chunk-duration`: Audio chunk duration in seconds [default: 0.2, max: 5.0]
## Permissions ## Permissions
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission, There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission, so you'll be prompted the first time AudioTee tries to record anything. Note that some terminal emulators like iTerm don't always prompt for these permissions (though the macOS builtin terminal definitely does), so you might need to grant them ahead of time if audiotee runs but never records anything.
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).
## References If you want to check and/or request permissions ahead of time, check out [AudioCap's fantastic TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
## References / useful links
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) - [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - [AudioCap Implementation](https://github.com/insidegui/AudioCap)
- [AudioTee.js](https://github.com/makeusabrew/audioteejs)
## License ## License
@@ -189,11 +189,6 @@ class SimpleArgumentParser {
throw ArgumentParserError.invalidValue(optionName, value) throw ArgumentParserError.invalidValue(optionName, value)
} }
return doubleValue as! T return doubleValue as! T
} else if type == OutputFormat.self {
guard let format = OutputFormat(rawValue: value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return format as! T
} }
throw ArgumentParserError.invalidValue(optionName, value) throw ArgumentParserError.invalidValue(optionName, value)
@@ -1,13 +1,15 @@
import AudioTeeCore
import CoreAudio import CoreAudio
import Foundation import Foundation
struct AudioTee { struct AudioTee {
var format: OutputFormat = .auto
var includeProcesses: [Int32] = [] var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = [] var excludeProcesses: [Int32] = []
var mute: Bool = false var mute: Bool = false
var stereo: Bool = false
var sampleRate: Double? var sampleRate: Double?
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
var flush: Bool = false
init() {} init() {}
@@ -18,11 +20,6 @@ struct AudioTee {
discussion: """ discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output. AudioTee captures system audio using Core Audio taps and streams it as structured output.
Output formats:
json: Base64-encoded audio in JSON messages (safe for terminals)
binary: Raw binary audio with JSON metadata headers (efficient for pipes)
auto: Automatically choose based on whether stdout is a terminal (default)
Process filtering: Process filtering:
include-processes: Only tap specified process IDs (empty = all processes) include-processes: Only tap specified process IDs (empty = all processes)
exclude-processes: Tap all processes except specified ones exclude-processes: Tap all processes except specified ones
@@ -30,25 +27,25 @@ struct AudioTee {
Examples: Examples:
audiotee # Auto format, tap all processes audiotee # Auto format, tap all processes
audiotee --format=json # Always use JSON format audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
audiotee --format=binary # Always use binary format audiotee --sample-rate 8000 # Convert to 8kHz for telephony
audiotee --sample-rate=16000 # Convert to 16kHz mono for ASR
audiotee --sample-rate=8000 # Convert to 8kHz for telephony
audiotee --include-processes 1234 # Only tap process 1234 audiotee --include-processes 1234 # Only tap process 1234
audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped audiotee --mute # Mute processes being tapped
audiotee --flush # Flush stdout after each chunk
""" """
) )
// Configure arguments // Configure arguments
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
parser.addArrayOption( parser.addArrayOption(
name: "include-processes", name: "include-processes",
help: "Process IDs to include (space-separated, empty = all processes)") help: "Process IDs to include (space-separated, empty = all processes)")
parser.addArrayOption( parser.addArrayOption(
name: "exclude-processes", help: "Process IDs to exclude (space-separated)") name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped") parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addFlag(name: "stereo", help: "Records in stereo")
parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption( parser.addOption(
name: "sample-rate", name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
@@ -62,10 +59,11 @@ struct AudioTee {
var audioTee = AudioTee() var audioTee = AudioTee()
// Extract values // Extract values
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self) audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute") audioTee.mute = parser.getFlag("mute")
audioTee.stereo = parser.getFlag("stereo")
audioTee.flush = parser.getFlag("flush")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self) audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
@@ -102,7 +100,6 @@ struct AudioTee {
setupSignalHandlers() setupSignalHandlers()
Logger.info("Starting AudioTee...") Logger.info("Starting AudioTee...")
Logger.debug("Using output format: \(format)")
// Validate chunk duration // Validate chunk duration
guard chunkDuration > 0 && chunkDuration <= 5.0 else { guard chunkDuration > 0 && chunkDuration <= 5.0 else {
@@ -118,7 +115,8 @@ struct AudioTee {
let tapConfig = TapConfiguration( let tapConfig = TapConfiguration(
processes: processes, processes: processes,
muteBehavior: mute ? .muted : .unmuted, muteBehavior: mute ? .muted : .unmuted,
isExclusive: isExclusive isExclusive: isExclusive,
isMono: !stereo
) )
let audioTapManager = AudioTapManager() let audioTapManager = AudioTapManager()
@@ -143,7 +141,7 @@ struct AudioTee {
throw ExitCode.failure throw ExitCode.failure
} }
let outputHandler = createOutputHandler(for: format) let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
let recorder = AudioRecorder( let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration) chunkDuration: chunkDuration)
@@ -172,17 +170,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) { private func convertProcessFlags() -> ([Int32], Bool) {
if !includeProcesses.isEmpty { if !includeProcesses.isEmpty {
// Include specific processes only // Include specific processes only
@@ -200,7 +187,7 @@ struct AudioTee {
// Helper for stderr output // Helper for stderr output
var standardError = FileHandle.standardError var standardError = FileHandle.standardError
extension FileHandle: @retroactive TextOutputStream { extension FileHandle: TextOutputStream {
public func write(_ string: String) { public func write(_ string: String) {
let data = Data(string.utf8) let data = Data(string.utf8)
self.write(data) self.write(data)
@@ -1,3 +1,4 @@
import AudioTeeCore
import AudioToolbox import AudioToolbox
import Foundation import Foundation
+105
View File
@@ -0,0 +1,105 @@
import CoreAudio
import Foundation
public class AudioBuffer {
private var buffer: [UInt8]
private var writeIndex: Int = 0
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)
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
// 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),
])
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
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
writeIndex = (writeIndex + dataSize) % maxBufferSize
} else {
// two writes needed due to wrap-around
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = secondChunkSize
}
}
availableBytes += data.count
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
private func nextChunk() -> AudioPacket? {
// Check if we have enough data for a complete chunk
guard availableBytes >= 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
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// two copies needed due to wrap-around
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
return AudioPacket(
timestamp: Date(),
duration: chunkDuration,
data: chunkData
)
}
}
@@ -54,7 +54,7 @@ public class AudioFormatConverter {
} }
public func transform(_ packet: AudioPacket) -> AudioPacket { public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.rawAudioData let inputData = packet.data
// Calculate frame counts // Calculate frame counts
let inputFrameCount = let inputFrameCount =
@@ -119,17 +119,10 @@ public class AudioFormatConverter {
return AudioPacket( return AudioPacket(
timestamp: packet.timestamp, timestamp: packet.timestamp,
duration: packet.duration, duration: packet.duration,
peakAmplitude: packet.peakAmplitude, data: outputData
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( public static func toSampleRate(
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription _ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
) throws -> AudioFormatConverter { ) throws -> AudioFormatConverter {
@@ -137,26 +130,17 @@ extension AudioFormatConverter {
targetFormat.mSampleRate = sampleRate targetFormat.mSampleRate = sampleRate
targetFormat.mFormatID = kAudioFormatLinearPCM targetFormat.mFormatID = kAudioFormatLinearPCM
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
targetFormat.mBytesPerPacket = 2
targetFormat.mFramesPerPacket = 1 targetFormat.mFramesPerPacket = 1
targetFormat.mBytesPerFrame = 2
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
targetFormat.mBitsPerChannel = 16 targetFormat.mBitsPerChannel = 16
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerFrame =
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat) 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 { public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
return supportedSampleRates.contains(sampleRate) return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
} }
} }
// MARK: - Error Types
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
@@ -3,18 +3,15 @@ import Foundation
public struct AudioPacket { public struct AudioPacket {
public let timestamp: Date public let timestamp: Date
public let duration: Double public let duration: Double
public let peakAmplitude: Float // useful for level monitoring public let data: Data
public let rawAudioData: Data
public init( public init(
timestamp: Date, timestamp: Date,
duration: Double, duration: Double,
peakAmplitude: Float, data: Data
rawAudioData: Data
) { ) {
self.timestamp = timestamp self.timestamp = timestamp
self.duration = duration self.duration = duration
self.peakAmplitude = peakAmplitude self.data = data
self.rawAudioData = rawAudioData
} }
} }
@@ -5,24 +5,23 @@ import Foundation
public class AudioRecorder { public class AudioRecorder {
private var deviceID: AudioObjectID private var deviceID: AudioObjectID
private var ioProcID: AudioDeviceIOProcID? private var ioProcID: AudioDeviceIOProcID?
private var sourceFormat: AudioStreamBasicDescription? private var finalFormat: AudioStreamBasicDescription!
private var finalFormat: AudioStreamBasicDescription?
private var audioBuffer: AudioBuffer? private var audioBuffer: AudioBuffer?
private var outputHandler: AudioOutputHandler private var outputHandler: AudioOutputHandler
private var converter: AudioFormatConverter? private var converter: AudioFormatConverter?
private var chunkDuration: Double
init( public init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil, deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2 chunkDuration: Double = 0.2
) { ) {
self.deviceID = deviceID self.deviceID = deviceID
self.outputHandler = outputHandler self.outputHandler = outputHandler
self.chunkDuration = chunkDuration
// Get source format and set up conversion if requested // Get source format and set up conversion if requested
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID) let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
self.sourceFormat = sourceFormat
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
if let targetSampleRate = convertToSampleRate { if let targetSampleRate = convertToSampleRate {
// Validate sample rate // Validate sample rate
@@ -52,29 +51,22 @@ public class AudioRecorder {
} }
} }
func startRecording() { public func startRecording() {
Logger.debug("Starting audio recording") Logger.debug("Starting audio recording")
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else { // Log format info and send metadata for final format
fatalError("Audio formats not initialized")
}
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
// Log format info and send metadata for FINAL format
AudioFormatManager.logFormatInfo(finalFormat) AudioFormatManager.logFormatInfo(finalFormat)
let metadata = AudioFormatManager.createMetadata(for: finalFormat) let metadata = AudioFormatManager.createMetadata(for: finalFormat)
outputHandler.handleMetadata(metadata) outputHandler.handleMetadata(metadata)
outputHandler.handleStreamStart() outputHandler.handleStreamStart()
// Set up and start the IO proc
setupAndStartIOProc() setupAndStartIOProc()
Logger.info("Audio device started successfully") Logger.info("Audio device started successfully")
} }
// FIXME: note to self, what about installTap? Would require audio engine and a node? // Note to self, what about installTap? Would require audio engine and a node?
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
private func setupAndStartIOProc() { private func setupAndStartIOProc() {
Logger.debug("Creating IO proc") Logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID( var status = AudioDeviceCreateIOProcID(
@@ -115,24 +107,23 @@ public class AudioRecorder {
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
audioBuffer?.append(audioData) audioBuffer?.append(audioData)
processAudioBuffer()
return noErr
}
public func stopRecording() {
processAudioBuffer()
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func processAudioBuffer() {
// Process and send complete chunks, applying conversion if needed // Process and send complete chunks, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket) 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() { private func cleanupIOProc() {
@@ -3,14 +3,12 @@ import AudioToolbox
import CoreAudio import CoreAudio
import Foundation import Foundation
class AudioTapManager { public class AudioTapManager {
private var tapID: AudioObjectID? private var tapID: AudioObjectID?
private var deviceID: AudioObjectID? private var deviceID: AudioObjectID?
init() { public init() {}
// Empty init - setup happens in setupAudioTap()
}
deinit { deinit {
Logger.debug("Cleaning up audio tap manager") Logger.debug("Cleaning up audio tap manager")
@@ -26,7 +24,7 @@ class AudioTapManager {
} }
/// Sets up the audio tap and aggregate device /// Sets up the audio tap and aggregate device
func setupAudioTap(with config: TapConfiguration) throws { public func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager") Logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config) tapID = try createSystemAudioTap(with: config)
@@ -42,34 +40,30 @@ class AudioTapManager {
} }
/// Returns the aggregate device ID for recording /// Returns the aggregate device ID for recording
func getDeviceID() -> AudioObjectID? { public func getDeviceID() -> AudioObjectID? {
return deviceID return deviceID
} }
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID { private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
Logger.debug("Creating tap description") Logger.debug("Creating tap description")
// Create a tap description
let description = CATapDescription() let description = CATapDescription()
// Configure the tap to capture all system audio
description.name = "audiotee-tap" description.name = "audiotee-tap"
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
description.isPrivate = true description.isPrivate = true
description.muteBehavior = config.muteBehavior.coreAudioValue description.muteBehavior = config.muteBehavior.coreAudioValue
description.isMixdown = true description.isMixdown = true
description.isMono = true description.isMono = config.isMono
description.isExclusive = config.isExclusive description.isExclusive = config.isExclusive
description.deviceUID = nil // system default description.deviceUID = nil // system default
description.stream = 0 // first stream of output device description.stream = 0 // first stream of output device
Logger.debug( Logger.debug(
"Tap description configured", "Tap description configured",
context: [ context: [
"name": description.name, "name": description.name,
"processes": String(describing: config.processes), "processes": String(describing: config.processes),
"private": String(description.isPrivate),
"mute": String(describing: description.muteBehavior), "mute": String(describing: description.muteBehavior),
"mixdown": String(description.isMixdown),
"mono": String(description.isMono), "mono": String(description.isMono),
"exclusive": String(description.isExclusive), "exclusive": String(description.isExclusive),
]) ])
@@ -153,4 +147,4 @@ class AudioTapManager {
throw AudioTeeError.tapAssignmentFailed(status) throw AudioTeeError.tapAssignmentFailed(status)
} }
} }
} }
@@ -2,10 +2,12 @@ public struct TapConfiguration {
public let processes: [Int32] public let processes: [Int32]
public let muteBehavior: TapMuteBehavior public let muteBehavior: TapMuteBehavior
public let isExclusive: Bool public let isExclusive: Bool
public let isMono: Bool
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) { public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool, isMono: Bool) {
self.processes = processes self.processes = processes
self.muteBehavior = muteBehavior self.muteBehavior = muteBehavior
self.isExclusive = isExclusive self.isExclusive = isExclusive
self.isMono = isMono
} }
} }
@@ -1,4 +1,3 @@
import Foundation import Foundation
/// Protocol for handling audio output in different formats /// Protocol for handling audio output in different formats
@@ -1,18 +1,17 @@
import Foundation import Foundation
/// Binary output with JSON headers (pipe-optimised)
public class BinaryAudioOutputHandler: AudioOutputHandler { public class BinaryAudioOutputHandler: AudioOutputHandler {
public init() {} private let flushAfterWrite: Bool
public init(flushAfterWrite: Bool = false) {
self.flushAfterWrite = flushAfterWrite
}
public func handleAudioPacket(_ packet: AudioPacket) { public func handleAudioPacket(_ packet: AudioPacket) {
// Create metadata without the audio data
let metadata = BinaryPacketHeader(from: packet)
// Write JSON metadata line
Logger.writeMessage(.audio, data: metadata)
// Write raw binary audio data directly to stdout // Write raw binary audio data directly to stdout
FileHandle.standardOutput.write(packet.rawAudioData) FileHandle.standardOutput.write(packet.data)
if flushAfterWrite {
fflush(stdout)
}
} }
public func handleMetadata(_ metadata: AudioStreamMetadata) { public func handleMetadata(_ metadata: AudioStreamMetadata) {
@@ -7,9 +7,6 @@ public enum MessageType: String, Codable {
case streamStart = "stream_start" case streamStart = "stream_start"
case streamStop = "stream_stop" case streamStop = "stream_stop"
// Audio data
case audio
// Logging // Logging
case info case info
case error case error
@@ -24,8 +24,8 @@ public class Logger {
let message = Message(type: type, data: data) let message = Message(type: type, data: data)
do { do {
let jsonData = try jsonEncoder.encode(message) let jsonData = try jsonEncoder.encode(message)
FileHandle.standardOutput.write(jsonData) FileHandle.standardError.write(jsonData)
FileHandle.standardOutput.write("\n".data(using: .utf8)!) FileHandle.standardError.write("\n".data(using: .utf8)!)
} catch { } catch {
// TODO: handle at some point // TODO: handle at some point
} }
@@ -89,9 +89,9 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
} }
extension String { extension String {
func print(to fileHandle: FileHandle) { func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) { if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data) fileHandle.write(data)
}
} }
} }
}
-16
View File
@@ -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)"
}
}
}
-61
View File
@@ -1,61 +0,0 @@
import CoreAudio
import Foundation
public class AudioBuffer {
private var buffer = Data()
private let targetChunkDuration: Double
private let streamFormat: AudioStreamBasicDescription
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
self.streamFormat = format
self.targetChunkDuration = chunkDuration
}
public func append(_ data: Data) {
buffer.append(data)
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
public func flushRemaining() -> AudioPacket? {
guard !buffer.isEmpty else { return nil }
let packet = AudioPacket(
timestamp: Date(),
duration: 0.0, // Unknown duration for final chunk
peakAmplitude: 0.0,
rawAudioData: buffer
)
buffer.removeAll()
return packet
}
private func nextChunk() -> AudioPacket? {
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
let bytesPerChunk = samplesPerChunk * bytesPerFrame
guard buffer.count >= bytesPerChunk else { return nil }
let chunkData = buffer.prefix(bytesPerChunk)
let packet = AudioPacket(
timestamp: Date(),
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
peakAmplitude: 0.0, // No analysis in raw mode
rawAudioData: Data(chunkData)
)
buffer.removeFirst(bytesPerChunk)
return packet
}
}
@@ -1,31 +0,0 @@
import Foundation
/// Auto-detecting output handler based on TTY
public class AutoAudioOutputHandler: AudioOutputHandler {
private let handler: AudioOutputHandler
public init() {
// Auto-detect based on whether stdout is a terminal
if isatty(STDOUT_FILENO) != 0 {
handler = JSONAudioOutputHandler()
} else {
handler = BinaryAudioOutputHandler()
}
}
public func handleAudioPacket(_ packet: AudioPacket) {
handler.handleAudioPacket(packet)
}
public func handleMetadata(_ metadata: AudioStreamMetadata) {
handler.handleMetadata(metadata)
}
public func handleStreamStart() {
handler.handleStreamStart()
}
public func handleStreamStop() {
handler.handleStreamStop()
}
}
@@ -1,23 +0,0 @@
import Foundation
/// Base64-encoded JSON output (terminal-safe)
public class JSONAudioOutputHandler: AudioOutputHandler {
public init() {}
public func handleAudioPacket(_ packet: AudioPacket) {
let jsonPacket = JSONAudioPacket(from: packet)
Logger.writeMessage(.audio, data: jsonPacket)
}
public func handleMetadata(_ metadata: AudioStreamMetadata) {
Logger.writeMessage(.metadata, data: metadata)
}
public func handleStreamStart() {
Logger.writeMessage(.streamStart, data: Optional<String>.none)
}
public func handleStreamStop() {
Logger.writeMessage(.streamStop, data: Optional<String>.none)
}
}
-45
View File
@@ -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
}
}
@@ -0,0 +1,30 @@
import XCTest
@testable import AudioTeeCore
final class AudioPacketTests: XCTestCase {
func testPacketCreation() {
let timestamp = Date()
let duration = 1.0
let data = Data([0x01, 0x02, 0x03, 0x04])
let packet = AudioPacket(
timestamp: timestamp,
duration: duration,
data: data
)
XCTAssertEqual(packet.timestamp, timestamp)
XCTAssertEqual(packet.duration, duration)
XCTAssertEqual(packet.data, data)
}
func testPacketDataSize() {
let packet = AudioPacket(
timestamp: Date(),
duration: 0.5,
data: Data(repeating: 0xFF, count: 1024)
)
XCTAssertEqual(packet.data.count, 1024)
}
}