Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f0bc8f6c | |||
| c4cf27553a | |||
| 087e4b1642 | |||
| 0b1ba5c8cd | |||
| b7455d26d1 | |||
| a311cc583f | |||
| 8b3de5918c | |||
| cf7bc88528 | |||
| 25f017f319 | |||
| 307eae147d | |||
| c9d11316ff | |||
| 71b0c45a5c | |||
| fd82e3d9d9 | |||
| d260c7824d | |||
| b3b11c8649 | |||
| 00985c0d33 | |||
| 1c94f55a9b |
@@ -7,3 +7,5 @@ DerivedData/
|
|||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
*.pcm
|
||||||
|
*.wav
|
||||||
|
|||||||
+37
-7
@@ -4,11 +4,41 @@
|
|||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "audiotee",
|
name: "audiotee",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS("14.2")
|
.macOS("14.2")
|
||||||
],
|
],
|
||||||
targets: [
|
products: [
|
||||||
.executableTarget(name: "audiotee")
|
// 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"
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
|
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
|
||||||
|
|
||||||
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can
|
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:
|
||||||
capture system audio to a file like this:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/path/to/audiotee > output.pcm
|
/path/to/audiotee > output.pcm
|
||||||
```
|
```
|
||||||
|
|
||||||
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualise it, etc.
|
It's more likely you want to capture this output programmatically. Check out [AudioTee.js](https://github.com/makeusabrew/audioteejs) for a simple Node.js package which does this.
|
||||||
|
|
||||||
By default, audiotee captures audio output from **all** running processes. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
|
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - save it to disk, visualise it, transcribe it, etc.
|
||||||
|
|
||||||
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, franky, improvements) of audiotee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
|
By default, AudioTee captures audio output from **all** running processes. Tap output defaults to `mono` (configurable via the `--stereo` flag) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
|
||||||
|
|
||||||
|
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, frankly, improvements) of AudioTee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
|
||||||
|
|
||||||
|
## Why?
|
||||||
|
|
||||||
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
||||||
|
|
||||||
@@ -25,7 +28,7 @@ Recording system audio is harder than it should be on macOS, and folks often wre
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
The following will start capturing audio output from all running programs and write raw PCM audio data to your terminal:
|
The following will start capturing audio output from all running programs and write binary chunks of raw PCM audio data to your terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:makeusabrew/audiotee.git
|
git clone git@github.com:makeusabrew/audiotee.git
|
||||||
@@ -74,11 +77,14 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
|
|||||||
|
|
||||||
### Audio conversion
|
### Audio conversion
|
||||||
|
|
||||||
Note that performing sample rate conversion will also convert the output bit depth to
|
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 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 a 50% reduction in output size. For ASR services, 16-bit is sufficient, but it's a non-obvious behaviour worth being aware of.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Convert to 16kHz mono (useful for ASR services)
|
# No sample rate preserves your device's default (probably 44.1 or 48kHz with 32-bit float bit depth)
|
||||||
|
./audiotee
|
||||||
|
|
||||||
|
# Any sample rate (even one matching your device default) converts to 16-bit signed integers (half the bandwidth)
|
||||||
./audiotee --sample-rate 16000
|
./audiotee --sample-rate 16000
|
||||||
|
|
||||||
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
|
# Other supported sample rates: 22050, 24000, 32000, 44100, 48000
|
||||||
@@ -118,12 +124,12 @@ Note that trying to include or exclude a PID which isn't currently playing audio
|
|||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`, allowing for clean separation of audio data from program output.
|
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
|
||||||
|
|
||||||
### Audio format
|
### Audio format
|
||||||
|
|
||||||
- **Format**: Raw PCM audio data
|
- **Format**: Raw PCM audio data
|
||||||
- **Channels**: Mono (1 channel)
|
- **Channels**: 1 in Mono mode (default), 2 in stereo mode
|
||||||
- **Sample rate**: Matches your output device's sample rate by default (configurable)
|
- **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
|
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
|
||||||
- **Endianness**: Little-endian
|
- **Endianness**: Little-endian
|
||||||
@@ -146,20 +152,21 @@ All program logs are written to `stderr` and can be captured separately:
|
|||||||
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
- `--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
|
||||||
|
- `--stereo`: Record in stereo
|
||||||
- `--sample-rate`: Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)
|
- `--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]
|
- `--chunk-duration`: Audio chunk duration in seconds [default: 0.2, max: 5.0]
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
|
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission, so you'll be prompted the first time AudioTee tries to record anything. 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.
|
||||||
so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift). Note that some terminal emulators like
|
|
||||||
iTerm don't always prompt for these permissions (the macOS builtin terminal definitely does), so you
|
|
||||||
might need to grant them ahead of time if audiotee looks like it's running but never records anything.
|
|
||||||
|
|
||||||
## References
|
If you want to check and/or request permissions ahead of time, check out [AudioCap's fantastic TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
|
||||||
|
|
||||||
|
## References / useful links
|
||||||
|
|
||||||
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
- [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)
|
||||||
|
- [AudioTee.js](https://github.com/makeusabrew/audioteejs)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AudioTeeCore
|
||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -5,8 +6,10 @@ struct AudioTee {
|
|||||||
var includeProcesses: [Int32] = []
|
var includeProcesses: [Int32] = []
|
||||||
var excludeProcesses: [Int32] = []
|
var excludeProcesses: [Int32] = []
|
||||||
var mute: Bool = false
|
var mute: Bool = false
|
||||||
|
var stereo: Bool = false
|
||||||
var sampleRate: Double?
|
var sampleRate: Double?
|
||||||
var chunkDuration: Double = 0.2
|
var chunkDuration: Double = 0.2
|
||||||
|
var flush: Bool = false
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ struct AudioTee {
|
|||||||
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
|
||||||
audiotee --mute # Mute processes being tapped
|
audiotee --mute # Mute processes being tapped
|
||||||
|
audiotee --flush # Flush stdout after each chunk
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +44,8 @@ struct AudioTee {
|
|||||||
parser.addArrayOption(
|
parser.addArrayOption(
|
||||||
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
||||||
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
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(
|
parser.addOption(
|
||||||
name: "sample-rate",
|
name: "sample-rate",
|
||||||
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
||||||
@@ -56,6 +62,8 @@ struct AudioTee {
|
|||||||
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
audioTee.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")
|
||||||
|
audioTee.stereo = parser.getFlag("stereo")
|
||||||
|
audioTee.flush = parser.getFlag("flush")
|
||||||
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
||||||
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
||||||
|
|
||||||
@@ -91,11 +99,11 @@ struct AudioTee {
|
|||||||
func run() throws {
|
func run() throws {
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
||||||
|
|
||||||
// Validate chunk duration
|
// Validate chunk duration
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Invalid chunk duration",
|
"Invalid chunk duration",
|
||||||
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
@@ -107,14 +115,15 @@ struct AudioTee {
|
|||||||
let tapConfig = TapConfiguration(
|
let tapConfig = TapConfiguration(
|
||||||
processes: processes,
|
processes: processes,
|
||||||
muteBehavior: mute ? .muted : .unmuted,
|
muteBehavior: mute ? .muted : .unmuted,
|
||||||
isExclusive: isExclusive
|
isExclusive: isExclusive,
|
||||||
|
isMono: !stereo
|
||||||
)
|
)
|
||||||
|
|
||||||
let audioTapManager = AudioTapManager()
|
let audioTapManager = AudioTapManager()
|
||||||
do {
|
do {
|
||||||
try audioTapManager.setupAudioTap(with: tapConfig)
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
||||||
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to translate process IDs to audio objects",
|
"Failed to translate process IDs to audio objects",
|
||||||
context: [
|
context: [
|
||||||
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
||||||
@@ -122,21 +131,21 @@ struct AudioTee {
|
|||||||
])
|
])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let deviceID = audioTapManager.getDeviceID() else {
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
||||||
Logger.error("Failed to get device ID from audio tap manager")
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputHandler = BinaryAudioOutputHandler()
|
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
|
||||||
let recorder = AudioRecorder(
|
let recorder = try AudioRecorder(
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
chunkDuration: chunkDuration)
|
chunkDuration: chunkDuration)
|
||||||
recorder.startRecording()
|
try recorder.startRecording()
|
||||||
|
|
||||||
// Run until the run loop is stopped (by signal handler)
|
// Run until the run loop is stopped (by signal handler)
|
||||||
while true {
|
while true {
|
||||||
@@ -146,17 +155,17 @@ struct AudioTee {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("Shutting down...")
|
AudioTeeLogging.logger.info("Shutting down...")
|
||||||
recorder.stopRecording()
|
recorder.stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSignalHandlers() {
|
private func setupSignalHandlers() {
|
||||||
signal(SIGINT) { _ in
|
signal(SIGINT) { _ in
|
||||||
Logger.info("Received SIGINT, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
signal(SIGTERM) { _ in
|
signal(SIGTERM) { _ in
|
||||||
Logger.info("Received SIGTERM, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AudioTeeCore
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ public class AudioBuffer {
|
|||||||
|
|
||||||
public func append(_ data: Data) {
|
public func append(_ data: Data) {
|
||||||
guard availableBytes + data.count <= maxBufferSize else {
|
guard availableBytes + data.count <= maxBufferSize else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Audio buffer overflow",
|
"Audio buffer overflow",
|
||||||
context: [
|
context: [
|
||||||
"requested": String(data.count),
|
"requested": String(data.count),
|
||||||
@@ -96,12 +96,10 @@ public class AudioBuffer {
|
|||||||
|
|
||||||
availableBytes -= bytesPerChunk
|
availableBytes -= bytesPerChunk
|
||||||
|
|
||||||
let packet = AudioPacket(
|
return AudioPacket(
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
duration: chunkDuration,
|
duration: chunkDuration,
|
||||||
rawAudioData: chunkData
|
data: chunkData
|
||||||
)
|
)
|
||||||
|
|
||||||
return packet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-28
@@ -28,7 +28,7 @@ public class AudioFormatConverter {
|
|||||||
self.targetFormat = targetAVFormat
|
self.targetFormat = targetAVFormat
|
||||||
self.avConverter = converter
|
self.avConverter = converter
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio converter created",
|
"Audio converter created",
|
||||||
context: [
|
context: [
|
||||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||||
@@ -39,7 +39,7 @@ public class AudioFormatConverter {
|
|||||||
|
|
||||||
// Warn about upsampling once during initialization
|
// Warn about upsampling once during initialization
|
||||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||||
context: [
|
context: [
|
||||||
"source_rate": String(sourceAVFormat.sampleRate),
|
"source_rate": String(sourceAVFormat.sampleRate),
|
||||||
@@ -48,13 +48,18 @@ public class AudioFormatConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the target format as AudioStreamBasicDescription
|
/// The source format this converter reads from.
|
||||||
|
public var sourceFormatDescription: AudioStreamBasicDescription {
|
||||||
|
return sourceFormat.streamDescription.pointee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target format this converter produces.
|
||||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||||
return targetFormat.streamDescription.pointee
|
return targetFormat.streamDescription.pointee
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
||||||
let inputData = packet.rawAudioData
|
let inputData = packet.data
|
||||||
|
|
||||||
// Calculate frame counts
|
// Calculate frame counts
|
||||||
let inputFrameCount =
|
let inputFrameCount =
|
||||||
@@ -67,7 +72,7 @@ public class AudioFormatConverter {
|
|||||||
let inputBuffer = AVAudioPCMBuffer(
|
let inputBuffer = AVAudioPCMBuffer(
|
||||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
||||||
else {
|
else {
|
||||||
Logger.error("Failed to create input buffer")
|
AudioTeeLogging.logger.error("Failed to create input buffer")
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ public class AudioFormatConverter {
|
|||||||
let outputBuffer = AVAudioPCMBuffer(
|
let outputBuffer = AVAudioPCMBuffer(
|
||||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
||||||
else {
|
else {
|
||||||
Logger.error("Failed to create output buffer")
|
AudioTeeLogging.logger.error("Failed to create output buffer")
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +104,7 @@ public class AudioFormatConverter {
|
|||||||
|
|
||||||
// Check if conversion produced output (regardless of status code)
|
// Check if conversion produced output (regardless of status code)
|
||||||
guard outputBuffer.frameLength > 0 else {
|
guard outputBuffer.frameLength > 0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Audio conversion produced no output",
|
"Audio conversion produced no output",
|
||||||
context: [
|
context: [
|
||||||
"status": String(describing: status),
|
"status": String(describing: status),
|
||||||
@@ -119,16 +124,10 @@ public class AudioFormatConverter {
|
|||||||
return AudioPacket(
|
return AudioPacket(
|
||||||
timestamp: packet.timestamp,
|
timestamp: packet.timestamp,
|
||||||
duration: packet.duration,
|
duration: packet.duration,
|
||||||
rawAudioData: outputData
|
data: outputData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Constructors
|
|
||||||
|
|
||||||
extension AudioFormatConverter {
|
|
||||||
/// Create a converter to a specific sample rate with mono PCM 16-bit output
|
|
||||||
/// Since the tap already converts to mono, we hardcode channels to 1
|
|
||||||
public static func toSampleRate(
|
public static func toSampleRate(
|
||||||
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
||||||
) throws -> AudioFormatConverter {
|
) throws -> AudioFormatConverter {
|
||||||
@@ -136,26 +135,17 @@ extension AudioFormatConverter {
|
|||||||
targetFormat.mSampleRate = sampleRate
|
targetFormat.mSampleRate = sampleRate
|
||||||
targetFormat.mFormatID = kAudioFormatLinearPCM
|
targetFormat.mFormatID = kAudioFormatLinearPCM
|
||||||
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
||||||
targetFormat.mBytesPerPacket = 2
|
|
||||||
targetFormat.mFramesPerPacket = 1
|
targetFormat.mFramesPerPacket = 1
|
||||||
targetFormat.mBytesPerFrame = 2
|
|
||||||
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
|
|
||||||
targetFormat.mBitsPerChannel = 16
|
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)
|
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 {
|
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||||
return supportedSampleRates.contains(sampleRate)
|
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Types
|
|
||||||
|
|
||||||
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
|
|
||||||
+10
-18
@@ -3,25 +3,25 @@ import CoreAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class AudioFormatManager {
|
public class AudioFormatManager {
|
||||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription {
|
||||||
// First, wait for the device to become alive/ready
|
// First, wait for the device to become alive/ready
|
||||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||||
let pollInterval = 0.1 // 100ms poll interval
|
let pollInterval = 0.1 // 100ms poll interval
|
||||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||||
|
|
||||||
// Poll device readiness
|
// Poll device readiness
|
||||||
for poll in 1...maxPolls {
|
for poll in 1...maxPolls {
|
||||||
if isAudioDeviceValid(deviceID) {
|
if isAudioDeviceValid(deviceID) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if poll == maxPolls {
|
if poll == maxPolls {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Device did not become ready within timeout, proceeding anyway",
|
"Device did not become ready within timeout, proceeding anyway",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -30,7 +30,7 @@ public class AudioFormatManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("------- not ready; retrying...")
|
AudioTeeLogging.logger.info("------- not ready; retrying...")
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: pollInterval)
|
Thread.sleep(forTimeInterval: pollInterval)
|
||||||
}
|
}
|
||||||
@@ -49,11 +49,11 @@ public class AudioFormatManager {
|
|||||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
if status == noErr {
|
if status == noErr {
|
||||||
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
|
AudioTeeLogging.logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
|
||||||
return streamFormat
|
return streamFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"------- Failed to get stream format after device ready check, retrying...",
|
"------- Failed to get stream format after device ready check, retrying...",
|
||||||
context: [
|
context: [
|
||||||
"attempt": String(attempt),
|
"attempt": String(attempt),
|
||||||
@@ -69,16 +69,14 @@ public class AudioFormatManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to get device format after device readiness check and retries",
|
"Failed to get device format after device readiness check and retries",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
"device_was_ready": "true",
|
"device_was_ready": "true",
|
||||||
])
|
])
|
||||||
|
|
||||||
fatalError(
|
throw AudioTeeError.deviceFormatUnavailable(deviceID)
|
||||||
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||||
@@ -94,14 +92,8 @@ public class AudioFormatManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func writeMetadata(for format: AudioStreamBasicDescription) {
|
|
||||||
let metadata = createMetadata(for: format)
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Using device's native format",
|
"Using device's native format",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(format.mChannelsPerFrame),
|
"channels": String(format.mChannelsPerFrame),
|
||||||
@@ -3,15 +3,15 @@ import Foundation
|
|||||||
public struct AudioPacket {
|
public struct AudioPacket {
|
||||||
public let timestamp: Date
|
public let timestamp: Date
|
||||||
public let duration: Double
|
public let duration: Double
|
||||||
public let rawAudioData: Data
|
public let data: Data
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
duration: Double,
|
duration: Double,
|
||||||
rawAudioData: Data
|
data: Data
|
||||||
) {
|
) {
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.rawAudioData = rawAudioData
|
self.data = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,29 +5,38 @@ import Foundation
|
|||||||
public class AudioRecorder {
|
public class AudioRecorder {
|
||||||
private var deviceID: AudioObjectID
|
private var deviceID: AudioObjectID
|
||||||
private var ioProcID: AudioDeviceIOProcID?
|
private var ioProcID: AudioDeviceIOProcID?
|
||||||
private var sourceFormat: AudioStreamBasicDescription?
|
private var finalFormat: AudioStreamBasicDescription!
|
||||||
private var finalFormat: AudioStreamBasicDescription?
|
|
||||||
private var audioBuffer: AudioBuffer?
|
private var audioBuffer: AudioBuffer?
|
||||||
private var outputHandler: AudioOutputHandler
|
private var outputHandler: AudioOutputHandler
|
||||||
private var converter: AudioFormatConverter?
|
private var converter: AudioFormatConverter?
|
||||||
private var chunkDuration: Double
|
|
||||||
|
|
||||||
init(
|
/// The audio format this recorder produces (after any conversion).
|
||||||
|
public var outputFormat: AudioStreamBasicDescription {
|
||||||
|
return finalFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this recorder is performing sample rate conversion.
|
||||||
|
public var isConverting: Bool {
|
||||||
|
return converter != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||||
chunkDuration: Double = 0.2
|
chunkDuration: Double = 0.2
|
||||||
) {
|
) throws {
|
||||||
self.deviceID = deviceID
|
self.deviceID = deviceID
|
||||||
self.outputHandler = outputHandler
|
self.outputHandler = outputHandler
|
||||||
self.chunkDuration = chunkDuration
|
|
||||||
|
|
||||||
// Get source format and set up conversion if requested
|
// Get source format and set up conversion if requested
|
||||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
let sourceFormat = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||||
self.sourceFormat = sourceFormat
|
|
||||||
|
// Set up the audio buffer using source format and configurable chunk duration
|
||||||
|
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||||
|
|
||||||
if let targetSampleRate = convertToSampleRate {
|
if let targetSampleRate = convertToSampleRate {
|
||||||
// Validate sample rate
|
// Validate sample rate
|
||||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
||||||
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
AudioTeeLogging.logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
self.finalFormat = sourceFormat
|
self.finalFormat = sourceFormat
|
||||||
return
|
return
|
||||||
@@ -37,10 +46,10 @@ public class AudioRecorder {
|
|||||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.finalFormat = converter.targetFormatDescription
|
self.finalFormat = converter.targetFormatDescription
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to create audio converter, using original format",
|
"Failed to create audio converter, using original format",
|
||||||
context: ["error": String(describing: error)])
|
context: ["error": String(describing: error)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
@@ -52,32 +61,24 @@ public class AudioRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRecording() {
|
public func startRecording() throws {
|
||||||
Logger.debug("Starting audio recording")
|
AudioTeeLogging.logger.debug("Starting audio recording")
|
||||||
|
|
||||||
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
|
// Log format info and send metadata for final format
|
||||||
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)
|
AudioFormatManager.logFormatInfo(finalFormat)
|
||||||
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
||||||
outputHandler.handleMetadata(metadata)
|
outputHandler.handleMetadata(metadata)
|
||||||
outputHandler.handleStreamStart()
|
outputHandler.handleStreamStart()
|
||||||
|
|
||||||
// Set up and start the IO proc
|
try setupAndStartIOProc()
|
||||||
setupAndStartIOProc()
|
|
||||||
|
|
||||||
Logger.info("Audio device started successfully")
|
AudioTeeLogging.logger.info("Audio device started successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note to self, what about installTap? Would require audio engine and a node?
|
// 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
|
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||||
private func setupAndStartIOProc() {
|
private func setupAndStartIOProc() throws {
|
||||||
Logger.debug("Creating IO proc")
|
AudioTeeLogging.logger.debug("Creating IO proc")
|
||||||
var status = AudioDeviceCreateIOProcID(
|
var status = AudioDeviceCreateIOProcID(
|
||||||
deviceID,
|
deviceID,
|
||||||
{
|
{
|
||||||
@@ -91,15 +92,15 @@ public class AudioRecorder {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard status == noErr else {
|
guard status == noErr else {
|
||||||
fatalError("Failed to create IO proc: \(status)")
|
throw AudioTeeError.ioProcCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug("Starting audio device")
|
AudioTeeLogging.logger.debug("Starting audio device")
|
||||||
status = AudioDeviceStart(deviceID, ioProcID)
|
status = AudioDeviceStart(deviceID, ioProcID)
|
||||||
|
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
cleanupIOProc()
|
cleanupIOProc()
|
||||||
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
|
throw AudioTeeError.deviceStartFailed(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ public class AudioRecorder {
|
|||||||
let firstBuffer = bufferList.mBuffers
|
let firstBuffer = bufferList.mBuffers
|
||||||
|
|
||||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
||||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
AudioTeeLogging.logger.error("Received empty audio buffer")
|
||||||
return noErr
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,24 +117,23 @@ public class AudioRecorder {
|
|||||||
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
||||||
audioBuffer?.append(audioData)
|
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
|
// Process and send complete chunks, applying conversion if needed
|
||||||
audioBuffer?.processChunks().forEach { packet in
|
audioBuffer?.processChunks().forEach { packet in
|
||||||
let processedPacket = converter?.transform(packet) ?? packet
|
let processedPacket = converter?.transform(packet) ?? packet
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
outputHandler.handleAudioPacket(processedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
return noErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRecording() {
|
|
||||||
// Send any remaining buffered audio, applying conversion if needed
|
|
||||||
audioBuffer?.processChunks().forEach { packet in
|
|
||||||
let processedPacket = converter?.transform(packet) ?? packet
|
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputHandler.handleStreamStop()
|
|
||||||
cleanupIOProc()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupIOProc() {
|
private func cleanupIOProc() {
|
||||||
+18
-24
@@ -3,16 +3,14 @@ import AudioToolbox
|
|||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class AudioTapManager {
|
public class AudioTapManager {
|
||||||
private var tapID: AudioObjectID?
|
private var tapID: AudioObjectID?
|
||||||
private var deviceID: AudioObjectID?
|
private var deviceID: AudioObjectID?
|
||||||
|
|
||||||
init() {
|
public init() {}
|
||||||
// Empty init - setup happens in setupAudioTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Logger.debug("Cleaning up audio tap manager")
|
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
|
||||||
|
|
||||||
if let tapID = tapID {
|
if let tapID = tapID {
|
||||||
AudioHardwareDestroyProcessTap(tapID)
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
@@ -26,8 +24,8 @@ class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up the audio tap and aggregate device
|
/// Sets up the audio tap and aggregate device
|
||||||
func setupAudioTap(with config: TapConfiguration) throws {
|
public func setupAudioTap(with config: TapConfiguration) throws {
|
||||||
Logger.debug("Setting up audio tap manager")
|
AudioTeeLogging.logger.debug("Setting up audio tap manager")
|
||||||
|
|
||||||
tapID = try createSystemAudioTap(with: config)
|
tapID = try createSystemAudioTap(with: config)
|
||||||
deviceID = try createAggregateDevice()
|
deviceID = try createAggregateDevice()
|
||||||
@@ -38,51 +36,47 @@ class AudioTapManager {
|
|||||||
|
|
||||||
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
||||||
|
|
||||||
Logger.debug("Audio tap manager setup complete")
|
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the aggregate device ID for recording
|
/// Returns the aggregate device ID for recording
|
||||||
func getDeviceID() -> AudioObjectID? {
|
public func getDeviceID() -> AudioObjectID? {
|
||||||
return deviceID
|
return deviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||||
Logger.debug("Creating tap description")
|
AudioTeeLogging.logger.debug("Creating tap description")
|
||||||
// Create a tap description
|
|
||||||
let description = CATapDescription()
|
let description = CATapDescription()
|
||||||
|
|
||||||
// Configure the tap to capture all system audio
|
|
||||||
description.name = "audiotee-tap"
|
description.name = "audiotee-tap"
|
||||||
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
|
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
|
||||||
description.isPrivate = true
|
description.isPrivate = true
|
||||||
description.muteBehavior = config.muteBehavior.coreAudioValue
|
description.muteBehavior = config.muteBehavior.coreAudioValue
|
||||||
description.isMixdown = true
|
description.isMixdown = true
|
||||||
description.isMono = true
|
description.isMono = config.isMono
|
||||||
description.isExclusive = config.isExclusive
|
description.isExclusive = config.isExclusive
|
||||||
description.deviceUID = nil // system default
|
description.deviceUID = nil // system default
|
||||||
description.stream = 0 // first stream of output device
|
description.stream = 0 // first stream of output device
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap description configured",
|
"Tap description configured",
|
||||||
context: [
|
context: [
|
||||||
"name": description.name,
|
"name": description.name,
|
||||||
"processes": String(describing: config.processes),
|
"processes": String(describing: config.processes),
|
||||||
"private": String(description.isPrivate),
|
|
||||||
"mute": String(describing: description.muteBehavior),
|
"mute": String(describing: description.muteBehavior),
|
||||||
"mixdown": String(description.isMixdown),
|
|
||||||
"mono": String(description.isMono),
|
"mono": String(description.isMono),
|
||||||
"exclusive": String(description.isExclusive),
|
"exclusive": String(description.isExclusive),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Create the tap
|
// Create the tap
|
||||||
Logger.debug("Creating tap")
|
AudioTeeLogging.logger.debug("Creating tap")
|
||||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error("Failed to create audio tap", context: ["status": String(status)])
|
AudioTeeLogging.logger.error("Failed to create audio tap", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapCreationFailed(status)
|
throw AudioTeeError.tapCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +88,7 @@ class AudioTapManager {
|
|||||||
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
||||||
|
|
||||||
if formatStatus == noErr {
|
if formatStatus == noErr {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap format retrieved",
|
"Tap format retrieved",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(streamDescription.mChannelsPerFrame),
|
"channels": String(streamDescription.mChannelsPerFrame),
|
||||||
@@ -121,7 +115,7 @@ class AudioTapManager {
|
|||||||
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
|
AudioTeeLogging.logger.error("Failed to create aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +142,7 @@ class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapAssignmentFailed(status)
|
throw AudioTeeError.tapAssignmentFailed(status)
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Core AudioTee Errors
|
// MARK: - Core AudioTee Errors
|
||||||
@@ -8,6 +9,9 @@ public enum AudioTeeError: Error {
|
|||||||
case aggregateDeviceCreationFailed(OSStatus)
|
case aggregateDeviceCreationFailed(OSStatus)
|
||||||
case tapAssignmentFailed(OSStatus)
|
case tapAssignmentFailed(OSStatus)
|
||||||
case pidTranslationFailed([Int32])
|
case pidTranslationFailed([Int32])
|
||||||
|
case deviceFormatUnavailable(AudioObjectID)
|
||||||
|
case ioProcCreationFailed(OSStatus)
|
||||||
|
case deviceStartFailed(OSStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Format Conversion Errors
|
// MARK: - Audio Format Conversion Errors
|
||||||
+3
-1
@@ -2,10 +2,12 @@ public struct TapConfiguration {
|
|||||||
public let processes: [Int32]
|
public let processes: [Int32]
|
||||||
public let muteBehavior: TapMuteBehavior
|
public let muteBehavior: TapMuteBehavior
|
||||||
public let isExclusive: Bool
|
public let isExclusive: Bool
|
||||||
|
public let isMono: Bool
|
||||||
|
|
||||||
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
|
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool, isMono: Bool) {
|
||||||
self.processes = processes
|
self.processes = processes
|
||||||
self.muteBehavior = muteBehavior
|
self.muteBehavior = muteBehavior
|
||||||
self.isExclusive = isExclusive
|
self.isExclusive = isExclusive
|
||||||
|
self.isMono = isMono
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol for handling audio output in different formats
|
/// Protocol for handling audio output in different formats
|
||||||
@@ -7,9 +7,6 @@ public enum MessageType: String, Codable {
|
|||||||
case streamStart = "stream_start"
|
case streamStart = "stream_start"
|
||||||
case streamStop = "stream_stop"
|
case streamStop = "stream_stop"
|
||||||
|
|
||||||
// Audio data
|
|
||||||
case audio
|
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
case info
|
case info
|
||||||
case error
|
case error
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Logging protocol
|
||||||
|
|
||||||
|
/// Protocol that library consumers implement to receive log output.
|
||||||
|
/// The library never writes to stderr directly — it calls through this.
|
||||||
|
public protocol AudioTeeLogger {
|
||||||
|
func debug(_ message: String, context: [String: String]?)
|
||||||
|
func info(_ message: String, context: [String: String]?)
|
||||||
|
func error(_ message: String, context: [String: String]?)
|
||||||
|
|
||||||
|
/// Called for structured lifecycle messages (metadata, stream_start, stream_stop).
|
||||||
|
/// Default implementation is a no-op — pure library consumers get metadata
|
||||||
|
/// via AudioOutputHandler instead.
|
||||||
|
func writeMessage<T: Codable>(_ type: MessageType, data: T?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
extension AudioTeeLogger {
|
||||||
|
/// Library consumers typically don't need structured message output;
|
||||||
|
/// they receive metadata via the AudioOutputHandler protocol instead.
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {}
|
||||||
|
|
||||||
|
/// Convenience overloads so callers can omit context when it's nil.
|
||||||
|
public func debug(_ message: String) { debug(message, context: nil) }
|
||||||
|
public func info(_ message: String) { info(message, context: nil) }
|
||||||
|
public func error(_ message: String) { error(message, context: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global logging configuration
|
||||||
|
|
||||||
|
/// Global logger instance. Defaults to StderrJSONLogger (CLI behavior).
|
||||||
|
/// Library consumers can replace this before calling any AudioTeeCore API.
|
||||||
|
///
|
||||||
|
/// // Silence all logging:
|
||||||
|
/// AudioTeeLogging.logger = NullLogger()
|
||||||
|
///
|
||||||
|
/// // Custom logging:
|
||||||
|
/// AudioTeeLogging.logger = MyOSLogLogger()
|
||||||
|
///
|
||||||
|
public enum AudioTeeLogging {
|
||||||
|
nonisolated(unsafe) public static var logger: AudioTeeLogger = StderrJSONLogger()
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Logger {
|
/// Default logger implementation that writes JSON messages to stderr.
|
||||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
/// 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()
|
let formatter = ISO8601DateFormatter()
|
||||||
formatter.formatOptions = [
|
formatter.formatOptions = [
|
||||||
.withInternetDateTime,
|
.withInternetDateTime,
|
||||||
@@ -10,17 +13,22 @@ public class Logger {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let jsonEncoder: JSONEncoder = {
|
private let jsonEncoder: JSONEncoder = {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(dateFormatter.string(from: date))
|
|
||||||
}
|
|
||||||
return encoder
|
return encoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Write any message with the unified envelope
|
public init() {
|
||||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
// 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)
|
let message = Message(type: type, data: data)
|
||||||
do {
|
do {
|
||||||
let jsonData = try jsonEncoder.encode(message)
|
let jsonData = try jsonEncoder.encode(message)
|
||||||
@@ -32,17 +40,17 @@ public class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods for different message types
|
// Convenience methods for different message types
|
||||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
public func info(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.info, data: logData)
|
writeMessage(.info, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
public func error(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.error, data: logData)
|
writeMessage(.error, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
public func debug(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.debug, data: logData)
|
writeMessage(.debug, data: logData)
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
|
|||||||
|
|
||||||
let valid = status == kAudioHardwareNoError && isAlive == 1
|
let valid = status == kAudioHardwareNoError && isAlive == 1
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Checked device validity",
|
"Checked device validity",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -63,7 +63,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
||||||
processObjects.append(processObject)
|
processObjects.append(processObject)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Translated PID to process object",
|
"Translated PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -71,7 +71,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
failedPIDs.append(pid)
|
failedPIDs.append(pid)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Failed to translate PID to process object",
|
"Failed to translate PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -87,11 +87,3 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
return processObjects
|
return processObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
|
||||||
func print(to fileHandle: FileHandle) {
|
|
||||||
if let data = (self + "\n").data(using: .utf8) {
|
|
||||||
fileHandle.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Binary output with JSON headers (pipe-optimised)
|
|
||||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
// Write raw binary audio data directly to stdout
|
|
||||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AudioTeeCore
|
||||||
|
|
||||||
|
final class AudioPacketTests: XCTestCase {
|
||||||
|
func testPacketCreation() {
|
||||||
|
let timestamp = Date()
|
||||||
|
let duration = 1.0
|
||||||
|
let data = Data([0x01, 0x02, 0x03, 0x04])
|
||||||
|
|
||||||
|
let packet = AudioPacket(
|
||||||
|
timestamp: timestamp,
|
||||||
|
duration: duration,
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(packet.timestamp, timestamp)
|
||||||
|
XCTAssertEqual(packet.duration, duration)
|
||||||
|
XCTAssertEqual(packet.data, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPacketDataSize() {
|
||||||
|
let packet = AudioPacket(
|
||||||
|
timestamp: Date(),
|
||||||
|
duration: 0.5,
|
||||||
|
data: Data(repeating: 0xFF, count: 1024)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(packet.data.count, 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user