18 Commits

Author SHA1 Message Date
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
Nick Payne 80d7555b60 Merge pull request #2 from makeusabrew/readiness-test
Wait for aggregate device ID to be valid
2025-07-04 09:22:50 +01:00
Nick Payne c696fa0197 Merge branch 'main' into readiness-test 2025-07-04 08:53:17 +01:00
Nick Payne a0902f6eec remove swift-argument-parser - it was adding well over 1Mb of bloat to release builds 2025-07-02 20:58:28 +01:00
Nick Payne b7ce5e61d8 Merge branch 'main' into readiness-test 2025-07-02 16:48:35 +01:00
Nick Payne d10f16a119 if a sample rate conversion is requested, always honour it 2025-07-02 16:48:28 +01:00
Nick Payne a7be157d06 attempt to wait for device readiness 2025-07-02 16:13:38 +01:00
Nick Payne bfce968fb6 slightly better zeroes explanation. 2025-06-21 09:52:09 +01:00
Nick Payne 95f11b17c7 add a note about no audio zero encoding 2025-06-21 09:44:39 +01:00
Nick Payne 756c1b3535 a few readme clarifications 2025-06-21 09:41:20 +01:00
19 changed files with 545 additions and 408 deletions
-15
View File
@@ -1,15 +0,0 @@
{
"originHash" : "5f2b81278809343fed36ed8c17e7d6930bfd5b85261cdf5dadb17ab7ffdfc0e3",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
"version" : "1.5.1"
}
}
],
"version" : 3
}
+1 -11
View File
@@ -8,17 +8,7 @@ let package = Package(
platforms: [ platforms: [
.macOS("14.2") .macOS("14.2")
], ],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
],
targets: [ targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite. .executableTarget(name: "audiotee")
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "audiotee",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
)
] ]
) )
+59 -140
View File
@@ -1,12 +1,21 @@
# AudioTee # AudioTee
AudioTee captures your Mac's system audio output and writes PCM encoded chunks of it 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 configurable) and preserves your output device's sample rate unless you pass a `--sample-rate` flag. Only the default output device is currently supported. AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can
capture system audio to a file like this:
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. ```bash
/path/to/audiotee > output.pcm
```
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` and `Core/AudioRecorder`. Everything's wired together in `CLI/AudioTee`. The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without. System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualise it, etc.
By default, audiotee captures audio output from **all** running processes. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, franky, improvements) of audiotee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
## Requirements ## Requirements
@@ -16,12 +25,26 @@ 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 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
cd audiotee cd audiotee
swift run swift run
``` ```
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
```bash ```bash
@@ -36,18 +59,24 @@ 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
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but it's a behaviour worth being aware of.
```bash ```bash
# Convert to 16kHz mono (useful for ASR services) # Convert to 16kHz mono (useful for ASR services)
./audiotee --sample-rate 16000 ./audiotee --sample-rate 16000
@@ -60,6 +89,8 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome. For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
Note that trying to include or exclude a PID which isn't currently playing audio will probably fail to convert to an Audio Object and will cause the process to exit.
```bash ```bash
# Tap all system audio (default) # Tap all system audio (default)
./audiotee ./audiotee
@@ -85,147 +116,33 @@ For now, only a subset of the `CATapDescription` (https://developer.apple.com/do
./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`, allowing for clean separation of audio data from program output.
### JSON format (`--format json` or auto in terminal) ### Audio format
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety. - **Format**: Raw PCM audio data
- **Channels**: Mono (1 channel)
- **Sample rate**: Matches your output device's sample rate by default (configurable)
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
- **Endianness**: Little-endian
- **Chunk duration**: 200ms by default (configurable)
### Binary format (`--format binary` or auto when piped) ### Logs and monitoring
JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes. All program logs are written to `stderr` and can be captured separately:
## Protocol ```bash
# Capture audio and logs separately
./audiotee > audio.pcm 2> audiotee.log
### Message types # 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
@@ -235,7 +152,9 @@ Info, error, and debug messages (useful for monitoring):
## 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. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). Note that some terminal emulators like
iTerm don't always prompt for these permissions (the macOS builtin terminal definitely does), so you
might need to grant them ahead of time if audiotee looks like it's running but never records anything.
## References ## References
+229
View File
@@ -0,0 +1,229 @@
import Foundation
// MARK: - Error Types
enum ArgumentParserError: Error, CustomStringConvertible {
case unknownOption(String)
case missingValue(String)
case invalidValue(String, String)
case validationFailed(String)
case helpRequested
var description: String {
switch self {
case .unknownOption(let option):
return "Unknown option: \(option)"
case .missingValue(let option):
return "Missing value for option: \(option)"
case .invalidValue(let option, let value):
return "Invalid value '\(value)' for option: \(option)"
case .validationFailed(let message):
return message
case .helpRequested:
return "" // Help is handled separately
}
}
}
// MARK: - Argument Configuration
struct ArgumentConfig {
let name: String
let shortName: String?
let help: String
let isFlag: Bool
let isArray: Bool
let defaultValue: String?
init(
name: String, shortName: String? = nil, help: String, isFlag: Bool = false,
isArray: Bool = false, defaultValue: String? = nil
) {
self.name = name
self.shortName = shortName
self.help = help
self.isFlag = isFlag
self.isArray = isArray
self.defaultValue = defaultValue
}
}
// MARK: - Simple Argument Parser
class SimpleArgumentParser {
private let programName: String
private let abstract: String
private let discussion: String
private var configs: [ArgumentConfig] = []
private var parsedValues: [String: [String]] = [:]
init(programName: String, abstract: String, discussion: String = "") {
self.programName = programName
self.abstract = abstract
self.discussion = discussion
}
func addOption(name: String, shortName: String? = nil, help: String, defaultValue: String? = nil)
{
configs.append(
ArgumentConfig(name: name, shortName: shortName, help: help, defaultValue: defaultValue))
}
func addArrayOption(name: String, shortName: String? = nil, help: String) {
configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isArray: true))
}
func addFlag(name: String, shortName: String? = nil, help: String) {
configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isFlag: true))
}
func parse(_ arguments: [String] = Array(CommandLine.arguments.dropFirst())) throws {
var i = 0
while i < arguments.count {
let arg = arguments[i]
if arg == "--help" || arg == "-h" {
throw ArgumentParserError.helpRequested
}
guard arg.hasPrefix("-") else {
throw ArgumentParserError.unknownOption(arg)
}
let optionName = findOptionName(arg)
guard let config = findConfig(optionName) else {
throw ArgumentParserError.unknownOption(arg)
}
if config.isFlag {
parsedValues[config.name] = ["true"]
i += 1
} else {
// Need a value
i += 1
guard i < arguments.count else {
throw ArgumentParserError.missingValue(arg)
}
if config.isArray {
// Collect all values until next option or end
var values: [String] = []
while i < arguments.count && !arguments[i].hasPrefix("-") {
values.append(arguments[i])
i += 1
}
if values.isEmpty {
throw ArgumentParserError.missingValue(arg)
}
parsedValues[config.name] = values
} else {
let value = arguments[i]
parsedValues[config.name] = [value]
i += 1
}
}
}
// Set default values for missing options
for config in configs {
if parsedValues[config.name] == nil, let defaultValue = config.defaultValue {
parsedValues[config.name] = [defaultValue]
}
}
}
private func findOptionName(_ arg: String) -> String {
if arg.hasPrefix("--") {
return String(arg.dropFirst(2))
} else if arg.hasPrefix("-") {
return String(arg.dropFirst(1))
}
return arg
}
private func findConfig(_ optionName: String) -> ArgumentConfig? {
return configs.first { config in
config.name == optionName || config.shortName == optionName
}
}
func getValue<T>(_ name: String, as type: T.Type) throws -> T {
guard let values = parsedValues[name], let value = values.first else {
throw ArgumentParserError.missingValue(name)
}
return try convertValue(value, to: type, optionName: name)
}
func getOptionalValue<T>(_ name: String, as type: T.Type) throws -> T? {
guard let values = parsedValues[name], let value = values.first else {
return nil
}
return try convertValue(value, to: type, optionName: name)
}
func getArrayValue<T>(_ name: String, as type: T.Type) throws -> [T] {
guard let values = parsedValues[name] else {
return []
}
return try values.map { try convertValue($0, to: type, optionName: name) }
}
func getFlag(_ name: String) -> Bool {
return parsedValues[name]?.first == "true"
}
private func convertValue<T>(_ value: String, to type: T.Type, optionName: String) throws -> T {
if type == String.self {
return value as! T
} else if type == Int32.self {
guard let intValue = Int32(value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return intValue as! T
} else if type == Double.self {
guard let doubleValue = Double(value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return doubleValue as! T
}
throw ArgumentParserError.invalidValue(optionName, value)
}
func printHelp() {
print(abstract)
if !discussion.isEmpty {
print("\n\(discussion)")
}
print("\nUSAGE:")
print(" \(programName) [OPTIONS]")
let optionConfigs = configs.filter { !$0.isFlag }
let flagConfigs = configs.filter { $0.isFlag }
if !optionConfigs.isEmpty {
print("\nOPTIONS:")
for config in optionConfigs {
let shortName = config.shortName.map { "-\($0), " } ?? ""
let defaultDesc = config.defaultValue.map { " (default: \($0))" } ?? ""
print(" \(shortName)--\(config.name) \(config.help)\(defaultDesc)")
}
}
if !flagConfigs.isEmpty {
print("\nFLAGS:")
for config in flagConfigs {
let shortName = config.shortName.map { "-\($0), " } ?? ""
print(" \(shortName)--\(config.name) \(config.help)")
}
}
print("\n -h, --help Show this help message")
}
}
+101 -62
View File
@@ -1,63 +1,90 @@
import ArgumentParser
import CoreAudio import CoreAudio
import Foundation import Foundation
struct AudioTee: ParsableCommand { struct AudioTee {
static let configuration = CommandConfiguration(
abstract: "Capture system audio and stream to stdout",
discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output.
Output formats:
• json: Base64-encoded audio in JSON messages (safe for terminals)
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
• auto: Automatically choose based on whether stdout is a terminal (default)
Process filtering:
• include-processes: Only tap specified process IDs (empty = all processes)
• exclude-processes: Tap all processes except specified ones
• mute: How to handle processes being tapped
Examples:
audiotee # Auto format, tap all processes
audiotee --format=json # Always use JSON format
audiotee --format=binary # Always use binary format
audiotee --sample-rate=16000 # Convert to 16kHz mono for ASR
audiotee --sample-rate=8000 # Convert to 8kHz for telephony
audiotee --include-processes 1234 # Only tap process 1234
audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped
"""
)
@Option(name: .shortAndLong, help: "Output format")
var format: OutputFormat = .auto
@Option(
name: .long, help: "Process IDs to include (space-separated, empty = all processes)")
var includeProcesses: [Int32] = [] var includeProcesses: [Int32] = []
@Option(
name: .long, help: "Process IDs to exclude (space-separated)")
var excludeProcesses: [Int32] = [] var excludeProcesses: [Int32] = []
@Flag(name: .long, help: "Mute processes being tapped")
var mute: Bool = false var mute: Bool = false
@Option(
name: .long,
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
var sampleRate: Double? var sampleRate: Double?
@Option(
name: .long,
help: "Audio chunk duration in seconds (default: 0.2)")
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
init() {}
static func main() {
let parser = SimpleArgumentParser(
programName: "audiotee",
abstract: "Capture system audio and stream to stdout",
discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output.
Process filtering:
• include-processes: Only tap specified process IDs (empty = all processes)
• exclude-processes: Tap all processes except specified ones
• mute: How to handle processes being tapped
Examples:
audiotee # Auto format, tap all processes
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
audiotee --sample-rate 8000 # Convert to 8kHz for telephony
audiotee --include-processes 1234 # Only tap process 1234
audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped
"""
)
// Configure arguments
parser.addArrayOption(
name: "include-processes",
help: "Process IDs to include (space-separated, empty = all processes)")
parser.addArrayOption(
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addOption(
name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
parser.addOption(
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
// Parse arguments
do {
try parser.parse()
var audioTee = AudioTee()
// Extract values
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.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
// Validate
try audioTee.validate()
// Run
try audioTee.run()
} catch ArgumentParserError.helpRequested {
parser.printHelp()
exit(0)
} catch ArgumentParserError.validationFailed(let message) {
print("Error: \(message)", to: &standardError)
exit(1)
} catch let error as ArgumentParserError {
print("Error: \(error.description)", to: &standardError)
parser.printHelp()
exit(1)
} catch {
print("Error: \(error)", to: &standardError)
exit(1)
}
}
func validate() throws { func validate() throws {
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty { if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
throw ValidationError("Cannot specify both --include-processes and --exclude-processes") throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes")
} }
} }
@@ -65,7 +92,6 @@ struct AudioTee: ParsableCommand {
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 {
@@ -106,7 +132,7 @@ struct AudioTee: ParsableCommand {
throw ExitCode.failure throw ExitCode.failure
} }
let outputHandler = createOutputHandler(for: format) let outputHandler = BinaryAudioOutputHandler()
let recorder = AudioRecorder( let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration) chunkDuration: chunkDuration)
@@ -135,17 +161,6 @@ struct AudioTee: ParsableCommand {
} }
} }
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
@@ -159,3 +174,27 @@ struct AudioTee: ParsableCommand {
} }
} }
} }
// Helper for stderr output
var standardError = FileHandle.standardError
extension FileHandle: TextOutputStream {
public func write(_ string: String) {
let data = Data(string.utf8)
self.write(data)
}
}
// Exit code handling
enum ExitCode: Error {
case failure
}
extension ExitCode {
var code: Int32 {
switch self {
case .failure:
return 1
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import ArgumentParser
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
case json = "json"
case binary = "binary"
case auto = "auto"
var description: String {
switch self {
case .json:
return "Base64-encoded JSON (terminal-safe)"
case .binary:
return "Binary with JSON headers (pipe-optimised)"
case .auto:
return "Auto-detect based on TTY (default)"
}
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
import ArgumentParser
import CoreAudio import CoreAudio
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument { public enum TapMuteBehavior: String, CaseIterable {
case unmuted = "unmuted" case unmuted = "unmuted"
case muted = "muted" case muted = "muted"
+75 -29
View File
@@ -2,17 +2,64 @@ import CoreAudio
import Foundation import Foundation
public class AudioBuffer { public class AudioBuffer {
private var buffer = Data() private var buffer: [UInt8]
private let targetChunkDuration: Double private var writeIndex: Int = 0
private let streamFormat: AudioStreamBasicDescription 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) { public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
self.streamFormat = format
self.targetChunkDuration = chunkDuration // 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) { public func append(_ data: Data) {
buffer.append(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] { public func processChunks() -> [AudioPacket] {
@@ -25,37 +72,36 @@ public class AudioBuffer {
return packets 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? { private func nextChunk() -> AudioPacket? {
let bytesPerFrame = Int(streamFormat.mBytesPerFrame) // Check if we have enough data for a complete chunk
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration) guard availableBytes >= bytesPerChunk else { return nil }
let bytesPerChunk = samplesPerChunk * bytesPerFrame
guard buffer.count >= bytesPerChunk else { return nil } var chunkData = Data(capacity: bytesPerChunk)
let chunkData = buffer.prefix(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( let packet = AudioPacket(
timestamp: Date(), timestamp: Date(),
duration: Double(samplesPerChunk) / streamFormat.mSampleRate, duration: chunkDuration,
peakAmplitude: 0.0, // No analysis in raw mode rawAudioData: chunkData
rawAudioData: Data(chunkData)
) )
buffer.removeFirst(bytesPerChunk)
return packet return packet
} }
} }
-6
View File
@@ -56,11 +56,6 @@ public class AudioFormatConverter {
public func transform(_ packet: AudioPacket) -> AudioPacket { public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.rawAudioData let inputData = packet.rawAudioData
// Short-circuit if no conversion needed
if sourceFormat.sampleRate == targetFormat.sampleRate {
return packet
}
// Calculate frame counts // Calculate frame counts
let inputFrameCount = let inputFrameCount =
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
@@ -124,7 +119,6 @@ public class AudioFormatConverter {
return AudioPacket( return AudioPacket(
timestamp: packet.timestamp, timestamp: packet.timestamp,
duration: packet.duration, duration: packet.duration,
peakAmplitude: packet.peakAmplitude,
rawAudioData: outputData rawAudioData: outputData
) )
} }
+72 -10
View File
@@ -4,19 +4,81 @@ import Foundation
public class AudioFormatManager { public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription { public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
var propertyAddress = getPropertyAddress( // First, wait for the device to become alive/ready
selector: kAudioDevicePropertyStreamFormat, let deviceReadyTimeout = 2.0 // 2 seconds max wait
scope: kAudioDevicePropertyScopeInput) let pollInterval = 0.1 // 100ms poll interval
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride) let maxPolls = Int(deviceReadyTimeout / pollInterval)
var streamFormat = AudioStreamBasicDescription()
let status = AudioObjectGetPropertyData(
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
guard status == noErr else { Logger.debug(
fatalError("Failed to get stream format: \(status)") "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(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
Logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
"timeout_seconds": String(deviceReadyTimeout),
])
break
}
Logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
} }
return streamFormat // Now attempt to get the stream format with limited retries
let maxRetries = 3 // Reduced since device should be ready
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
for attempt in 1...maxRetries {
var propertyAddress = getPropertyAddress(
selector: kAudioDevicePropertyStreamFormat,
scope: kAudioDevicePropertyScopeInput)
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
var streamFormat = AudioStreamBasicDescription()
let status = AudioObjectGetPropertyData(
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr {
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
}
Logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
"max_retries": String(maxRetries),
"status": String(status),
"device_id": String(deviceID),
])
// Don't delay on the last attempt
if attempt < maxRetries {
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
}
}
// If all attempts failed after device readiness confirmation, this is a genuine error
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."
)
} }
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata { static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
-3
View File
@@ -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 rawAudioData: Data public let rawAudioData: Data
public init( public init(
timestamp: Date, timestamp: Date,
duration: Double, duration: Double,
peakAmplitude: Float,
rawAudioData: Data rawAudioData: Data
) { ) {
self.timestamp = timestamp self.timestamp = timestamp
self.duration = duration self.duration = duration
self.peakAmplitude = peakAmplitude
self.rawAudioData = rawAudioData self.rawAudioData = rawAudioData
} }
} }
+4 -3
View File
@@ -74,7 +74,8 @@ public class AudioRecorder {
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(
@@ -126,8 +127,8 @@ public class AudioRecorder {
func stopRecording() { func stopRecording() {
// Send any remaining buffered audio, applying conversion if needed // Send any remaining buffered audio, applying conversion if needed
if let finalPacket = audioBuffer?.flushRemaining() { audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(finalPacket) ?? finalPacket let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket) outputHandler.handleAudioPacket(processedPacket)
} }
@@ -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()
}
}
@@ -5,12 +5,6 @@ public class BinaryAudioOutputHandler: AudioOutputHandler {
public init() {} public init() {}
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.rawAudioData)
} }
@@ -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
}
}
+2 -2
View File
@@ -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
} }
+1 -2
View File
@@ -1,5 +1,4 @@
import ArgumentParser
import AudioToolbox import AudioToolbox
import Foundation import Foundation
AudioTee.main() AudioTee.main()
BIN
View File
Binary file not shown.