22 Commits

Author SHA1 Message Date
Nick Payne a1eb465142 zero-alloc audio pipeline: pointer-based IO from ring buffer to stdout
Replace Data/AudioPacket allocations with raw pointer callbacks through
the entire audio pipeline. Ring buffer hands out direct pointers (or
linearizes into a pre-allocated scratch buffer on wrap-around), converter
accepts/emits pointers via its cached buffers, and output handler writes
to stdout via write(2) with EINTR handling.

Remove AudioPacket (dead code), --flush flag (no-op with raw write(2)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:16 +00:00
Nick Payne 65c2d58c82 remove unused append(_ data: Data) overload from AudioBuffer
Only one append path exists now: append(from:count:), which is what the
IO proc callback uses. The Data-based overload had no callers in source
and added a dead code path to maintain.

Also resolves CoreAudio.AudioBuffer name collision in tests via typealias.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:38:14 +00:00
Nick Payne 85975d6cc3 optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers
- Replace Swift Array<UInt8> ring buffer with UnsafeMutableRawPointer to
  eliminate COW ref-count checks on every write/read
- Add append(from:count:) to copy directly from Core Audio buffer pointer
  into the ring buffer, removing the per-callback Data heap allocation
- Pre-allocate AVAudioPCMBuffer pair in AudioFormatConverter and reuse
  across transform() calls (lazy init, capacity-checked)
- Fix float-to-int truncation in output frame count calculation (ceil)
- Add comprehensive AudioBuffer test suite (12 tests) including proper
  wrap-around coverage for both append and read paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:32:42 +00:00
Nick Payne 1cd2e83060 Merge pull request #12 from makeusabrew/lib-improvements
library improvements
2026-03-01 11:09:09 +00:00
Nick Payne 4600e34bfb Merge pull request #11 from makeusabrew/lib-split-cli
Split source into library and CLI targets
2026-02-26 13:50:24 +00:00
Nick Payne 08f0bc8f6c library improvements 2026-02-25 20:26:39 +00:00
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
27 changed files with 820 additions and 462 deletions
+2
View File
@@ -7,3 +7,5 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.pcm
*.wav
+37 -7
View File
@@ -4,11 +4,41 @@
import PackageDescription
let package = Package(
name: "audiotee",
platforms: [
.macOS("14.2")
],
targets: [
.executableTarget(name: "audiotee")
]
name: "audiotee",
platforms: [
.macOS("14.2")
],
products: [
// 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"
)
]
)
+23 -16
View File
@@ -2,18 +2,21 @@
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
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:
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:
```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.
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.
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.
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.
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.
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.
@@ -25,7 +28,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 raw PCM audio data to your terminal:
The following will start capturing audio output from all running programs and write binary chunks of raw PCM audio data to your terminal:
```bash
git clone git@github.com:makeusabrew/audiotee.git
@@ -74,11 +77,14 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
### 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 it's a behaviour worth being aware of.
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 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
# 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
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
@@ -118,12 +124,12 @@ Note that trying to include or exclude a PID which isn't currently playing audio
## Output
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.
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
### Audio format
- **Format**: Raw PCM audio data
- **Channels**: Mono (1 channel)
- **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
@@ -146,20 +152,21 @@ All program logs are written to `stderr` and can be captured separately:
- `--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
- `--stereo`: Record in stereo
- `--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]
## 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). 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.
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.
## 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)
- [AudioCap Implementation](https://github.com/insidegui/AudioCap)
- [AudioTee.js](https://github.com/makeusabrew/audioteejs)
## License
@@ -1,3 +1,4 @@
import AudioTeeCore
import CoreAudio
import Foundation
@@ -5,6 +6,7 @@ struct AudioTee {
var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = []
var mute: Bool = false
var stereo: Bool = false
var sampleRate: Double?
var chunkDuration: Double = 0.2
@@ -40,6 +42,7 @@ struct AudioTee {
parser.addArrayOption(
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addFlag(name: "stereo", help: "Records in stereo")
parser.addOption(
name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
@@ -56,6 +59,7 @@ struct AudioTee {
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")
audioTee.stereo = parser.getFlag("stereo")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
@@ -91,11 +95,11 @@ struct AudioTee {
func run() throws {
setupSignalHandlers()
Logger.info("Starting AudioTee...")
AudioTeeLogging.logger.info("Starting AudioTee...")
// Validate chunk duration
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
Logger.error(
AudioTeeLogging.logger.error(
"Invalid chunk duration",
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
throw ExitCode.failure
@@ -107,14 +111,15 @@ struct AudioTee {
let tapConfig = TapConfiguration(
processes: processes,
muteBehavior: mute ? .muted : .unmuted,
isExclusive: isExclusive
isExclusive: isExclusive,
isMono: !stereo
)
let audioTapManager = AudioTapManager()
do {
try audioTapManager.setupAudioTap(with: tapConfig)
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
Logger.error(
AudioTeeLogging.logger.error(
"Failed to translate process IDs to audio objects",
context: [
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
@@ -122,21 +127,21 @@ struct AudioTee {
])
throw ExitCode.failure
} catch {
Logger.error(
AudioTeeLogging.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")
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
throw ExitCode.failure
}
let outputHandler = BinaryAudioOutputHandler()
let recorder = AudioRecorder(
let recorder = try AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration)
recorder.startRecording()
try recorder.startRecording()
// Run until the run loop is stopped (by signal handler)
while true {
@@ -146,17 +151,17 @@ struct AudioTee {
}
}
Logger.info("Shutting down...")
AudioTeeLogging.logger.info("Shutting down...")
recorder.stopRecording()
}
private func setupSignalHandlers() {
signal(SIGINT) { _ in
Logger.info("Received SIGINT, initiating graceful shutdown...")
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
signal(SIGTERM) { _ in
Logger.info("Received SIGTERM, initiating graceful shutdown...")
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
}
@@ -0,0 +1,34 @@
import AudioTeeCore
import Foundation
/// CLI-specific output handler that writes raw PCM audio to stdout
/// and lifecycle messages to stderr via the logger.
class BinaryAudioOutputHandler: AudioOutputHandler {
private let fd = STDOUT_FILENO
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
var written = 0
while written < count {
let result = write(fd, pointer.advanced(by: written), count - written)
if result >= 0 {
written += result
} else if errno == EINTR {
continue
} else {
break // EPIPE, EIO, etc consumer gone or real error
}
}
}
func handleMetadata(_ metadata: AudioStreamMetadata) {
AudioTeeLogging.logger.writeMessage(.metadata, data: metadata)
}
func handleStreamStart() {
AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional<String>.none)
}
func handleStreamStop() {
AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional<String>.none)
}
}
@@ -1,3 +1,4 @@
import AudioTeeCore
import AudioToolbox
import Foundation
+121
View File
@@ -0,0 +1,121 @@
import CoreAudio
import Foundation
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
///
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
/// copy-on-write reference-count checks on every mutation. This buffer
/// lives on the real-time audio IO thread and is never shared, so COW
/// semantics are pure overhead.
public class AudioBuffer {
/// Raw heap-allocated ring buffer backing store.
private let buffer: UnsafeMutableRawPointer
/// Pre-allocated buffer for linearizing chunks that straddle the ring
/// buffer boundary. Avoids a heap allocation on the wrap-around path.
private let linearizationBuffer: UnsafeMutableRawPointer
private var writeIndex: Int = 0
private var readIndex: Int = 0
private var availableBytes: Int = 0
private let maxBufferSize: Int
public let bytesPerChunk: Int
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
// Calculate max buffer size to hold ~10 seconds of audio (safety limit)
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
self.maxBufferSize = bytesPerSecond * 10
// Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
// to eliminate Swift Array's COW ref-count check on every write/read.
self.buffer = UnsafeMutableRawPointer.allocate(
byteCount: maxBufferSize,
alignment: MemoryLayout<UInt8>.alignment
)
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
byteCount: bytesPerChunk,
alignment: MemoryLayout<UInt8>.alignment
)
}
deinit {
buffer.deallocate()
linearizationBuffer.deallocate()
}
/// Appends audio data directly from a raw pointer into the ring buffer.
/// This is the fast path used by the IO proc callback: one memcpy from
/// the Core Audio buffer into our ring buffer, with no intermediate
/// Data allocation.
public func append(from source: UnsafeRawPointer, count: Int) {
guard count >= 0 else {
AudioTeeLogging.logger.error(
"Audio buffer append called with negative count",
context: ["count": String(count)])
return
}
guard availableBytes + count <= maxBufferSize else {
AudioTeeLogging.logger.error(
"Audio buffer overflow",
context: [
"requested": String(count),
"available": String(maxBufferSize - availableBytes),
])
return
}
if writeIndex + count <= maxBufferSize {
// Single contiguous write no wrap-around needed
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
writeIndex = (writeIndex + count) % maxBufferSize
} else {
// Two writes needed due to wrap-around at the end of the ring buffer
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = count - firstChunkSize
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
writeIndex = secondChunkSize
}
availableBytes += count
}
/// Calls `handler` once for each complete chunk available in the buffer.
/// The pointer passed to the handler is valid only for the duration of
/// that call. In the common (contiguous) case this points directly into
/// the ring buffer zero copies. In the wrap-around case the chunk is
/// linearized into a pre-allocated scratch buffer one memcpy, zero
/// heap allocations.
public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) {
while availableBytes >= bytesPerChunk {
if readIndex + bytesPerChunk <= maxBufferSize {
// Contiguous: point directly into the ring buffer
handler(buffer.advanced(by: readIndex), bytesPerChunk)
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// Wrap-around: linearize into the pre-allocated scratch buffer
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
linearizationBuffer.copyMemory(
from: buffer.advanced(by: readIndex), byteCount: firstChunkSize)
linearizationBuffer.advanced(by: firstChunkSize).copyMemory(
from: buffer, byteCount: secondChunkSize)
handler(linearizationBuffer, bytesPerChunk)
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
}
}
}
@@ -0,0 +1,199 @@
import AVFoundation
import CoreAudio
import Foundation
/// Audio format converter using AVFoundation's AVAudioConverter.
///
/// Pre-allocates input/output buffers on first use and reuses them across
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
/// per chunk significant when chunks are small (50ms = 20 calls/sec).
public class AudioFormatConverter {
private let avConverter: AVAudioConverter
private let sourceFormat: AVAudioFormat
private let targetFormat: AVAudioFormat
/// Pre-allocated buffers reused across transform() calls. Lazily created
/// on first transform() since we need the actual input frame count to
/// size them correctly.
private var cachedInputBuffer: AVAudioPCMBuffer?
private var cachedOutputBuffer: AVAudioPCMBuffer?
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
AudioTeeLogging.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 {
AudioTeeLogging.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),
])
}
}
/// The source format this converter reads from.
public var sourceFormatDescription: AudioStreamBasicDescription {
return sourceFormat.streamDescription.pointee
}
/// The target format this converter produces.
public var targetFormatDescription: AudioStreamBasicDescription {
return targetFormat.streamDescription.pointee
}
/// Returns pre-allocated input and output buffers sized for the given
/// input frame count. Allocates once on first call; reuses on subsequent
/// calls when capacity is sufficient. Re-allocates if a larger frame
/// count arrives (shouldn't happen with fixed chunk sizes, but handled
/// gracefully).
private func getBuffers(inputFrameCount: AVAudioFrameCount)
-> (input: AVAudioPCMBuffer, output: AVAudioPCMBuffer)?
{
// ceil() prevents float-to-int truncation from undersizing the buffer
// by one frame (e.g. 3199.9999 3199 instead of 3200).
let outputFrameCount = AVAudioFrameCount(
ceil(Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
)
// Reuse cached buffers if they have sufficient capacity
if let inputBuf = cachedInputBuffer,
let outputBuf = cachedOutputBuffer,
inputBuf.frameCapacity >= inputFrameCount,
outputBuf.frameCapacity >= outputFrameCount
{
// Reset frame lengths for reuse the underlying memory is retained,
// we just tell AVAudioPCMBuffer how many frames are valid this time.
inputBuf.frameLength = 0
outputBuf.frameLength = 0
return (inputBuf, outputBuf)
}
// Allocate new buffers (first call, or unexpected capacity increase)
guard
let inputBuf = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
else {
AudioTeeLogging.logger.error("Failed to create input buffer")
return nil
}
guard
let outputBuf = AVAudioPCMBuffer(
pcmFormat: targetFormat, frameCapacity: outputFrameCount)
else {
AudioTeeLogging.logger.error("Failed to create output buffer")
return nil
}
// Cache for reuse on subsequent calls
cachedInputBuffer = inputBuf
cachedOutputBuffer = outputBuf
AudioTeeLogging.logger.debug(
"Allocated converter buffers",
context: [
"input_frame_capacity": String(inputFrameCount),
"output_frame_capacity": String(outputFrameCount),
])
return (inputBuf, outputBuf)
}
/// Converts audio data in-place through the pre-allocated converter buffers.
/// Calls `handler` with a pointer to the converted output, valid only for
/// the duration of that call. Returns false on failure (caller should
/// pass through the original data or drop it).
@discardableResult
public func transform(
from source: UnsafeRawPointer, count: Int,
handler: (UnsafeRawPointer, Int) -> Void
) -> Bool {
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame)
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
return false
}
// Copy source data into the reusable input buffer
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
dest.copyMemory(from: source, byteCount: count)
inputBuffer.frameLength = inputFrameCount
// Perform conversion we do NOT call avConverter.reset() between
// calls because the resampler maintains internal state for continuity
// across chunks (avoiding discontinuity artifacts).
var error: NSError?
let status = avConverter.convert(to: outputBuffer, error: &error) {
requestedPackets, outStatus in
outStatus.pointee = .haveData
return inputBuffer
}
guard outputBuffer.frameLength > 0 else {
AudioTeeLogging.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 false
}
let outputCount = Int(
outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
return true
}
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.mFramesPerPacket = 1
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)
}
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
}
}
@@ -3,25 +3,26 @@ import CoreAudio
import Foundation
public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription
{
// First, wait for the device to become alive/ready
let deviceReadyTimeout = 2.0 // 2 seconds max wait
let pollInterval = 0.1 // 100ms poll interval
let maxPolls = Int(deviceReadyTimeout / pollInterval)
Logger.debug(
AudioTeeLogging.logger.debug(
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
// Poll device readiness
for poll in 1...maxPolls {
if isAudioDeviceValid(deviceID) {
Logger.debug(
AudioTeeLogging.logger.debug(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
Logger.info(
AudioTeeLogging.logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
@@ -30,7 +31,7 @@ public class AudioFormatManager {
break
}
Logger.info("------- not ready; retrying...")
AudioTeeLogging.logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
}
@@ -49,11 +50,12 @@ public class AudioFormatManager {
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr {
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
AudioTeeLogging.logger.debug(
"Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
}
Logger.info(
AudioTeeLogging.logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
@@ -69,16 +71,14 @@ public class AudioFormatManager {
}
// If all attempts failed after device readiness confirmation, this is a genuine error
Logger.error(
AudioTeeLogging.logger.error(
"Failed to get device format after device readiness check and retries",
context: [
"device_id": String(deviceID),
"device_was_ready": "true",
])
fatalError(
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
)
throw AudioTeeError.deviceFormatUnavailable(deviceID)
}
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
@@ -94,14 +94,8 @@ public class AudioFormatManager {
)
}
public static func writeMetadata(for format: AudioStreamBasicDescription) {
let metadata = createMetadata(for: format)
Logger.writeMessage(.metadata, data: metadata)
Logger.writeMessage(.streamStart, data: Optional<String>.none)
}
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
Logger.debug(
AudioTeeLogging.logger.debug(
"Using device's native format",
context: [
"channels": String(format.mChannelsPerFrame),
@@ -5,29 +5,39 @@ import Foundation
public class AudioRecorder {
private var deviceID: AudioObjectID
private var ioProcID: AudioDeviceIOProcID?
private var sourceFormat: AudioStreamBasicDescription?
private var finalFormat: AudioStreamBasicDescription?
private var finalFormat: AudioStreamBasicDescription!
private var audioBuffer: AudioBuffer?
private var outputHandler: AudioOutputHandler
private var converter: AudioFormatConverter?
private var chunkDuration: Double
init(
/// The audio format this recorder produces (after any conversion).
public var outputFormat: AudioStreamBasicDescription {
return finalFormat
}
/// Whether this recorder is performing sample rate conversion.
public var isConverting: Bool {
return converter != nil
}
public init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2
) {
) throws {
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
let sourceFormat = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
if let targetSampleRate = convertToSampleRate {
// Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
AudioTeeLogging.logger.error(
"Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
self.converter = nil
self.finalFormat = sourceFormat
return
@@ -37,10 +47,10 @@ public class AudioRecorder {
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
self.converter = converter
self.finalFormat = converter.targetFormatDescription
Logger.info(
AudioTeeLogging.logger.info(
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
} catch {
Logger.error(
AudioTeeLogging.logger.error(
"Failed to create audio converter, using original format",
context: ["error": String(describing: error)])
self.converter = nil
@@ -52,32 +62,24 @@ public class AudioRecorder {
}
}
func startRecording() {
Logger.debug("Starting audio recording")
public func startRecording() throws {
AudioTeeLogging.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
// 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()
try setupAndStartIOProc()
Logger.info("Audio device started successfully")
AudioTeeLogging.logger.info("Audio device started successfully")
}
// 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() {
Logger.debug("Creating IO proc")
private func setupAndStartIOProc() throws {
AudioTeeLogging.logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID(
deviceID,
{
@@ -91,15 +93,15 @@ public class AudioRecorder {
)
guard status == noErr else {
fatalError("Failed to create IO proc: \(status)")
throw AudioTeeError.ioProcCreationFailed(status)
}
Logger.debug("Starting audio device")
AudioTeeLogging.logger.debug("Starting audio device")
status = AudioDeviceStart(deviceID, ioProcID)
if status != noErr {
cleanupIOProc()
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
throw AudioTeeError.deviceStartFailed(status)
}
}
@@ -107,35 +109,43 @@ public class AudioRecorder {
let bufferList = inputData.pointee
let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
"Warning: Received empty audio buffer".print(to: .standardError)
guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
AudioTeeLogging.logger.error("Received empty audio buffer")
return noErr
}
// Append raw audio data to buffer
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
audioBuffer?.append(audioData)
// Copy directly from the Core Audio buffer into our ring buffer.
// This avoids creating an intermediate Data object (heap alloc + memcpy)
// on every IO callback (~10ms). The pointer is valid for the duration
// of this callback, so this is safe.
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
// Process and send complete chunks, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket)
}
processAudioBuffer()
return noErr
}
func stopRecording() {
// Send any remaining buffered audio, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket)
}
public func stopRecording() {
processAudioBuffer()
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func processAudioBuffer() {
audioBuffer?.processChunks { pointer, count in
if let converter = self.converter {
if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
self.outputHandler.handleAudioData(outPtr, count: outCount)
}) {
// Conversion failed pass through unconverted audio
self.outputHandler.handleAudioData(pointer, count: count)
}
} else {
self.outputHandler.handleAudioData(pointer, count: count)
}
}
}
private func cleanupIOProc() {
if let ioProcID = ioProcID {
AudioDeviceStop(deviceID, ioProcID)
@@ -3,16 +3,14 @@ import AudioToolbox
import CoreAudio
import Foundation
class AudioTapManager {
public class AudioTapManager {
private var tapID: AudioObjectID?
private var deviceID: AudioObjectID?
init() {
// Empty init - setup happens in setupAudioTap()
}
public init() {}
deinit {
Logger.debug("Cleaning up audio tap manager")
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
if let tapID = tapID {
AudioHardwareDestroyProcessTap(tapID)
@@ -26,8 +24,8 @@ class AudioTapManager {
}
/// Sets up the audio tap and aggregate device
func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager")
public func setupAudioTap(with config: TapConfiguration) throws {
AudioTeeLogging.logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config)
deviceID = try createAggregateDevice()
@@ -38,51 +36,48 @@ class AudioTapManager {
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
Logger.debug("Audio tap manager setup complete")
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
}
/// Returns the aggregate device ID for recording
func getDeviceID() -> AudioObjectID? {
public func getDeviceID() -> AudioObjectID? {
return deviceID
}
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
Logger.debug("Creating tap description")
// Create a tap description
AudioTeeLogging.logger.debug("Creating 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.isMono = config.isMono
description.isExclusive = config.isExclusive
description.deviceUID = nil // system default
description.stream = 0 // first stream of output device
description.deviceUID = nil // system default
description.stream = 0 // first stream of output device
Logger.debug(
AudioTeeLogging.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")
AudioTeeLogging.logger.debug("Creating tap")
var tapID = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tapID)
Logger.debug(
AudioTeeLogging.logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create audio tap", context: ["status": String(status)])
AudioTeeLogging.logger.error(
"Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status)
}
@@ -94,7 +89,7 @@ class AudioTapManager {
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
if formatStatus == noErr {
Logger.debug(
AudioTeeLogging.logger.debug(
"Tap format retrieved",
context: [
"channels": String(streamDescription.mChannelsPerFrame),
@@ -121,7 +116,8 @@ class AudioTapManager {
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
AudioTeeLogging.logger.error(
"Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status)
}
@@ -148,7 +144,7 @@ class AudioTapManager {
}
guard status == kAudioHardwareNoError else {
Logger.error(
AudioTeeLogging.logger.error(
"Failed to add tap to aggregate device", context: ["status": String(status)])
throw AudioTeeError.tapAssignmentFailed(status)
}
@@ -1,3 +1,4 @@
import CoreAudio
import Foundation
// MARK: - Core AudioTee Errors
@@ -8,6 +9,9 @@ public enum AudioTeeError: Error {
case aggregateDeviceCreationFailed(OSStatus)
case tapAssignmentFailed(OSStatus)
case pidTranslationFailed([Int32])
case deviceFormatUnavailable(AudioObjectID)
case ioProcCreationFailed(OSStatus)
case deviceStartFailed(OSStatus)
}
// MARK: - Audio Format Conversion Errors
@@ -2,10 +2,12 @@ public struct TapConfiguration {
public let processes: [Int32]
public let muteBehavior: TapMuteBehavior
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.muteBehavior = muteBehavior
self.isExclusive = isExclusive
self.isMono = isMono
}
}
@@ -1,9 +1,10 @@
import Foundation
/// Protocol for handling audio output in different formats
public protocol AudioOutputHandler {
func handleAudioPacket(_ packet: AudioPacket)
/// Called with a pointer to raw PCM audio data. The pointer is only
/// valid for the duration of this call.
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int)
func handleMetadata(_ metadata: AudioStreamMetadata)
func handleStreamStart()
func handleStreamStop()
@@ -7,9 +7,6 @@ public enum MessageType: String, Codable {
case streamStart = "stream_start"
case streamStop = "stream_stop"
// Audio data
case audio
// Logging
case info
case error
@@ -0,0 +1,44 @@
import Foundation
// MARK: - Logging protocol
/// Protocol that library consumers implement to receive log output.
/// The library never writes to stderr directly it calls through this.
public protocol AudioTeeLogger {
func debug(_ message: String, context: [String: String]?)
func info(_ message: String, context: [String: String]?)
func error(_ message: String, context: [String: String]?)
/// Called for structured lifecycle messages (metadata, stream_start, stream_stop).
/// Default implementation is a no-op pure library consumers get metadata
/// via AudioOutputHandler instead.
func writeMessage<T: Codable>(_ type: MessageType, data: T?)
}
// MARK: - Defaults
extension AudioTeeLogger {
/// Library consumers typically don't need structured message output;
/// they receive metadata via the AudioOutputHandler protocol instead.
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {}
/// Convenience overloads so callers can omit context when it's nil.
public func debug(_ message: String) { debug(message, context: nil) }
public func info(_ message: String) { info(message, context: nil) }
public func error(_ message: String) { error(message, context: nil) }
}
// MARK: - Global logging configuration
/// Global logger instance. Defaults to StderrJSONLogger (CLI behavior).
/// Library consumers can replace this before calling any AudioTeeCore API.
///
/// // Silence all logging:
/// AudioTeeLogging.logger = NullLogger()
///
/// // Custom logging:
/// AudioTeeLogging.logger = MyOSLogLogger()
///
public enum AudioTeeLogging {
nonisolated(unsafe) public static var logger: AudioTeeLogger = StderrJSONLogger()
}
@@ -1,7 +1,10 @@
import Foundation
public class Logger {
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
/// Default logger implementation that writes JSON messages to stderr.
/// This is the CLI-appropriate logger; library consumers can replace it
/// via AudioTeeLogging.logger.
public class StderrJSONLogger: AudioTeeLogger {
private let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [
.withInternetDateTime,
@@ -10,17 +13,22 @@ public class Logger {
return formatter
}()
private static let jsonEncoder: JSONEncoder = {
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .custom { date, encoder in
var container = encoder.singleValueContainer()
try container.encode(dateFormatter.string(from: date))
}
return encoder
}()
// Write any message with the unified envelope
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
public init() {
// Configured in init because stored property initializers can't
// reference other instance properties (self.dateFormatter).
jsonEncoder.dateEncodingStrategy = .custom { [dateFormatter] date, encoder in
var container = encoder.singleValueContainer()
try container.encode(dateFormatter.string(from: date))
}
}
// Write any message with the unified envelope to stderr
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {
let message = Message(type: type, data: data)
do {
let jsonData = try jsonEncoder.encode(message)
@@ -32,17 +40,17 @@ public class Logger {
}
// Convenience methods for different message types
public static func info(_ message: String, context: [String: String]? = nil) {
public 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) {
public 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) {
public func debug(_ message: String, context: [String: String]? = nil) {
let logData = LogData(message: message, context: context)
writeMessage(.debug, data: logData)
}
@@ -15,7 +15,7 @@ func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
let valid = status == kAudioHardwareNoError && isAlive == 1
Logger.debug(
AudioTeeLogging.logger.debug(
"Checked device validity",
context: [
"device_id": String(deviceID),
@@ -63,7 +63,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
processObjects.append(processObject)
Logger.debug(
AudioTeeLogging.logger.debug(
"Translated PID to process object",
context: [
"pid": String(pid),
@@ -71,7 +71,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
])
} else {
failedPIDs.append(pid)
Logger.debug(
AudioTeeLogging.logger.debug(
"Failed to translate PID to process object",
context: [
"pid": String(pid),
@@ -87,11 +87,3 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
return processObjects
}
extension String {
func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data)
}
}
}
-107
View File
@@ -1,107 +0,0 @@
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
let packet = AudioPacket(
timestamp: Date(),
duration: chunkDuration,
rawAudioData: chunkData
)
return packet
}
}
-161
View File
@@ -1,161 +0,0 @@
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
// 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,
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
-17
View File
@@ -1,17 +0,0 @@
import Foundation
public struct AudioPacket {
public let timestamp: Date
public let duration: Double
public let rawAudioData: Data
public init(
timestamp: Date,
duration: Double,
rawAudioData: Data
) {
self.timestamp = timestamp
self.duration = duration
self.rawAudioData = rawAudioData
}
}
@@ -1,23 +0,0 @@
import Foundation
/// Binary output with JSON headers (pipe-optimised)
public class BinaryAudioOutputHandler: AudioOutputHandler {
public init() {}
public func handleAudioPacket(_ packet: AudioPacket) {
// Write raw binary audio data directly to stdout
FileHandle.standardOutput.write(packet.rawAudioData)
}
public func handleMetadata(_ metadata: AudioStreamMetadata) {
Logger.writeMessage(.metadata, data: metadata)
}
public func handleStreamStart() {
Logger.writeMessage(.streamStart, data: Optional<String>.none)
}
public func handleStreamStop() {
Logger.writeMessage(.streamStop, data: Optional<String>.none)
}
}
@@ -0,0 +1,219 @@
import CoreAudio
import XCTest
@testable import AudioTeeCore
// CoreAudio defines its own AudioBuffer struct, which collides with ours.
// Explicit module qualification avoids ambiguity in tests that import both.
private typealias AudioBuffer = AudioTeeCore.AudioBuffer
final class AudioBufferTests: XCTestCase {
// MARK: - Helpers
/// Creates a minimal AudioStreamBasicDescription for testing.
/// 16kHz, 16-bit, mono = 2 bytes per frame, 32000 bytes/sec.
private func makeFormat(
sampleRate: Double = 16000,
bytesPerFrame: UInt32 = 2,
bitsPerChannel: UInt32 = 16
) -> AudioStreamBasicDescription {
return AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger,
mBytesPerPacket: bytesPerFrame,
mFramesPerPacket: 1,
mBytesPerFrame: bytesPerFrame,
mChannelsPerFrame: 1,
mBitsPerChannel: bitsPerChannel,
mReserved: 0
)
}
/// Creates a repeating byte pattern of the given length.
private func makeData(byte: UInt8, count: Int) -> Data {
return Data(repeating: byte, count: count)
}
/// Appends Data to an AudioBuffer via the raw pointer path,
/// matching how processAudio() calls append(from:count:).
private func appendData(_ data: Data, to buffer: AudioBuffer) {
data.withUnsafeBytes { bytes in
buffer.append(from: bytes.baseAddress!, count: bytes.count)
}
}
/// Collects chunks from the buffer as Data objects for test verification.
private func collectChunks(from buffer: AudioBuffer) -> [Data] {
var chunks: [Data] = []
buffer.processChunks { pointer, count in
chunks.append(Data(bytes: pointer, count: count))
}
return chunks
}
// MARK: - Basic append + processChunks
func testSingleChunkExtraction() {
// 16kHz, 2 bytes/frame, 0.1s chunk = 3200 bytes per chunk
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200 // 16000 * 0.1 * 2
let data = makeData(byte: 0xAB, count: chunkSize)
appendData(data, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[0], data)
}
func testMultipleChunksExtracted() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append 2.5 chunks worth
appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer)
let chunks = collectChunks(from: buffer)
// Should get 2 complete chunks, remainder stays in buffer
XCTAssertEqual(chunks.count, 2)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[1].count, chunkSize)
}
func testInsufficientDataReturnsNoChunks() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append less than one chunk
appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 0)
}
// MARK: - Wrap-around
func testWrapAroundWrite() {
// 8kHz, 2 bytes/frame, 0.3s chunks chunkSize = 4800, maxBuffer = 160000.
// 160000 / 4800 = 33.33 chunks do NOT divide evenly into the buffer,
// so after enough writes the writeIndex will straddle the boundary.
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800 // 8000 * 0.3 * 2
// Write 33 chunks (158400 bytes), drain them all.
// writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
let drained = collectChunks(from: buffer)
XCTAssertEqual(drained.count, 33)
// Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000.
// This MUST take the wrap-around else branch in append():
// firstChunkSize = 160000 - 158400 = 1600
// secondChunkSize = 4800 - 1600 = 3200
// Verify by using distinct byte patterns for the portion before and after the boundary.
var wrappingData = Data()
wrappingData.append(makeData(byte: 0xAA, count: 1600)) // fills to boundary
wrappingData.append(makeData(byte: 0xBB, count: 3200)) // wraps to start
XCTAssertEqual(wrappingData.count, chunkSize)
appendData(wrappingData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], wrappingData)
}
func testWrapAroundRead() {
// Same setup as above: position readIndex so that a chunk extraction
// straddles the ring buffer boundary, exercising the else branch in nextChunk().
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800
// Write and drain 33 chunks. Both indices land at 158400.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
_ = collectChunks(from: buffer)
// Write one chunk starting at 158400. The write itself wraps (tested above),
// but crucially the READ will also wrap: readIndex = 158400,
// 158400 + 4800 = 163200 > 160000 else branch in nextChunk():
// firstChunkSize = 160000 - 158400 = 1600 (read from end of buffer)
// secondChunkSize = 4800 - 1600 = 3200 (read from start of buffer)
var crossBoundaryData = Data()
crossBoundaryData.append(makeData(byte: 0xCC, count: 1600))
crossBoundaryData.append(makeData(byte: 0xDD, count: 3200))
appendData(crossBoundaryData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], crossBoundaryData)
}
// MARK: - Overflow guard
func testOverflowPreventsWrite() {
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let maxBuffer = 160000
// Fill the buffer completely
appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer)
// Try to append more should be silently rejected (overflow guard)
appendData(makeData(byte: 0x02, count: 100), to: buffer)
// Drain and verify we only got the original data
let chunks = collectChunks(from: buffer)
let totalBytes = chunks.reduce(0) { $0 + $1.count }
XCTAssertEqual(totalBytes, maxBuffer)
// Every byte should be 0x01, not 0x02
for chunk in chunks {
XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 })
}
}
// MARK: - Incremental appends accumulate correctly
func testIncrementalAppendsThenChunk() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Simulate many small IO callbacks building up to one chunk
let callbackSize = 320 // 10 callbacks to fill one chunk
for i in 0..<10 {
appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer)
}
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
// Verify the data is in the correct order
for i in 0..<10 {
let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize))
XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) })
}
}
// MARK: - Chunk size
func testBytesPerChunkIsCorrect() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
// 16kHz * 0.1s * 2 bytes/frame = 3200
XCTAssertEqual(buffer.bytesPerChunk, 3200)
}
}
BIN
View File
Binary file not shown.