16 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
23 changed files with 162 additions and 113 deletions
+2
View File
@@ -7,3 +7,5 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.pcm
*.wav
+38 -8
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,8 +6,10 @@ struct AudioTee {
var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = []
var mute: Bool = false
var stereo: Bool = false
var sampleRate: Double?
var chunkDuration: Double = 0.2
var flush: Bool = false
init() {}
@@ -30,6 +33,7 @@ struct AudioTee {
audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped
audiotee --flush # Flush stdout after each chunk
"""
)
@@ -40,6 +44,8 @@ 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.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption(
name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
@@ -56,6 +62,8 @@ 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.flush = parser.getFlag("flush")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
@@ -107,7 +115,8 @@ struct AudioTee {
let tapConfig = TapConfiguration(
processes: processes,
muteBehavior: mute ? .muted : .unmuted,
isExclusive: isExclusive
isExclusive: isExclusive,
isMono: !stereo
)
let audioTapManager = AudioTapManager()
@@ -132,7 +141,7 @@ struct AudioTee {
throw ExitCode.failure
}
let outputHandler = BinaryAudioOutputHandler()
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration)
@@ -1,3 +1,4 @@
import AudioTeeCore
import AudioToolbox
import Foundation
@@ -96,12 +96,10 @@ public class AudioBuffer {
availableBytes -= bytesPerChunk
let packet = AudioPacket(
return AudioPacket(
timestamp: Date(),
duration: chunkDuration,
rawAudioData: chunkData
data: chunkData
)
return packet
}
}
@@ -54,7 +54,7 @@ public class AudioFormatConverter {
}
public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.rawAudioData
let inputData = packet.data
// Calculate frame counts
let inputFrameCount =
@@ -119,16 +119,10 @@ public class AudioFormatConverter {
return AudioPacket(
timestamp: packet.timestamp,
duration: packet.duration,
rawAudioData: outputData
data: 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 {
@@ -136,26 +130,17 @@ extension AudioFormatConverter {
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
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerFrame =
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
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)
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
}
}
// MARK: - Error Types
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
@@ -3,15 +3,15 @@ import Foundation
public struct AudioPacket {
public let timestamp: Date
public let duration: Double
public let rawAudioData: Data
public let data: Data
public init(
timestamp: Date,
duration: Double,
rawAudioData: Data
data: Data
) {
self.timestamp = timestamp
self.duration = duration
self.rawAudioData = rawAudioData
self.data = data
}
}
@@ -5,24 +5,23 @@ 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(
public init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2
) {
self.deviceID = deviceID
self.outputHandler = outputHandler
self.chunkDuration = chunkDuration
// Get source format and set up conversion if requested
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
self.sourceFormat = sourceFormat
// 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
@@ -52,23 +51,15 @@ public class AudioRecorder {
}
}
func startRecording() {
public func startRecording() {
Logger.debug("Starting audio recording")
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
fatalError("Audio formats not initialized")
}
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
// Log format info and send metadata for FINAL format
// Log format info and send metadata for final format
AudioFormatManager.logFormatInfo(finalFormat)
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
outputHandler.handleMetadata(metadata)
outputHandler.handleStreamStart()
// Set up and start the IO proc
setupAndStartIOProc()
Logger.info("Audio device started successfully")
@@ -116,24 +107,23 @@ public class AudioRecorder {
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
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
audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket)
}
return noErr
}
func stopRecording() {
// Send any remaining buffered audio, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket)
}
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func cleanupIOProc() {
@@ -3,14 +3,12 @@ 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")
@@ -26,7 +24,7 @@ class AudioTapManager {
}
/// 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")
tapID = try createSystemAudioTap(with: config)
@@ -42,34 +40,30 @@ class AudioTapManager {
}
/// 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
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.isMixdown = 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(
"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),
])
@@ -153,4 +147,4 @@ class AudioTapManager {
throw AudioTeeError.tapAssignmentFailed(status)
}
}
}
}
@@ -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,4 +1,3 @@
import Foundation
/// Protocol for handling audio output in different formats
@@ -1,12 +1,17 @@
import Foundation
/// Binary output with JSON headers (pipe-optimised)
public class BinaryAudioOutputHandler: AudioOutputHandler {
public init() {}
private let flushAfterWrite: Bool
public init(flushAfterWrite: Bool = false) {
self.flushAfterWrite = flushAfterWrite
}
public func handleAudioPacket(_ packet: AudioPacket) {
// 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) {
@@ -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
@@ -89,9 +89,9 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
}
extension String {
func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data)
}
func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data)
}
}
}
}
@@ -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)
}
}
BIN
View File
Binary file not shown.