4 Commits

Author SHA1 Message Date
Nick Payne ef1d1f247d update readme 2025-07-04 11:12:08 +01:00
Nick Payne c68ca87234 use exit codes to indicate permission 2025-07-04 11:04:02 +01:00
Nick Payne 506e15684b WIP attempt to request audio capture permissions 2025-07-04 10:53:42 +01:00
Nick Payne 4bbdee96ac import AudioCap's AudioRecordingPermission class 2025-07-04 09:38:47 +01:00
32 changed files with 836 additions and 503 deletions
-2
View File
@@ -7,5 +7,3 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.pcm
*.wav
+12 -38
View File
@@ -4,41 +4,15 @@
import PackageDescription
let package = Package(
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"
)
]
)
name: "audiotee",
platforms: [
.macOS("14.2")
],
targets: [
.executableTarget(
name: "audiotee",
swiftSettings: [
.define("ENABLE_TCC_SPI")
])
]
)
+168 -63
View File
@@ -1,22 +1,10 @@
# AudioTee
**⚠️ 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, 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.
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:
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
```bash
/path/to/audiotee > output.pcm
```
It's more likely you want to capture this output programmatically. Check out [AudioTee.js](https://github.com/makeusabrew/audioteejs) for a simple Node.js package which does this.
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - save it to disk, visualise it, transcribe it, etc.
By default, AudioTee captures audio output from **all** running processes. Tap output defaults to `mono` (configurable via the `--stereo` flag) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, frankly, improvements) of AudioTee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
## Why?
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.
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.
@@ -28,7 +16,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 binary chunks of raw PCM audio data to your terminal:
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms:
```bash
git clone git@github.com:makeusabrew/audiotee.git
@@ -36,17 +24,7 @@ cd audiotee
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
```
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes.
## Build
@@ -62,29 +40,23 @@ 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.
```bash
# Write raw PCM audio to stdout (logs go to stderr)
# Auto-detect output format (JSON in terminal, binary when piped)
./audiotee
# Redirect audio to a file
./audiotee > output.pcm
# Always use JSON format (terminal-safe)
./audiotee --format json
# Pipe to another program
./audiotee | your_audio_processing_tool
# Redirect logs as well
./audiotee > captured_audio.pcm 2> audiotee.log
# Always use binary format (pipe-optimised)
./audiotee --format binary
```
### Audio conversion
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.
Note that performing sample rate conversion will also convert the output bit depth to
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
```bash
# 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)
# Convert to 16kHz mono (useful for ASR services)
./audiotee --sample-rate 16000
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
@@ -122,51 +94,184 @@ Note that trying to include or exclude a PID which isn't currently playing audio
./audiotee --chunk-duration 0.1
```
## Output
## Output formats
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
AudioTee supports two output formats optimised for different use cases:
### Audio format
### JSON format (`--format json` or auto in terminal)
- **Format**: Raw PCM audio data
- **Channels**: 1 in Mono mode (default), 2 in stereo mode
- **Sample rate**: Matches your output device's sample rate by default (configurable)
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
- **Endianness**: Little-endian
- **Chunk duration**: 200ms by default (configurable)
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
### Logs and monitoring
### Binary format (`--format binary` or auto when piped)
All program logs are written to `stderr` and can be captured separately:
JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes.
```bash
# Capture audio and logs separately
./audiotee > audio.pcm 2> audiotee.log
## Protocol
# View logs in real-time while capturing audio
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
### Message types
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
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
- `--mute`: Mute processes being tapped
- `--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. 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.
AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
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).
### Lazy permissions (default approach)
## References / useful links
Simply run `./audiotee` and you'll be prompted for permissions the first time AudioTee tries to record audio from the tap. Note that some terminal emulators (at least `iTerm`) will **not** prompt at all, nor will the process fail: instead, AudioTee will happily run but will record a stream of empty data. The built in macOS terminal **does** prompt for permissions and blocks until granted.
### Explicit permissions management
Use the `--permissions` flag to check or request permissions ahead of time:
```bash
# Check current permission status
./audiotee --permissions
# Request permissions with user prompt
./audiotee --permissions --request
```
Note that the same caveat as above exists here regarding terminal emulators. If you know why, or how to fix it, please help out.
**Exit codes** indicate permission status, making this approach ideal for scripting:
- `0`: Permissions granted
- `1`: Permission status unknown
- `2`: Permissions denied
## References
- [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)
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
## License
@@ -1,32 +0,0 @@
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 flushAfterWrite: Bool
init(flushAfterWrite: Bool = false) {
self.flushAfterWrite = flushAfterWrite
}
func handleAudioPacket(_ packet: AudioPacket) {
// Write raw binary audio data directly to stdout
FileHandle.standardOutput.write(packet.data)
if flushAfterWrite {
fflush(stdout)
}
}
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)
}
}
-105
View File
@@ -1,105 +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 {
AudioTeeLogging.logger.error(
"Audio buffer overflow",
context: [
"requested": String(data.count),
"available": String(maxBufferSize - availableBytes),
])
return
}
data.withUnsafeBytes { bytes in
let sourceBytes = bytes.bindMemory(to: UInt8.self)
let dataSize = sourceBytes.count
// Check if we can copy in one block (no wrap-around)
if writeIndex + dataSize <= maxBufferSize {
// only one write needed
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
writeIndex = (writeIndex + dataSize) % maxBufferSize
} else {
// two writes needed due to wrap-around
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = secondChunkSize
}
}
availableBytes += data.count
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
private func nextChunk() -> AudioPacket? {
// Check if we have enough data for a complete chunk
guard availableBytes >= bytesPerChunk else { return nil }
var chunkData = Data(capacity: bytesPerChunk)
// Check if we can copy in one block (no wrap-around)
if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// two copies needed due to wrap-around
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
return AudioPacket(
timestamp: Date(),
duration: chunkDuration,
data: chunkData
)
}
}
@@ -1,44 +0,0 @@
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()
}
-57
View File
@@ -1,57 +0,0 @@
import Foundation
/// 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,
.withFractionalSeconds,
]
return formatter
}()
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
return encoder
}()
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)
FileHandle.standardError.write(jsonData)
FileHandle.standardError.write("\n".data(using: .utf8)!)
} catch {
// TODO: handle at some point
}
}
// Convenience methods for different message types
public func info(_ message: String, context: [String: String]? = nil) {
let logData = LogData(message: message, context: context)
writeMessage(.info, data: logData)
}
public func error(_ message: String, context: [String: String]? = nil) {
let logData = LogData(message: message, context: context)
writeMessage(.error, data: logData)
}
public func debug(_ message: String, context: [String: String]? = nil) {
let logData = LogData(message: message, context: context)
writeMessage(.debug, data: logData)
}
}
@@ -189,6 +189,11 @@ class SimpleArgumentParser {
throw ArgumentParserError.invalidValue(optionName, value)
}
return doubleValue as! T
} else if type == OutputFormat.self {
guard let format = OutputFormat(rawValue: value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return format as! T
}
throw ArgumentParserError.invalidValue(optionName, value)
@@ -1,15 +1,15 @@
import AudioTeeCore
import CoreAudio
import Foundation
struct AudioTee {
var format: OutputFormat = .auto
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
var permissionsMode: Bool = false
var requestPermissions: Bool = false
init() {}
@@ -20,32 +20,45 @@ struct AudioTee {
discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output.
Permission modes:
--permissions: Check current audio recording permissions
--permissions --request: Request audio recording permissions
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 --permissions # Check audio recording permissions
audiotee --permissions --request # Request audio recording permissions
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 --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
audiotee --flush # Flush stdout after each chunk
"""
)
// Configure arguments
parser.addFlag(name: "permissions", help: "Check audio recording permissions")
parser.addFlag(name: "request", help: "Request permissions (use with --permissions)")
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
parser.addArrayOption(
name: "include-processes",
help: "Process IDs to include (space-separated, empty = all processes)")
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)")
@@ -59,11 +72,12 @@ struct AudioTee {
var audioTee = AudioTee()
// Extract values
audioTee.permissionsMode = parser.getFlag("permissions")
audioTee.requestPermissions = parser.getFlag("request")
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute")
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)
@@ -94,16 +108,29 @@ struct AudioTee {
throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes")
}
if requestPermissions && !permissionsMode {
throw ArgumentParserError.validationFailed(
"--request can only be used with --permissions")
}
}
func run() throws {
// Handle permissions mode
if permissionsMode {
let permissionsHandler = PermissionsHandler(shouldRequest: requestPermissions)
permissionsHandler.handle() // This will exit with appropriate code
}
// Continue with normal audio tapping functionality
setupSignalHandlers()
AudioTeeLogging.logger.info("Starting AudioTee...")
Logger.info("Starting AudioTee...")
Logger.debug("Using output format: \(format)")
// Validate chunk duration
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
AudioTeeLogging.logger.error(
Logger.error(
"Invalid chunk duration",
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
throw ExitCode.failure
@@ -115,15 +142,14 @@ struct AudioTee {
let tapConfig = TapConfiguration(
processes: processes,
muteBehavior: mute ? .muted : .unmuted,
isExclusive: isExclusive,
isMono: !stereo
isExclusive: isExclusive
)
let audioTapManager = AudioTapManager()
do {
try audioTapManager.setupAudioTap(with: tapConfig)
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
AudioTeeLogging.logger.error(
Logger.error(
"Failed to translate process IDs to audio objects",
context: [
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
@@ -131,21 +157,21 @@ struct AudioTee {
])
throw ExitCode.failure
} catch {
AudioTeeLogging.logger.error(
Logger.error(
"Failed to setup audio tap", context: ["error": String(describing: error)])
throw ExitCode.failure
}
guard let deviceID = audioTapManager.getDeviceID() else {
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
Logger.error("Failed to get device ID from audio tap manager")
throw ExitCode.failure
}
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
let recorder = try AudioRecorder(
let outputHandler = createOutputHandler(for: format)
let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration)
try recorder.startRecording()
recorder.startRecording()
// Run until the run loop is stopped (by signal handler)
while true {
@@ -155,21 +181,32 @@ struct AudioTee {
}
}
AudioTeeLogging.logger.info("Shutting down...")
Logger.info("Shutting down...")
recorder.stopRecording()
}
private func setupSignalHandlers() {
signal(SIGINT) { _ in
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
Logger.info("Received SIGINT, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
signal(SIGTERM) { _ in
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
Logger.info("Received SIGTERM, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
}
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
switch format {
case .json:
return JSONAudioOutputHandler()
case .binary:
return BinaryAudioOutputHandler()
case .auto:
return AutoAudioOutputHandler()
}
}
private func convertProcessFlags() -> ([Int32], Bool) {
if !includeProcesses.isEmpty {
// Include specific processes only
@@ -187,7 +224,7 @@ struct AudioTee {
// Helper for stderr output
var standardError = FileHandle.standardError
extension FileHandle: TextOutputStream {
extension FileHandle: @retroactive TextOutputStream {
public func write(_ string: String) {
let data = Data(string.utf8)
self.write(data)
+16
View File
@@ -0,0 +1,16 @@
enum OutputFormat: String, CaseIterable {
case json = "json"
case binary = "binary"
case auto = "auto"
var description: String {
switch self {
case .json:
return "Base64-encoded JSON (terminal-safe)"
case .binary:
return "Binary with JSON headers (pipe-optimised)"
case .auto:
return "Auto-detect based on TTY (default)"
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import CoreFoundation
import Foundation
/// Handles audio recording permissions for the CLI, including checking status and requesting permissions.
/// Uses exit codes to communicate permission status:
/// - 0: granted (authorized)
/// - 1: unknown
/// - 2: denied
struct PermissionsHandler {
private let shouldRequest: Bool
init(shouldRequest: Bool) {
self.shouldRequest = shouldRequest
}
/// Handles the permissions workflow and exits with appropriate exit code
func handle() -> Never {
let permissionHandler = AudioRecordingPermission()
if shouldRequest {
print("Requesting audio recording permissions...")
permissionHandler.request()
// Wait for the permission request to complete
while permissionHandler.status == .unknown {
// Run the main run loop to allow DispatchQueue.main.async to execute
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, true)
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
break
}
}
}
// Get final status and exit with appropriate code
let status = permissionHandler.status
print("Audio recording permission status: \(status.rawValue)")
switch status {
case .authorized:
exit(0) // granted
case .unknown:
exit(1) // unknown
case .denied:
exit(2) // denied
}
}
}
@@ -2,12 +2,10 @@ 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, isMono: Bool) {
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
self.processes = processes
self.muteBehavior = muteBehavior
self.isExclusive = isExclusive
self.isMono = isMono
}
}
+61
View File
@@ -0,0 +1,61 @@
import CoreAudio
import Foundation
public class AudioBuffer {
private var buffer = Data()
private let targetChunkDuration: Double
private let streamFormat: AudioStreamBasicDescription
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
self.streamFormat = format
self.targetChunkDuration = chunkDuration
}
public func append(_ data: Data) {
buffer.append(data)
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
public func flushRemaining() -> AudioPacket? {
guard !buffer.isEmpty else { return nil }
let packet = AudioPacket(
timestamp: Date(),
duration: 0.0, // Unknown duration for final chunk
peakAmplitude: 0.0,
rawAudioData: buffer
)
buffer.removeAll()
return packet
}
private func nextChunk() -> AudioPacket? {
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
let bytesPerChunk = samplesPerChunk * bytesPerFrame
guard buffer.count >= bytesPerChunk else { return nil }
let chunkData = buffer.prefix(bytesPerChunk)
let packet = AudioPacket(
timestamp: Date(),
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
peakAmplitude: 0.0, // No analysis in raw mode
rawAudioData: Data(chunkData)
)
buffer.removeFirst(bytesPerChunk)
return packet
}
}
@@ -28,7 +28,7 @@ public class AudioFormatConverter {
self.targetFormat = targetAVFormat
self.avConverter = converter
AudioTeeLogging.logger.debug(
Logger.debug(
"Audio converter created",
context: [
"source_sample_rate": String(sourceAVFormat.sampleRate),
@@ -39,7 +39,7 @@ public class AudioFormatConverter {
// Warn about upsampling once during initialization
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
AudioTeeLogging.logger.info(
Logger.info(
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
context: [
"source_rate": String(sourceAVFormat.sampleRate),
@@ -48,18 +48,13 @@ public class AudioFormatConverter {
}
}
/// The source format this converter reads from.
public var sourceFormatDescription: AudioStreamBasicDescription {
return sourceFormat.streamDescription.pointee
}
/// The target format this converter produces.
/// Get the target format as AudioStreamBasicDescription
public var targetFormatDescription: AudioStreamBasicDescription {
return targetFormat.streamDescription.pointee
}
public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.data
let inputData = packet.rawAudioData
// Calculate frame counts
let inputFrameCount =
@@ -72,7 +67,7 @@ public class AudioFormatConverter {
let inputBuffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
else {
AudioTeeLogging.logger.error("Failed to create input buffer")
Logger.error("Failed to create input buffer")
return packet
}
@@ -88,7 +83,7 @@ public class AudioFormatConverter {
let outputBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
else {
AudioTeeLogging.logger.error("Failed to create output buffer")
Logger.error("Failed to create output buffer")
return packet
}
@@ -104,7 +99,7 @@ public class AudioFormatConverter {
// Check if conversion produced output (regardless of status code)
guard outputBuffer.frameLength > 0 else {
AudioTeeLogging.logger.error(
Logger.error(
"Audio conversion produced no output",
context: [
"status": String(describing: status),
@@ -124,10 +119,17 @@ public class AudioFormatConverter {
return AudioPacket(
timestamp: packet.timestamp,
duration: packet.duration,
data: outputData
peakAmplitude: packet.peakAmplitude,
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 {
@@ -135,17 +137,26 @@ public class 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 [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
return supportedSampleRates.contains(sampleRate)
}
}
// MARK: - Error Types
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
@@ -3,25 +3,25 @@ import CoreAudio
import Foundation
public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription {
public static func getDeviceFormat(deviceID: AudioObjectID) -> 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)
AudioTeeLogging.logger.debug(
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) {
AudioTeeLogging.logger.debug(
Logger.debug(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
AudioTeeLogging.logger.info(
Logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
@@ -30,7 +30,7 @@ public class AudioFormatManager {
break
}
AudioTeeLogging.logger.info("------- not ready; retrying...")
Logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
}
@@ -49,11 +49,11 @@ public class AudioFormatManager {
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr {
AudioTeeLogging.logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
}
AudioTeeLogging.logger.info(
Logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
@@ -69,14 +69,16 @@ public class AudioFormatManager {
}
// If all attempts failed after device readiness confirmation, this is a genuine error
AudioTeeLogging.logger.error(
Logger.error(
"Failed to get device format after device readiness check and retries",
context: [
"device_id": String(deviceID),
"device_was_ready": "true",
])
throw AudioTeeError.deviceFormatUnavailable(deviceID)
fatalError(
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
)
}
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
@@ -92,8 +94,14 @@ 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) {
AudioTeeLogging.logger.debug(
Logger.debug(
"Using device's native format",
context: [
"channels": String(format.mChannelsPerFrame),
@@ -3,15 +3,18 @@ import Foundation
public struct AudioPacket {
public let timestamp: Date
public let duration: Double
public let data: Data
public let peakAmplitude: Float // useful for level monitoring
public let rawAudioData: Data
public init(
timestamp: Date,
duration: Double,
data: Data
peakAmplitude: Float,
rawAudioData: Data
) {
self.timestamp = timestamp
self.duration = duration
self.data = data
self.peakAmplitude = peakAmplitude
self.rawAudioData = rawAudioData
}
}
@@ -5,38 +5,29 @@ import Foundation
public class AudioRecorder {
private var deviceID: AudioObjectID
private var ioProcID: AudioDeviceIOProcID?
private var finalFormat: AudioStreamBasicDescription!
private var sourceFormat: AudioStreamBasicDescription?
private var finalFormat: AudioStreamBasicDescription?
private var audioBuffer: AudioBuffer?
private var outputHandler: AudioOutputHandler
private var converter: AudioFormatConverter?
private var chunkDuration: Double
/// 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(
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 = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
self.sourceFormat = sourceFormat
if let targetSampleRate = convertToSampleRate {
// Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
AudioTeeLogging.logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
self.converter = nil
self.finalFormat = sourceFormat
return
@@ -46,10 +37,10 @@ public class AudioRecorder {
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
self.converter = converter
self.finalFormat = converter.targetFormatDescription
AudioTeeLogging.logger.info(
Logger.info(
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
} catch {
AudioTeeLogging.logger.error(
Logger.error(
"Failed to create audio converter, using original format",
context: ["error": String(describing: error)])
self.converter = nil
@@ -61,24 +52,31 @@ public class AudioRecorder {
}
}
public func startRecording() throws {
AudioTeeLogging.logger.debug("Starting audio recording")
func startRecording() {
Logger.debug("Starting audio recording")
// Log format info and send metadata for final format
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
AudioFormatManager.logFormatInfo(finalFormat)
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
outputHandler.handleMetadata(metadata)
outputHandler.handleStreamStart()
try setupAndStartIOProc()
// Set up and start the IO proc
setupAndStartIOProc()
AudioTeeLogging.logger.info("Audio device started successfully")
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() throws {
AudioTeeLogging.logger.debug("Creating IO proc")
// FIXME: note to self, what about installTap? Would require audio engine and a node?
private func setupAndStartIOProc() {
Logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID(
deviceID,
{
@@ -92,15 +90,15 @@ public class AudioRecorder {
)
guard status == noErr else {
throw AudioTeeError.ioProcCreationFailed(status)
fatalError("Failed to create IO proc: \(status)")
}
AudioTeeLogging.logger.debug("Starting audio device")
Logger.debug("Starting audio device")
status = AudioDeviceStart(deviceID, ioProcID)
if status != noErr {
cleanupIOProc()
throw AudioTeeError.deviceStartFailed(status)
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
}
}
@@ -109,7 +107,7 @@ public class AudioRecorder {
let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
AudioTeeLogging.logger.error("Received empty audio buffer")
"Warning: Received empty audio buffer".print(to: .standardError)
return noErr
}
@@ -117,23 +115,24 @@ 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
if let finalPacket = audioBuffer?.flushRemaining() {
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
outputHandler.handleAudioPacket(processedPacket)
}
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func cleanupIOProc() {
@@ -3,14 +3,16 @@ import AudioToolbox
import CoreAudio
import Foundation
public class AudioTapManager {
class AudioTapManager {
private var tapID: AudioObjectID?
private var deviceID: AudioObjectID?
public init() {}
init() {
// Empty init - setup happens in setupAudioTap()
}
deinit {
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
Logger.debug("Cleaning up audio tap manager")
if let tapID = tapID {
AudioHardwareDestroyProcessTap(tapID)
@@ -24,8 +26,8 @@ public class AudioTapManager {
}
/// Sets up the audio tap and aggregate device
public func setupAudioTap(with config: TapConfiguration) throws {
AudioTeeLogging.logger.debug("Setting up audio tap manager")
func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config)
deviceID = try createAggregateDevice()
@@ -36,47 +38,51 @@ public class AudioTapManager {
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
Logger.debug("Audio tap manager setup complete")
}
/// Returns the aggregate device ID for recording
public func getDeviceID() -> AudioObjectID? {
func getDeviceID() -> AudioObjectID? {
return deviceID
}
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
AudioTeeLogging.logger.debug("Creating tap description")
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 = config.isMono
description.isMixdown = true
description.isMono = true
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
AudioTeeLogging.logger.debug(
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
AudioTeeLogging.logger.debug("Creating tap")
Logger.debug("Creating tap")
var tapID = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tapID)
AudioTeeLogging.logger.debug(
Logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error("Failed to create audio tap", context: ["status": String(status)])
Logger.error("Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status)
}
@@ -88,7 +94,7 @@ public class AudioTapManager {
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
if formatStatus == noErr {
AudioTeeLogging.logger.debug(
Logger.debug(
"Tap format retrieved",
context: [
"channels": String(streamDescription.mChannelsPerFrame),
@@ -115,7 +121,7 @@ public class AudioTapManager {
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error("Failed to create aggregate device", context: ["status": String(status)])
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status)
}
@@ -142,9 +148,9 @@ public class AudioTapManager {
}
guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error(
Logger.error(
"Failed to add tap to aggregate device", context: ["status": String(status)])
throw AudioTeeError.tapAssignmentFailed(status)
}
}
}
}
@@ -1,4 +1,3 @@
import CoreAudio
import Foundation
// MARK: - Core AudioTee Errors
@@ -9,9 +8,6 @@ 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
@@ -1,3 +1,4 @@
import Foundation
/// Protocol for handling audio output in different formats
@@ -0,0 +1,31 @@
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()
}
}
@@ -0,0 +1,29 @@
import Foundation
/// Binary output with JSON headers (pipe-optimised)
public class BinaryAudioOutputHandler: AudioOutputHandler {
public init() {}
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
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,23 @@
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)
}
}
@@ -7,6 +7,9 @@ public enum MessageType: String, Codable {
case streamStart = "stream_start"
case streamStop = "stream_stop"
// Audio data
case audio
// Logging
case info
case error
+45
View File
@@ -0,0 +1,45 @@
import Foundation
/// JSON-serializable version of AudioPacket with base64-encoded audio data
public struct JSONAudioPacket: Codable {
public let timestamp: Date
public let duration: Double
public let peakAmplitude: Float
public let audioData: String // base64 encoded audio data
public enum CodingKeys: String, CodingKey {
case timestamp
case duration
case peakAmplitude = "peak_amplitude"
case audioData = "audio_data"
}
public init(from packet: AudioPacket) {
self.timestamp = packet.timestamp
self.duration = packet.duration
self.peakAmplitude = packet.peakAmplitude
self.audioData = packet.rawAudioData.base64EncodedString()
}
}
/// Metadata-only packet for binary output (without base64 audio data)
public struct BinaryPacketHeader: Codable {
public let timestamp: Date
public let duration: Double
public let peakAmplitude: Float
public let audioLength: Int // Length of raw audio data in bytes
public enum CodingKeys: String, CodingKey {
case timestamp
case duration
case peakAmplitude = "peak_amplitude"
case audioLength = "audio_length"
}
public init(from packet: AudioPacket) {
self.timestamp = packet.timestamp
self.duration = packet.duration
self.peakAmplitude = packet.peakAmplitude
self.audioLength = packet.rawAudioData.count
}
}
@@ -0,0 +1,149 @@
import OSLog
import Observation
import SwiftUI
// Adapted with a huge debt of gratitude from https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift
/// Uses TCC SPI in order to check/request system audio recording permission.
@Observable
final class AudioRecordingPermission {
// private let logger = Logger(subsystem: kAppSubsystem, category: String(describing: AudioRecordingPermission.self))
enum Status: String {
case unknown
case denied
case authorized
}
private(set) var status: Status = .unknown
init() {
#if ENABLE_TCC_SPI
NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self else { return }
self.updateStatus()
}
updateStatus()
#else
status = .authorized
#endif // ENABLE_TCC_SPI
}
func request() {
#if ENABLE_TCC_SPI
// logger.debug(#function)
print("DEBUG: TCC SPI request called")
guard let request = Self.requestSPI else {
// logger.fault("Request SPI missing")
print("DEBUG: Request SPI is nil - TCC framework loading failed")
return
}
print("DEBUG: Calling TCC request function...")
request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in
guard let self else { return }
// self.logger.info("Request finished with result: \(granted, privacy: .public)")
print("DEBUG: TCC request completed with result: \(granted)")
DispatchQueue.main.async {
print("DEBUG: Updating status on main queue...")
if granted {
self.status = .authorized
print("DEBUG: Status set to authorized")
} else {
self.status = .denied
print("DEBUG: Status set to denied")
}
}
}
#else
print("DEBUG: ENABLE_TCC_SPI not defined")
#endif // ENABLE_TCC_SPI
}
private func updateStatus() {
#if ENABLE_TCC_SPI
// logger.debug(#function)
guard let preflight = Self.preflightSPI else {
// logger.fault("Preflight SPI missing")
return
}
let result = preflight("kTCCServiceAudioCapture" as CFString, nil)
if result == 1 {
status = .denied
} else if result == 0 {
status = .authorized
} else {
status = .unknown
}
#endif // ENABLE_TCC_SPI
}
#if ENABLE_TCC_SPI
private typealias PreflightFuncType = @convention(c) (CFString, CFDictionary?) -> Int
private typealias RequestFuncType = @convention(c) (
CFString, CFDictionary?, @escaping (Bool) -> Void
) -> Void
/// `dlopen` handle to the TCC framework.
private static let apiHandle: UnsafeMutableRawPointer? = {
let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC"
print("DEBUG: Attempting to load TCC framework from: \(tccPath)")
guard let handle = dlopen(tccPath, RTLD_NOW) else {
print("DEBUG: dlopen failed for TCC framework")
assertionFailure("dlopen failed")
return nil
}
print("DEBUG: TCC framework loaded successfully")
return handle
}()
/// `dlsym` function handle for `TCCAccessPreflight`.
private static let preflightSPI: PreflightFuncType? = {
guard let apiHandle else { return nil }
let fnName = "TCCAccessPreflight"
guard let funcSym = dlsym(apiHandle, fnName) else {
assertionFailure("Couldn't find symbol")
return nil
}
let fn = unsafeBitCast(funcSym, to: PreflightFuncType.self)
return fn
}()
/// `dlsym` function handle for `TCCAccessRequest`.
private static let requestSPI: RequestFuncType? = {
guard let apiHandle else {
print("DEBUG: No API handle for TCCAccessRequest")
return nil
}
let fnName = "TCCAccessRequest"
print("DEBUG: Looking for symbol: \(fnName)")
guard let funcSym = dlsym(apiHandle, fnName) else {
print("DEBUG: Couldn't find symbol: \(fnName)")
assertionFailure("Couldn't find symbol")
return nil
}
print("DEBUG: Found TCCAccessRequest symbol successfully")
let fn = unsafeBitCast(funcSym, to: RequestFuncType.self)
return fn
}()
#endif // ENABLE_TCC_SPI
}
+49
View File
@@ -0,0 +1,49 @@
import Foundation
public class Logger {
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [
.withInternetDateTime,
.withFractionalSeconds,
]
return formatter
}()
private static 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) {
let message = Message(type: type, data: data)
do {
let jsonData = try jsonEncoder.encode(message)
FileHandle.standardOutput.write(jsonData)
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
} catch {
// TODO: handle at some point
}
}
// Convenience methods for different message types
public static 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) {
let logData = LogData(message: message, context: context)
writeMessage(.error, data: logData)
}
public static 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
AudioTeeLogging.logger.debug(
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)
AudioTeeLogging.logger.debug(
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)
AudioTeeLogging.logger.debug(
Logger.debug(
"Failed to translate PID to process object",
context: [
"pid": String(pid),
@@ -87,3 +87,11 @@ 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)
}
}
}
@@ -1,4 +1,3 @@
import AudioTeeCore
import AudioToolbox
import Foundation
@@ -1,30 +0,0 @@
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)
}
}