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
17 changed files with 584 additions and 143 deletions
+5 -1
View File
@@ -9,6 +9,10 @@ let package = Package(
.macOS("14.2") .macOS("14.2")
], ],
targets: [ targets: [
.executableTarget(name: "audiotee") .executableTarget(
name: "audiotee",
swiftSettings: [
.define("ENABLE_TCC_SPI")
])
] ]
) )
+166 -54
View File
@@ -1,19 +1,10 @@
# AudioTee # 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 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.
capture system audio to a file like this:
```bash 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.
/path/to/audiotee > output.pcm
```
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualise it, etc.
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. Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
@@ -25,7 +16,7 @@ Recording system audio is harder than it should be on macOS, and folks often wre
## Quick start ## Quick start
The following will start capturing audio output from all running programs and write 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 ```bash
git clone git@github.com:makeusabrew/audiotee.git git clone git@github.com:makeusabrew/audiotee.git
@@ -33,17 +24,7 @@ cd audiotee
swift run swift run
``` ```
More usefully, you can redirect `stdout` to a file: 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.
```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
@@ -59,23 +40,20 @@ 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
# Write raw PCM audio to stdout (logs go to stderr) # Auto-detect output format (JSON in terminal, binary when piped)
./audiotee ./audiotee
# Redirect audio to a file # Always use JSON format (terminal-safe)
./audiotee > output.pcm ./audiotee --format json
# Pipe to another program # Always use binary format (pipe-optimised)
./audiotee | your_audio_processing_tool ./audiotee --format binary
# Redirect logs as well
./audiotee > captured_audio.pcm 2> audiotee.log
``` ```
### Audio conversion ### Audio conversion
Note that performing sample rate conversion will also convert the output bit depth to Note that performing 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. 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 ```bash
# Convert to 16kHz mono (useful for ASR services) # Convert to 16kHz mono (useful for ASR services)
@@ -116,33 +94,147 @@ Note that trying to include or exclude a PID which isn't currently playing audio
./audiotee --chunk-duration 0.1 ./audiotee --chunk-duration 0.1
``` ```
## Output ## Output formats
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`, allowing for clean separation of audio data from program output. AudioTee supports two output formats optimised for different use cases:
### Audio format ### JSON format (`--format json` or auto in terminal)
- **Format**: Raw PCM audio data JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
- **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)
### 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 ## Protocol
# Capture audio and logs separately
./audiotee > audio.pcm 2> audiotee.log
# View logs in real-time while capturing audio ### Message types
./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
@@ -151,15 +243,35 @@ All program logs are written to `stderr` and can be captured separately:
## Permissions ## Permissions
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission, AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
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 ### Lazy permissions (default approach)
might need to grant them ahead of time if audiotee looks like it's running but never records anything.
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 ## References
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) - [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
## License ## License
+5
View File
@@ -189,6 +189,11 @@ class SimpleArgumentParser {
throw ArgumentParserError.invalidValue(optionName, value) throw ArgumentParserError.invalidValue(optionName, value)
} }
return doubleValue as! T return doubleValue as! T
} else if type == OutputFormat.self {
guard let format = OutputFormat(rawValue: value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return format as! T
} }
throw ArgumentParserError.invalidValue(optionName, value) throw ArgumentParserError.invalidValue(optionName, value)
+50 -4
View File
@@ -2,11 +2,14 @@ import CoreAudio
import Foundation import Foundation
struct AudioTee { struct AudioTee {
var format: OutputFormat = .auto
var includeProcesses: [Int32] = [] var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = [] var excludeProcesses: [Int32] = []
var mute: Bool = false var mute: Bool = false
var sampleRate: Double? var sampleRate: Double?
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
var permissionsMode: Bool = false
var requestPermissions: Bool = false
init() {} init() {}
@@ -17,15 +20,28 @@ struct AudioTee {
discussion: """ discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output. AudioTee captures system audio using Core Audio taps and streams it as structured output.
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: Process filtering:
• include-processes: Only tap specified process IDs (empty = all processes) • include-processes: Only tap specified process IDs (empty = all processes)
• exclude-processes: Tap all processes except specified ones • exclude-processes: Tap all processes except specified ones
• mute: How to handle processes being tapped • mute: How to handle processes being tapped
Examples: Examples:
audiotee --permissions # Check audio recording permissions
audiotee --permissions --request # Request audio recording permissions
audiotee # Auto format, tap all processes audiotee # Auto format, tap all processes
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR audiotee --format=json # Always use JSON format
audiotee --sample-rate 8000 # Convert to 8kHz for telephony 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 # Only tap process 1234
audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --exclude-processes 1234 5678 # Tap everything except these
@@ -34,6 +50,9 @@ struct AudioTee {
) )
// Configure arguments // 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( parser.addArrayOption(
name: "include-processes", name: "include-processes",
help: "Process IDs to include (space-separated, empty = all processes)") help: "Process IDs to include (space-separated, empty = all processes)")
@@ -53,6 +72,9 @@ struct AudioTee {
var audioTee = AudioTee() var audioTee = AudioTee()
// Extract values // 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.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute") audioTee.mute = parser.getFlag("mute")
@@ -86,12 +108,25 @@ struct AudioTee {
throw ArgumentParserError.validationFailed( throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes") "Cannot specify both --include-processes and --exclude-processes")
} }
if requestPermissions && !permissionsMode {
throw ArgumentParserError.validationFailed(
"--request can only be used with --permissions")
}
} }
func run() throws { 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() 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 {
@@ -132,7 +167,7 @@ struct AudioTee {
throw ExitCode.failure throw ExitCode.failure
} }
let outputHandler = BinaryAudioOutputHandler() let outputHandler = createOutputHandler(for: format)
let recorder = AudioRecorder( let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration) chunkDuration: chunkDuration)
@@ -161,6 +196,17 @@ struct AudioTee {
} }
} }
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
switch format {
case .json:
return JSONAudioOutputHandler()
case .binary:
return BinaryAudioOutputHandler()
case .auto:
return AutoAudioOutputHandler()
}
}
private func convertProcessFlags() -> ([Int32], Bool) { private func convertProcessFlags() -> ([Int32], Bool) {
if !includeProcesses.isEmpty { if !includeProcesses.isEmpty {
// Include specific processes only // Include specific processes only
@@ -178,7 +224,7 @@ struct AudioTee {
// Helper for stderr output // Helper for stderr output
var standardError = FileHandle.standardError var standardError = FileHandle.standardError
extension FileHandle: TextOutputStream { extension FileHandle: @retroactive TextOutputStream {
public func write(_ string: String) { public func write(_ string: String) {
let data = Data(string.utf8) let data = Data(string.utf8)
self.write(data) self.write(data)
+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
}
}
}
+32 -78
View File
@@ -2,64 +2,17 @@ import CoreAudio
import Foundation import Foundation
public class AudioBuffer { public class AudioBuffer {
private var buffer: [UInt8] private var buffer = Data()
private var writeIndex: Int = 0 private let targetChunkDuration: Double
private var readIndex: Int = 0 private let streamFormat: AudioStreamBasicDescription
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
// Pre-calculate chunk parameters self.targetChunkDuration = chunkDuration
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) {
guard availableBytes + data.count <= maxBufferSize else { buffer.append(data)
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] {
@@ -72,36 +25,37 @@ public class AudioBuffer {
return packets return packets
} }
private func nextChunk() -> AudioPacket? { public func flushRemaining() -> AudioPacket? {
// Check if we have enough data for a complete chunk guard !buffer.isEmpty else { return nil }
guard availableBytes >= bytesPerChunk else { return nil }
var chunkData = Data(capacity: bytesPerChunk)
// Check if we can copy in one block (no wrap-around)
if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// two copies needed due to wrap-around
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
let packet = AudioPacket( let packet = AudioPacket(
timestamp: Date(), timestamp: Date(),
duration: chunkDuration, duration: 0.0, // Unknown duration for final chunk
rawAudioData: chunkData 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 return packet
} }
} }
+1
View File
@@ -119,6 +119,7 @@ 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
) )
} }
+3
View File
@@ -3,15 +3,18 @@ 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
} }
} }
+3 -4
View File
@@ -74,8 +74,7 @@ public class AudioRecorder {
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? // FIXME: 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(
@@ -127,8 +126,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
audioBuffer?.processChunks().forEach { packet in if let finalPacket = audioBuffer?.flushRemaining() {
let processedPacket = converter?.transform(packet) ?? packet let processedPacket = converter?.transform(finalPacket) ?? finalPacket
outputHandler.handleAudioPacket(processedPacket) outputHandler.handleAudioPacket(processedPacket)
} }
@@ -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()
}
}
@@ -5,6 +5,12 @@ 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)
} }
@@ -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)
}
}
+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
}
+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.standardError.write(jsonData) FileHandle.standardOutput.write(jsonData)
FileHandle.standardError.write("\n".data(using: .utf8)!) FileHandle.standardOutput.write("\n".data(using: .utf8)!)
} catch { } catch {
// TODO: handle at some point // TODO: handle at some point
} }
BIN
View File
Binary file not shown.