Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1eb465142 | |||
| 65c2d58c82 | |||
| 85975d6cc3 | |||
| 1cd2e83060 | |||
| 4600e34bfb | |||
| 08f0bc8f6c | |||
| c4cf27553a | |||
| 087e4b1642 | |||
| 0b1ba5c8cd | |||
| b7455d26d1 | |||
| a311cc583f | |||
| 8b3de5918c | |||
| cf7bc88528 | |||
| 25f017f319 | |||
| 307eae147d | |||
| c9d11316ff | |||
| 71b0c45a5c | |||
| fd82e3d9d9 | |||
| d260c7824d | |||
| b3b11c8649 | |||
| 00985c0d33 | |||
| 1c94f55a9b | |||
| 2040510e9e | |||
| 3449a9bb9c | |||
| ac0ae46cfa | |||
| 8c3ee0f4e7 | |||
| abaa019bd2 | |||
| 98e33b7bcb | |||
| ee8968e2d6 | |||
| 4bc36019c2 | |||
| 1b537eb395 | |||
| 80d7555b60 | |||
| c696fa0197 | |||
| a0902f6eec | |||
| b7ce5e61d8 | |||
| d10f16a119 | |||
| a7be157d06 | |||
| bfce968fb6 | |||
| 95f11b17c7 | |||
| 756c1b3535 |
@@ -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
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "5f2b81278809343fed36ed8c17e7d6930bfd5b85261cdf5dadb17ab7ffdfc0e3",
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "swift-argument-parser",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-argument-parser.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
|
|
||||||
"version" : "1.5.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 3
|
|
||||||
}
|
|
||||||
+37
-17
@@ -4,21 +4,41 @@
|
|||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "audiotee",
|
name: "audiotee",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS("14.2")
|
.macOS("14.2")
|
||||||
],
|
],
|
||||||
dependencies: [
|
products: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
|
// Library that can be imported by other packages
|
||||||
],
|
.library(
|
||||||
targets: [
|
name: "AudioTeeCore",
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
targets: ["AudioTeeCore"]
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
),
|
||||||
.executableTarget(
|
// CLI executable
|
||||||
name: "audiotee",
|
.executable(
|
||||||
dependencies: [
|
name: "audiotee",
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
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"
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
# AudioTee
|
# AudioTee
|
||||||
|
|
||||||
AudioTee captures your Mac's system audio output and writes PCM encoded chunks of it to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
|
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
|
||||||
|
|
||||||
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not configurable) and preserves your output device's sample rate unless you pass a `--sample-rate` flag. Only the default output device is currently supported.
|
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can capture whatever's playing through your speakers to a file like this:
|
||||||
|
|
||||||
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
|
```bash
|
||||||
|
/path/to/audiotee > output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` and `Core/AudioRecorder`. Everything's wired together in `CLI/AudioTee`. The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
It's more likely you want to capture this output programmatically. Check out [AudioTee.js](https://github.com/makeusabrew/audioteejs) for a simple Node.js package which does this.
|
||||||
|
|
||||||
|
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - save it to disk, visualise it, transcribe it, etc.
|
||||||
|
|
||||||
|
By default, AudioTee captures audio output from **all** running processes. Tap output defaults to `mono` (configurable via the `--stereo` flag) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
|
||||||
|
|
||||||
|
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. I have internal variations (and, frankly, improvements) of AudioTee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
|
||||||
|
|
||||||
|
## Why?
|
||||||
|
|
||||||
|
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -16,12 +28,26 @@ Recording system audio is harder than it should be on macOS, and folks often wre
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
The following will start capturing audio output from all running programs and write 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
|
||||||
cd audiotee
|
cd audiotee
|
||||||
swift run
|
swift run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
More usefully, you can redirect `stdout` to a file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift run audiotee --sample-rate 16000 > output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
|
Which you can play back using something like `ffplay`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffplay -f s16le -ar 16000 output.pcm
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,20 +62,29 @@ swift build -c release
|
|||||||
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
|
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-detect output format (JSON in terminal, binary when piped)
|
# Write raw PCM audio to stdout (logs go to stderr)
|
||||||
./audiotee
|
./audiotee
|
||||||
|
|
||||||
# Always use JSON format (terminal-safe)
|
# Redirect audio to a file
|
||||||
./audiotee --format json
|
./audiotee > output.pcm
|
||||||
|
|
||||||
# Always use binary format (pipe-optimised)
|
# Pipe to another program
|
||||||
./audiotee --format binary
|
./audiotee | your_audio_processing_tool
|
||||||
|
|
||||||
|
# Redirect logs as well
|
||||||
|
./audiotee > captured_audio.pcm 2> audiotee.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audio conversion
|
### Audio conversion
|
||||||
|
|
||||||
|
Note that performing _any_ sample rate conversion will also convert the output bit depth to
|
||||||
|
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for a 50% reduction in output size. For ASR services, 16-bit is sufficient, but it's a non-obvious behaviour worth being aware of.
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
@@ -60,6 +95,8 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
|
|||||||
|
|
||||||
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
|
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
|
||||||
|
|
||||||
|
Note that trying to include or exclude a PID which isn't currently playing audio will probably fail to convert to an Audio Object and will cause the process to exit.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tap all system audio (default)
|
# Tap all system audio (default)
|
||||||
./audiotee
|
./audiotee
|
||||||
@@ -85,162 +122,51 @@ For now, only a subset of the `CATapDescription` (https://developer.apple.com/do
|
|||||||
./audiotee --chunk-duration 0.1
|
./audiotee --chunk-duration 0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output formats
|
## Output
|
||||||
|
|
||||||
AudioTee supports two output formats optimised for different use cases:
|
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`.
|
||||||
|
|
||||||
### JSON format (`--format json` or auto in terminal)
|
### Audio format
|
||||||
|
|
||||||
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
|
- **Format**: Raw PCM audio data
|
||||||
|
- **Channels**: 1 in Mono mode (default), 2 in stereo mode
|
||||||
|
- **Sample rate**: Matches your output device's sample rate by default (configurable)
|
||||||
|
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
|
||||||
|
- **Endianness**: Little-endian
|
||||||
|
- **Chunk duration**: 200ms by default (configurable)
|
||||||
|
|
||||||
### Binary format (`--format binary` or auto when piped)
|
### Logs and monitoring
|
||||||
|
|
||||||
JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes.
|
All program logs are written to `stderr` and can be captured separately:
|
||||||
|
|
||||||
## Protocol
|
```bash
|
||||||
|
# Capture audio and logs separately
|
||||||
|
./audiotee > audio.pcm 2> audiotee.log
|
||||||
|
|
||||||
### Message types
|
# View logs in real-time while capturing audio
|
||||||
|
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
|
||||||
All messages (except raw binary audio chunks) follow this envelope structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "...",
|
|
||||||
"data": { ... }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1. Metadata
|
|
||||||
|
|
||||||
Sent once at startup to describe the audio format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "metadata",
|
|
||||||
"data": {
|
|
||||||
"sample_rate": 48000,
|
|
||||||
"channels_per_frame": 1,
|
|
||||||
"bits_per_channel": 32,
|
|
||||||
"is_float": true,
|
|
||||||
"capture_mode": "audio",
|
|
||||||
"device_name": null,
|
|
||||||
"device_uid": null,
|
|
||||||
"encoding": "pcm_f32le"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Stream start
|
|
||||||
|
|
||||||
Indicates audio data will follow:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "stream_start",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Audio data
|
|
||||||
|
|
||||||
**JSON format:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "audio",
|
|
||||||
"data": {
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"duration": 0.2,
|
|
||||||
"peak_amplitude": 0.45,
|
|
||||||
"audio_data": "base64_encoded_raw_audio..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Binary format:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "audio",
|
|
||||||
"data": {
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"duration": 0.2,
|
|
||||||
"peak_amplitude": 0.45,
|
|
||||||
"audio_length": 9600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
_Followed immediately by 9600 bytes of raw binary audio data_
|
|
||||||
|
|
||||||
#### 4. Stream stop
|
|
||||||
|
|
||||||
Sent when recording stops:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "stream_stop",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Log messages
|
|
||||||
|
|
||||||
Info, error, and debug messages (useful for monitoring):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "info",
|
|
||||||
"data": {
|
|
||||||
"message": "Starting AudioTee...",
|
|
||||||
"context": { "output_format": "auto" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Consuming output
|
|
||||||
|
|
||||||
**JSON format:**
|
|
||||||
|
|
||||||
1. Parse each line as JSON using the envelope structure
|
|
||||||
2. Use `metadata` message to understand the audio format
|
|
||||||
3. For `audio` messages, decode `audio_data` from base64 to get raw PCM data
|
|
||||||
4. Do something with each chunk of data
|
|
||||||
|
|
||||||
**Binary format:**
|
|
||||||
|
|
||||||
1. Parse JSON metadata lines using the envelope structure
|
|
||||||
2. Use `metadata` message to understand the audio format
|
|
||||||
3. For `audio` messages, read `audio_length` bytes of raw binary data after the JSON line
|
|
||||||
4. Do something with each chunk of data
|
|
||||||
|
|
||||||
**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk.
|
|
||||||
|
|
||||||
## Command Line options
|
## Command Line options
|
||||||
|
|
||||||
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
|
|
||||||
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
||||||
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
||||||
- `--mute`: Mute processes being tapped
|
- `--mute`: Mute processes being tapped
|
||||||
|
- `--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).
|
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Error Types
|
||||||
|
|
||||||
|
enum ArgumentParserError: Error, CustomStringConvertible {
|
||||||
|
case unknownOption(String)
|
||||||
|
case missingValue(String)
|
||||||
|
case invalidValue(String, String)
|
||||||
|
case validationFailed(String)
|
||||||
|
case helpRequested
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .unknownOption(let option):
|
||||||
|
return "Unknown option: \(option)"
|
||||||
|
case .missingValue(let option):
|
||||||
|
return "Missing value for option: \(option)"
|
||||||
|
case .invalidValue(let option, let value):
|
||||||
|
return "Invalid value '\(value)' for option: \(option)"
|
||||||
|
case .validationFailed(let message):
|
||||||
|
return message
|
||||||
|
case .helpRequested:
|
||||||
|
return "" // Help is handled separately
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Argument Configuration
|
||||||
|
|
||||||
|
struct ArgumentConfig {
|
||||||
|
let name: String
|
||||||
|
let shortName: String?
|
||||||
|
let help: String
|
||||||
|
let isFlag: Bool
|
||||||
|
let isArray: Bool
|
||||||
|
let defaultValue: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
name: String, shortName: String? = nil, help: String, isFlag: Bool = false,
|
||||||
|
isArray: Bool = false, defaultValue: String? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.shortName = shortName
|
||||||
|
self.help = help
|
||||||
|
self.isFlag = isFlag
|
||||||
|
self.isArray = isArray
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Simple Argument Parser
|
||||||
|
|
||||||
|
class SimpleArgumentParser {
|
||||||
|
private let programName: String
|
||||||
|
private let abstract: String
|
||||||
|
private let discussion: String
|
||||||
|
private var configs: [ArgumentConfig] = []
|
||||||
|
private var parsedValues: [String: [String]] = [:]
|
||||||
|
|
||||||
|
init(programName: String, abstract: String, discussion: String = "") {
|
||||||
|
self.programName = programName
|
||||||
|
self.abstract = abstract
|
||||||
|
self.discussion = discussion
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOption(name: String, shortName: String? = nil, help: String, defaultValue: String? = nil)
|
||||||
|
{
|
||||||
|
configs.append(
|
||||||
|
ArgumentConfig(name: name, shortName: shortName, help: help, defaultValue: defaultValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addArrayOption(name: String, shortName: String? = nil, help: String) {
|
||||||
|
configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isArray: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFlag(name: String, shortName: String? = nil, help: String) {
|
||||||
|
configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isFlag: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(_ arguments: [String] = Array(CommandLine.arguments.dropFirst())) throws {
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while i < arguments.count {
|
||||||
|
let arg = arguments[i]
|
||||||
|
|
||||||
|
if arg == "--help" || arg == "-h" {
|
||||||
|
throw ArgumentParserError.helpRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
guard arg.hasPrefix("-") else {
|
||||||
|
throw ArgumentParserError.unknownOption(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
let optionName = findOptionName(arg)
|
||||||
|
guard let config = findConfig(optionName) else {
|
||||||
|
throw ArgumentParserError.unknownOption(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.isFlag {
|
||||||
|
parsedValues[config.name] = ["true"]
|
||||||
|
i += 1
|
||||||
|
} else {
|
||||||
|
// Need a value
|
||||||
|
i += 1
|
||||||
|
guard i < arguments.count else {
|
||||||
|
throw ArgumentParserError.missingValue(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.isArray {
|
||||||
|
// Collect all values until next option or end
|
||||||
|
var values: [String] = []
|
||||||
|
while i < arguments.count && !arguments[i].hasPrefix("-") {
|
||||||
|
values.append(arguments[i])
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
if values.isEmpty {
|
||||||
|
throw ArgumentParserError.missingValue(arg)
|
||||||
|
}
|
||||||
|
parsedValues[config.name] = values
|
||||||
|
} else {
|
||||||
|
let value = arguments[i]
|
||||||
|
parsedValues[config.name] = [value]
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values for missing options
|
||||||
|
for config in configs {
|
||||||
|
if parsedValues[config.name] == nil, let defaultValue = config.defaultValue {
|
||||||
|
parsedValues[config.name] = [defaultValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOptionName(_ arg: String) -> String {
|
||||||
|
if arg.hasPrefix("--") {
|
||||||
|
return String(arg.dropFirst(2))
|
||||||
|
} else if arg.hasPrefix("-") {
|
||||||
|
return String(arg.dropFirst(1))
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findConfig(_ optionName: String) -> ArgumentConfig? {
|
||||||
|
return configs.first { config in
|
||||||
|
config.name == optionName || config.shortName == optionName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue<T>(_ name: String, as type: T.Type) throws -> T {
|
||||||
|
guard let values = parsedValues[name], let value = values.first else {
|
||||||
|
throw ArgumentParserError.missingValue(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try convertValue(value, to: type, optionName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOptionalValue<T>(_ name: String, as type: T.Type) throws -> T? {
|
||||||
|
guard let values = parsedValues[name], let value = values.first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try convertValue(value, to: type, optionName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getArrayValue<T>(_ name: String, as type: T.Type) throws -> [T] {
|
||||||
|
guard let values = parsedValues[name] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return try values.map { try convertValue($0, to: type, optionName: name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFlag(_ name: String) -> Bool {
|
||||||
|
return parsedValues[name]?.first == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertValue<T>(_ value: String, to type: T.Type, optionName: String) throws -> T {
|
||||||
|
if type == String.self {
|
||||||
|
return value as! T
|
||||||
|
} else if type == Int32.self {
|
||||||
|
guard let intValue = Int32(value) else {
|
||||||
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
|
}
|
||||||
|
return intValue as! T
|
||||||
|
} else if type == Double.self {
|
||||||
|
guard let doubleValue = Double(value) else {
|
||||||
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
|
}
|
||||||
|
return doubleValue as! T
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHelp() {
|
||||||
|
print(abstract)
|
||||||
|
|
||||||
|
if !discussion.isEmpty {
|
||||||
|
print("\n\(discussion)")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\nUSAGE:")
|
||||||
|
print(" \(programName) [OPTIONS]")
|
||||||
|
|
||||||
|
let optionConfigs = configs.filter { !$0.isFlag }
|
||||||
|
let flagConfigs = configs.filter { $0.isFlag }
|
||||||
|
|
||||||
|
if !optionConfigs.isEmpty {
|
||||||
|
print("\nOPTIONS:")
|
||||||
|
for config in optionConfigs {
|
||||||
|
let shortName = config.shortName.map { "-\($0), " } ?? ""
|
||||||
|
let defaultDesc = config.defaultValue.map { " (default: \($0))" } ?? ""
|
||||||
|
print(" \(shortName)--\(config.name) \(config.help)\(defaultDesc)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flagConfigs.isEmpty {
|
||||||
|
print("\nFLAGS:")
|
||||||
|
for config in flagConfigs {
|
||||||
|
let shortName = config.shortName.map { "-\($0), " } ?? ""
|
||||||
|
print(" \(shortName)--\(config.name) \(config.help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n -h, --help Show this help message")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import AudioTeeCore
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AudioTee {
|
||||||
|
var includeProcesses: [Int32] = []
|
||||||
|
var excludeProcesses: [Int32] = []
|
||||||
|
var mute: Bool = false
|
||||||
|
var stereo: Bool = false
|
||||||
|
var sampleRate: Double?
|
||||||
|
var chunkDuration: Double = 0.2
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
static func main() {
|
||||||
|
let parser = SimpleArgumentParser(
|
||||||
|
programName: "audiotee",
|
||||||
|
abstract: "Capture system audio and stream to stdout",
|
||||||
|
discussion: """
|
||||||
|
AudioTee captures system audio using Core Audio taps and streams it as structured output.
|
||||||
|
|
||||||
|
Process filtering:
|
||||||
|
• include-processes: Only tap specified process IDs (empty = all processes)
|
||||||
|
• exclude-processes: Tap all processes except specified ones
|
||||||
|
• mute: How to handle processes being tapped
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
audiotee # Auto format, tap all processes
|
||||||
|
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
|
||||||
|
audiotee --sample-rate 8000 # Convert to 8kHz for telephony
|
||||||
|
audiotee --include-processes 1234 # Only tap process 1234
|
||||||
|
audiotee --include-processes 1234 5678 9012 # Tap only these processes
|
||||||
|
audiotee --exclude-processes 1234 5678 # Tap everything except these
|
||||||
|
audiotee --mute # Mute processes being tapped
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure arguments
|
||||||
|
parser.addArrayOption(
|
||||||
|
name: "include-processes",
|
||||||
|
help: "Process IDs to include (space-separated, empty = all processes)")
|
||||||
|
parser.addArrayOption(
|
||||||
|
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
||||||
|
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
||||||
|
parser.addFlag(name: "stereo", help: "Records in stereo")
|
||||||
|
parser.addOption(
|
||||||
|
name: "sample-rate",
|
||||||
|
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
||||||
|
parser.addOption(
|
||||||
|
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
do {
|
||||||
|
try parser.parse()
|
||||||
|
|
||||||
|
var audioTee = AudioTee()
|
||||||
|
|
||||||
|
// Extract values
|
||||||
|
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
||||||
|
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
|
||||||
|
audioTee.mute = parser.getFlag("mute")
|
||||||
|
audioTee.stereo = parser.getFlag("stereo")
|
||||||
|
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
||||||
|
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
try audioTee.validate()
|
||||||
|
|
||||||
|
// Run
|
||||||
|
try audioTee.run()
|
||||||
|
|
||||||
|
} catch ArgumentParserError.helpRequested {
|
||||||
|
parser.printHelp()
|
||||||
|
exit(0)
|
||||||
|
} catch ArgumentParserError.validationFailed(let message) {
|
||||||
|
print("Error: \(message)", to: &standardError)
|
||||||
|
exit(1)
|
||||||
|
} catch let error as ArgumentParserError {
|
||||||
|
print("Error: \(error.description)", to: &standardError)
|
||||||
|
parser.printHelp()
|
||||||
|
exit(1)
|
||||||
|
} catch {
|
||||||
|
print("Error: \(error)", to: &standardError)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
||||||
|
throw ArgumentParserError.validationFailed(
|
||||||
|
"Cannot specify both --include-processes and --exclude-processes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
setupSignalHandlers()
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
||||||
|
|
||||||
|
// Validate chunk duration
|
||||||
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Invalid chunk duration",
|
||||||
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
||||||
|
throw ExitCode.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert include/exclude processes to TapConfiguration format
|
||||||
|
let (processes, isExclusive) = convertProcessFlags()
|
||||||
|
|
||||||
|
let tapConfig = TapConfiguration(
|
||||||
|
processes: processes,
|
||||||
|
muteBehavior: mute ? .muted : .unmuted,
|
||||||
|
isExclusive: isExclusive,
|
||||||
|
isMono: !stereo
|
||||||
|
)
|
||||||
|
|
||||||
|
let audioTapManager = AudioTapManager()
|
||||||
|
do {
|
||||||
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
||||||
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Failed to translate process IDs to audio objects",
|
||||||
|
context: [
|
||||||
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
||||||
|
"suggestion": "Check that the process IDs exist and are running",
|
||||||
|
])
|
||||||
|
throw ExitCode.failure
|
||||||
|
} catch {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
||||||
|
throw ExitCode.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
||||||
|
throw ExitCode.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputHandler = BinaryAudioOutputHandler()
|
||||||
|
let recorder = try AudioRecorder(
|
||||||
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
|
chunkDuration: chunkDuration)
|
||||||
|
try recorder.startRecording()
|
||||||
|
|
||||||
|
// Run until the run loop is stopped (by signal handler)
|
||||||
|
while true {
|
||||||
|
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
|
||||||
|
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.info("Shutting down...")
|
||||||
|
recorder.stopRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSignalHandlers() {
|
||||||
|
signal(SIGINT) { _ in
|
||||||
|
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
|
||||||
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
|
}
|
||||||
|
signal(SIGTERM) { _ in
|
||||||
|
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
|
||||||
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertProcessFlags() -> ([Int32], Bool) {
|
||||||
|
if !includeProcesses.isEmpty {
|
||||||
|
// Include specific processes only
|
||||||
|
return (includeProcesses, false)
|
||||||
|
} else if !excludeProcesses.isEmpty {
|
||||||
|
// Exclude specific processes (tap everything except these)
|
||||||
|
return (excludeProcesses, true)
|
||||||
|
} else {
|
||||||
|
// Default: tap everything
|
||||||
|
return ([], true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for stderr output
|
||||||
|
var standardError = FileHandle.standardError
|
||||||
|
|
||||||
|
extension FileHandle: TextOutputStream {
|
||||||
|
public func write(_ string: String) {
|
||||||
|
let data = Data(string.utf8)
|
||||||
|
self.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit code handling
|
||||||
|
enum ExitCode: Error {
|
||||||
|
case failure
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExitCode {
|
||||||
|
var code: Int32 {
|
||||||
|
switch self {
|
||||||
|
case .failure:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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 fd = STDOUT_FILENO
|
||||||
|
|
||||||
|
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
|
||||||
|
var written = 0
|
||||||
|
while written < count {
|
||||||
|
let result = write(fd, pointer.advanced(by: written), count - written)
|
||||||
|
if result >= 0 {
|
||||||
|
written += result
|
||||||
|
} else if errno == EINTR {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
break // EPIPE, EIO, etc — consumer gone or real error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,4 +1,4 @@
|
|||||||
import ArgumentParser
|
import AudioTeeCore
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
|
||||||
|
///
|
||||||
|
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
|
||||||
|
/// copy-on-write reference-count checks on every mutation. This buffer
|
||||||
|
/// lives on the real-time audio IO thread and is never shared, so COW
|
||||||
|
/// semantics are pure overhead.
|
||||||
|
public class AudioBuffer {
|
||||||
|
/// Raw heap-allocated ring buffer backing store.
|
||||||
|
private let buffer: UnsafeMutableRawPointer
|
||||||
|
/// Pre-allocated buffer for linearizing chunks that straddle the ring
|
||||||
|
/// buffer boundary. Avoids a heap allocation on the wrap-around path.
|
||||||
|
private let linearizationBuffer: UnsafeMutableRawPointer
|
||||||
|
private var writeIndex: Int = 0
|
||||||
|
private var readIndex: Int = 0
|
||||||
|
private var availableBytes: Int = 0
|
||||||
|
private let maxBufferSize: Int
|
||||||
|
|
||||||
|
public let bytesPerChunk: Int
|
||||||
|
|
||||||
|
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||||
|
// Pre-calculate chunk parameters
|
||||||
|
let bytesPerFrame = Int(format.mBytesPerFrame)
|
||||||
|
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
||||||
|
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||||
|
|
||||||
|
// Calculate max buffer size to hold ~10 seconds of audio (safety limit)
|
||||||
|
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
||||||
|
self.maxBufferSize = bytesPerSecond * 10
|
||||||
|
|
||||||
|
// Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
|
||||||
|
// to eliminate Swift Array's COW ref-count check on every write/read.
|
||||||
|
self.buffer = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: maxBufferSize,
|
||||||
|
alignment: MemoryLayout<UInt8>.alignment
|
||||||
|
)
|
||||||
|
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
|
||||||
|
|
||||||
|
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: bytesPerChunk,
|
||||||
|
alignment: MemoryLayout<UInt8>.alignment
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
buffer.deallocate()
|
||||||
|
linearizationBuffer.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends audio data directly from a raw pointer into the ring buffer.
|
||||||
|
/// This is the fast path used by the IO proc callback: one memcpy from
|
||||||
|
/// the Core Audio buffer into our ring buffer, with no intermediate
|
||||||
|
/// Data allocation.
|
||||||
|
public func append(from source: UnsafeRawPointer, count: Int) {
|
||||||
|
guard count >= 0 else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Audio buffer append called with negative count",
|
||||||
|
context: ["count": String(count)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard availableBytes + count <= maxBufferSize else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Audio buffer overflow",
|
||||||
|
context: [
|
||||||
|
"requested": String(count),
|
||||||
|
"available": String(maxBufferSize - availableBytes),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if writeIndex + count <= maxBufferSize {
|
||||||
|
// Single contiguous write — no wrap-around needed
|
||||||
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
|
||||||
|
writeIndex = (writeIndex + count) % maxBufferSize
|
||||||
|
} else {
|
||||||
|
// Two writes needed due to wrap-around at the end of the ring buffer
|
||||||
|
let firstChunkSize = maxBufferSize - writeIndex
|
||||||
|
let secondChunkSize = count - firstChunkSize
|
||||||
|
|
||||||
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
|
||||||
|
buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
|
||||||
|
|
||||||
|
writeIndex = secondChunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
availableBytes += count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls `handler` once for each complete chunk available in the buffer.
|
||||||
|
/// The pointer passed to the handler is valid only for the duration of
|
||||||
|
/// that call. In the common (contiguous) case this points directly into
|
||||||
|
/// the ring buffer — zero copies. In the wrap-around case the chunk is
|
||||||
|
/// linearized into a pre-allocated scratch buffer — one memcpy, zero
|
||||||
|
/// heap allocations.
|
||||||
|
public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) {
|
||||||
|
while availableBytes >= bytesPerChunk {
|
||||||
|
if readIndex + bytesPerChunk <= maxBufferSize {
|
||||||
|
// Contiguous: point directly into the ring buffer
|
||||||
|
handler(buffer.advanced(by: readIndex), bytesPerChunk)
|
||||||
|
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
|
||||||
|
} else {
|
||||||
|
// Wrap-around: linearize into the pre-allocated scratch buffer
|
||||||
|
let firstChunkSize = maxBufferSize - readIndex
|
||||||
|
let secondChunkSize = bytesPerChunk - firstChunkSize
|
||||||
|
|
||||||
|
linearizationBuffer.copyMemory(
|
||||||
|
from: buffer.advanced(by: readIndex), byteCount: firstChunkSize)
|
||||||
|
linearizationBuffer.advanced(by: firstChunkSize).copyMemory(
|
||||||
|
from: buffer, byteCount: secondChunkSize)
|
||||||
|
|
||||||
|
handler(linearizationBuffer, bytesPerChunk)
|
||||||
|
readIndex = secondChunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
availableBytes -= bytesPerChunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Audio format converter using AVFoundation's AVAudioConverter.
|
||||||
|
///
|
||||||
|
/// Pre-allocates input/output buffers on first use and reuses them across
|
||||||
|
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
|
||||||
|
/// per chunk — significant when chunks are small (50ms = 20 calls/sec).
|
||||||
|
public class AudioFormatConverter {
|
||||||
|
private let avConverter: AVAudioConverter
|
||||||
|
private let sourceFormat: AVAudioFormat
|
||||||
|
private let targetFormat: AVAudioFormat
|
||||||
|
|
||||||
|
/// Pre-allocated buffers reused across transform() calls. Lazily created
|
||||||
|
/// on first transform() since we need the actual input frame count to
|
||||||
|
/// size them correctly.
|
||||||
|
private var cachedInputBuffer: AVAudioPCMBuffer?
|
||||||
|
private var cachedOutputBuffer: AVAudioPCMBuffer?
|
||||||
|
|
||||||
|
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
||||||
|
throws
|
||||||
|
{
|
||||||
|
var mutableSourceFormat = sourceFormat
|
||||||
|
var mutableTargetFormat = targetFormat
|
||||||
|
|
||||||
|
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
||||||
|
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
||||||
|
else {
|
||||||
|
throw AudioConverterError.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
||||||
|
throw AudioConverterError.creationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sourceFormat = sourceAVFormat
|
||||||
|
self.targetFormat = targetAVFormat
|
||||||
|
self.avConverter = converter
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Audio converter created",
|
||||||
|
context: [
|
||||||
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||||
|
"target_sample_rate": String(targetAVFormat.sampleRate),
|
||||||
|
"source_channels": String(sourceAVFormat.channelCount),
|
||||||
|
"target_channels": String(targetAVFormat.channelCount),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Warn about upsampling once during initialization
|
||||||
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||||
|
AudioTeeLogging.logger.info(
|
||||||
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||||
|
context: [
|
||||||
|
"source_rate": String(sourceAVFormat.sampleRate),
|
||||||
|
"target_rate": String(targetAVFormat.sampleRate),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return targetFormat.streamDescription.pointee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pre-allocated input and output buffers sized for the given
|
||||||
|
/// input frame count. Allocates once on first call; reuses on subsequent
|
||||||
|
/// calls when capacity is sufficient. Re-allocates if a larger frame
|
||||||
|
/// count arrives (shouldn't happen with fixed chunk sizes, but handled
|
||||||
|
/// gracefully).
|
||||||
|
private func getBuffers(inputFrameCount: AVAudioFrameCount)
|
||||||
|
-> (input: AVAudioPCMBuffer, output: AVAudioPCMBuffer)?
|
||||||
|
{
|
||||||
|
// ceil() prevents float-to-int truncation from undersizing the buffer
|
||||||
|
// by one frame (e.g. 3199.9999 → 3199 instead of 3200).
|
||||||
|
let outputFrameCount = AVAudioFrameCount(
|
||||||
|
ceil(Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reuse cached buffers if they have sufficient capacity
|
||||||
|
if let inputBuf = cachedInputBuffer,
|
||||||
|
let outputBuf = cachedOutputBuffer,
|
||||||
|
inputBuf.frameCapacity >= inputFrameCount,
|
||||||
|
outputBuf.frameCapacity >= outputFrameCount
|
||||||
|
{
|
||||||
|
// Reset frame lengths for reuse — the underlying memory is retained,
|
||||||
|
// we just tell AVAudioPCMBuffer how many frames are valid this time.
|
||||||
|
inputBuf.frameLength = 0
|
||||||
|
outputBuf.frameLength = 0
|
||||||
|
return (inputBuf, outputBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate new buffers (first call, or unexpected capacity increase)
|
||||||
|
guard
|
||||||
|
let inputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create input buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let outputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: targetFormat, frameCapacity: outputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create output buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for reuse on subsequent calls
|
||||||
|
cachedInputBuffer = inputBuf
|
||||||
|
cachedOutputBuffer = outputBuf
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Allocated converter buffers",
|
||||||
|
context: [
|
||||||
|
"input_frame_capacity": String(inputFrameCount),
|
||||||
|
"output_frame_capacity": String(outputFrameCount),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (inputBuf, outputBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts audio data in-place through the pre-allocated converter buffers.
|
||||||
|
/// Calls `handler` with a pointer to the converted output, valid only for
|
||||||
|
/// the duration of that call. Returns false on failure (caller should
|
||||||
|
/// pass through the original data or drop it).
|
||||||
|
@discardableResult
|
||||||
|
public func transform(
|
||||||
|
from source: UnsafeRawPointer, count: Int,
|
||||||
|
handler: (UnsafeRawPointer, Int) -> Void
|
||||||
|
) -> Bool {
|
||||||
|
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
|
let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame)
|
||||||
|
|
||||||
|
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy source data into the reusable input buffer
|
||||||
|
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
||||||
|
dest.copyMemory(from: source, byteCount: count)
|
||||||
|
inputBuffer.frameLength = inputFrameCount
|
||||||
|
|
||||||
|
// Perform conversion — we do NOT call avConverter.reset() between
|
||||||
|
// calls because the resampler maintains internal state for continuity
|
||||||
|
// across chunks (avoiding discontinuity artifacts).
|
||||||
|
var error: NSError?
|
||||||
|
|
||||||
|
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
||||||
|
requestedPackets, outStatus in
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return inputBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
guard outputBuffer.frameLength > 0 else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Audio conversion produced no output",
|
||||||
|
context: [
|
||||||
|
"status": String(describing: status),
|
||||||
|
"error": String(describing: error),
|
||||||
|
"input_frames": String(inputBuffer.frameLength),
|
||||||
|
"output_capacity": String(outputBuffer.frameCapacity),
|
||||||
|
])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputCount = Int(
|
||||||
|
outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
|
handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func toSampleRate(
|
||||||
|
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
||||||
|
) throws -> AudioFormatConverter {
|
||||||
|
var targetFormat = AudioStreamBasicDescription()
|
||||||
|
targetFormat.mSampleRate = sampleRate
|
||||||
|
targetFormat.mFormatID = kAudioFormatLinearPCM
|
||||||
|
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
||||||
|
targetFormat.mFramesPerPacket = 1
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||||
|
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class AudioFormatManager {
|
||||||
|
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription
|
||||||
|
{
|
||||||
|
// First, wait for the device to become alive/ready
|
||||||
|
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||||
|
let pollInterval = 0.1 // 100ms poll interval
|
||||||
|
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||||
|
|
||||||
|
// Poll device readiness
|
||||||
|
for poll in 1...maxPolls {
|
||||||
|
if isAudioDeviceValid(deviceID) {
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if poll == maxPolls {
|
||||||
|
AudioTeeLogging.logger.info(
|
||||||
|
"Device did not become ready within timeout, proceeding anyway",
|
||||||
|
context: [
|
||||||
|
"device_id": String(deviceID),
|
||||||
|
"timeout_seconds": String(deviceReadyTimeout),
|
||||||
|
])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.info("------- not ready; retrying...")
|
||||||
|
|
||||||
|
Thread.sleep(forTimeInterval: pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now attempt to get the stream format with limited retries
|
||||||
|
let maxRetries = 3 // Reduced since device should be ready
|
||||||
|
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
|
||||||
|
|
||||||
|
for attempt in 1...maxRetries {
|
||||||
|
var propertyAddress = getPropertyAddress(
|
||||||
|
selector: kAudioDevicePropertyStreamFormat,
|
||||||
|
scope: kAudioDevicePropertyScopeInput)
|
||||||
|
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
||||||
|
var streamFormat = AudioStreamBasicDescription()
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
|
if status == noErr {
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Successfully retrieved device format", context: ["attempt": String(attempt)])
|
||||||
|
return streamFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.info(
|
||||||
|
"------- Failed to get stream format after device ready check, retrying...",
|
||||||
|
context: [
|
||||||
|
"attempt": String(attempt),
|
||||||
|
"max_retries": String(maxRetries),
|
||||||
|
"status": String(status),
|
||||||
|
"device_id": String(deviceID),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Don't delay on the last attempt
|
||||||
|
if attempt < maxRetries {
|
||||||
|
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Failed to get device format after device readiness check and retries",
|
||||||
|
context: [
|
||||||
|
"device_id": String(deviceID),
|
||||||
|
"device_was_ready": "true",
|
||||||
|
])
|
||||||
|
|
||||||
|
throw AudioTeeError.deviceFormatUnavailable(deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||||
|
return AudioStreamMetadata(
|
||||||
|
sampleRate: format.mSampleRate,
|
||||||
|
channelsPerFrame: format.mChannelsPerFrame,
|
||||||
|
bitsPerChannel: format.mBitsPerChannel,
|
||||||
|
isFloat: format.mFormatFlags & kAudioFormatFlagIsFloat != 0,
|
||||||
|
captureMode: "audio",
|
||||||
|
deviceName: nil, // TODO: Get device name if needed
|
||||||
|
deviceUID: nil, // TODO: Get device UID if needed
|
||||||
|
encoding: format.mFormatFlags & kAudioFormatFlagIsFloat != 0 ? "pcm_f32le" : "pcm_s16le"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Using device's native format",
|
||||||
|
context: [
|
||||||
|
"channels": String(format.mChannelsPerFrame),
|
||||||
|
"sample_rate": String(format.mSampleRate),
|
||||||
|
"bits_per_channel": String(format.mBitsPerChannel),
|
||||||
|
"format_id": String(format.mFormatID),
|
||||||
|
"format_flags": String(format: "0x%08x", format.mFormatFlags),
|
||||||
|
"bytes_per_frame": String(format.mBytesPerFrame),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,29 +5,39 @@ 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 +47,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,31 +62,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: note to self, what about installTap? Would require audio engine and a node?
|
// Note to self, what about installTap? Would require audio engine and a node?
|
||||||
private func setupAndStartIOProc() {
|
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||||
Logger.debug("Creating IO proc")
|
private func setupAndStartIOProc() throws {
|
||||||
|
AudioTeeLogging.logger.debug("Creating IO proc")
|
||||||
var status = AudioDeviceCreateIOProcID(
|
var status = AudioDeviceCreateIOProcID(
|
||||||
deviceID,
|
deviceID,
|
||||||
{
|
{
|
||||||
@@ -90,15 +93,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,35 +109,43 @@ public class AudioRecorder {
|
|||||||
let bufferList = inputData.pointee
|
let bufferList = inputData.pointee
|
||||||
let firstBuffer = bufferList.mBuffers
|
let firstBuffer = bufferList.mBuffers
|
||||||
|
|
||||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
|
||||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
AudioTeeLogging.logger.error("Received empty audio buffer")
|
||||||
return noErr
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append raw audio data to buffer
|
// Copy directly from the Core Audio buffer into our ring buffer.
|
||||||
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
// This avoids creating an intermediate Data object (heap alloc + memcpy)
|
||||||
audioBuffer?.append(audioData)
|
// on every IO callback (~10ms). The pointer is valid for the duration
|
||||||
|
// of this callback, so this is safe.
|
||||||
|
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
|
||||||
|
|
||||||
// Process and send complete chunks, applying conversion if needed
|
processAudioBuffer()
|
||||||
audioBuffer?.processChunks().forEach { packet in
|
|
||||||
let processedPacket = converter?.transform(packet) ?? packet
|
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
return noErr
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
public func stopRecording() {
|
||||||
// Send any remaining buffered audio, applying conversion if needed
|
processAudioBuffer()
|
||||||
if let finalPacket = audioBuffer?.flushRemaining() {
|
|
||||||
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
|
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputHandler.handleStreamStop()
|
outputHandler.handleStreamStop()
|
||||||
cleanupIOProc()
|
cleanupIOProc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func processAudioBuffer() {
|
||||||
|
audioBuffer?.processChunks { pointer, count in
|
||||||
|
if let converter = self.converter {
|
||||||
|
if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
|
||||||
|
self.outputHandler.handleAudioData(outPtr, count: outCount)
|
||||||
|
}) {
|
||||||
|
// Conversion failed — pass through unconverted audio
|
||||||
|
self.outputHandler.handleAudioData(pointer, count: count)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.outputHandler.handleAudioData(pointer, count: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func cleanupIOProc() {
|
private func cleanupIOProc() {
|
||||||
if let ioProcID = ioProcID {
|
if let ioProcID = ioProcID {
|
||||||
AudioDeviceStop(deviceID, ioProcID)
|
AudioDeviceStop(deviceID, ioProcID)
|
||||||
+20
-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,48 @@ 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 +89,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 +116,8 @@ 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 +144,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,7 +1,6 @@
|
|||||||
import ArgumentParser
|
|
||||||
import CoreAudio
|
import CoreAudio
|
||||||
|
|
||||||
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
|
public enum TapMuteBehavior: String, CaseIterable {
|
||||||
case unmuted = "unmuted"
|
case unmuted = "unmuted"
|
||||||
case muted = "muted"
|
case muted = "muted"
|
||||||
|
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol for handling audio output in different formats
|
/// Protocol for handling audio output in different formats
|
||||||
public protocol AudioOutputHandler {
|
public protocol AudioOutputHandler {
|
||||||
func handleAudioPacket(_ packet: AudioPacket)
|
/// Called with a pointer to raw PCM audio data. The pointer is only
|
||||||
|
/// valid for the duration of this call.
|
||||||
|
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int)
|
||||||
func handleMetadata(_ metadata: AudioStreamMetadata)
|
func handleMetadata(_ metadata: AudioStreamMetadata)
|
||||||
func handleStreamStart()
|
func handleStreamStart()
|
||||||
func handleStreamStop()
|
func handleStreamStop()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Default logger implementation that writes JSON messages to stderr.
|
||||||
|
/// This is the CLI-appropriate logger; library consumers can replace it
|
||||||
|
/// via AudioTeeLogging.logger.
|
||||||
|
public class StderrJSONLogger: AudioTeeLogger {
|
||||||
|
private let dateFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [
|
||||||
|
.withInternetDateTime,
|
||||||
|
.withFractionalSeconds,
|
||||||
|
]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let jsonEncoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
// Configured in init because stored property initializers can't
|
||||||
|
// reference other instance properties (self.dateFormatter).
|
||||||
|
jsonEncoder.dateEncodingStrategy = .custom { [dateFormatter] date, encoder in
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(dateFormatter.string(from: date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any message with the unified envelope to stderr
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {
|
||||||
|
let message = Message(type: type, data: data)
|
||||||
|
do {
|
||||||
|
let jsonData = try jsonEncoder.encode(message)
|
||||||
|
FileHandle.standardError.write(jsonData)
|
||||||
|
FileHandle.standardError.write("\n".data(using: .utf8)!)
|
||||||
|
} catch {
|
||||||
|
// TODO: handle at some point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods for different message types
|
||||||
|
public func info(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.info, data: logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.error, data: logData)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func debug(_ message: String, context: [String: String]? = nil) {
|
||||||
|
let logData = LogData(message: message, context: context)
|
||||||
|
writeMessage(.debug, data: logData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,161 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct AudioTee: ParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(
|
|
||||||
abstract: "Capture system audio and stream to stdout",
|
|
||||||
discussion: """
|
|
||||||
AudioTee captures system audio using Core Audio taps and streams it as structured output.
|
|
||||||
|
|
||||||
Output formats:
|
|
||||||
• json: Base64-encoded audio in JSON messages (safe for terminals)
|
|
||||||
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
|
|
||||||
• auto: Automatically choose based on whether stdout is a terminal (default)
|
|
||||||
|
|
||||||
Process filtering:
|
|
||||||
• include-processes: Only tap specified process IDs (empty = all processes)
|
|
||||||
• exclude-processes: Tap all processes except specified ones
|
|
||||||
• mute: How to handle processes being tapped
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
audiotee # Auto format, tap all processes
|
|
||||||
audiotee --format=json # Always use JSON format
|
|
||||||
audiotee --format=binary # Always use binary format
|
|
||||||
audiotee --sample-rate=16000 # Convert to 16kHz mono for ASR
|
|
||||||
audiotee --sample-rate=8000 # Convert to 8kHz for telephony
|
|
||||||
audiotee --include-processes 1234 # Only tap process 1234
|
|
||||||
audiotee --include-processes 1234 5678 9012 # Tap only these processes
|
|
||||||
audiotee --exclude-processes 1234 5678 # Tap everything except these
|
|
||||||
audiotee --mute # Mute processes being tapped
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
@Option(name: .shortAndLong, help: "Output format")
|
|
||||||
var format: OutputFormat = .auto
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
name: .long, help: "Process IDs to include (space-separated, empty = all processes)")
|
|
||||||
var includeProcesses: [Int32] = []
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
name: .long, help: "Process IDs to exclude (space-separated)")
|
|
||||||
var excludeProcesses: [Int32] = []
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Mute processes being tapped")
|
|
||||||
var mute: Bool = false
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
name: .long,
|
|
||||||
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
|
||||||
var sampleRate: Double?
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
name: .long,
|
|
||||||
help: "Audio chunk duration in seconds (default: 0.2)")
|
|
||||||
var chunkDuration: Double = 0.2
|
|
||||||
|
|
||||||
func validate() throws {
|
|
||||||
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
|
||||||
throw ValidationError("Cannot specify both --include-processes and --exclude-processes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() throws {
|
|
||||||
setupSignalHandlers()
|
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
|
||||||
Logger.debug("Using output format: \(format)")
|
|
||||||
|
|
||||||
// Validate chunk duration
|
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
|
||||||
Logger.error(
|
|
||||||
"Invalid chunk duration",
|
|
||||||
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
|
||||||
throw ExitCode.failure
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert include/exclude processes to TapConfiguration format
|
|
||||||
let (processes, isExclusive) = convertProcessFlags()
|
|
||||||
|
|
||||||
let tapConfig = TapConfiguration(
|
|
||||||
processes: processes,
|
|
||||||
muteBehavior: mute ? .muted : .unmuted,
|
|
||||||
isExclusive: isExclusive
|
|
||||||
)
|
|
||||||
|
|
||||||
let audioTapManager = AudioTapManager()
|
|
||||||
do {
|
|
||||||
try audioTapManager.setupAudioTap(with: tapConfig)
|
|
||||||
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
|
||||||
Logger.error(
|
|
||||||
"Failed to translate process IDs to audio objects",
|
|
||||||
context: [
|
|
||||||
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
|
||||||
"suggestion": "Check that the process IDs exist and are running",
|
|
||||||
])
|
|
||||||
throw ExitCode.failure
|
|
||||||
} catch {
|
|
||||||
Logger.error(
|
|
||||||
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
|
||||||
throw ExitCode.failure
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let deviceID = audioTapManager.getDeviceID() else {
|
|
||||||
Logger.error("Failed to get device ID from audio tap manager")
|
|
||||||
throw ExitCode.failure
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputHandler = createOutputHandler(for: format)
|
|
||||||
let recorder = AudioRecorder(
|
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
|
||||||
chunkDuration: chunkDuration)
|
|
||||||
recorder.startRecording()
|
|
||||||
|
|
||||||
// Run until the run loop is stopped (by signal handler)
|
|
||||||
while true {
|
|
||||||
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
|
|
||||||
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("Shutting down...")
|
|
||||||
recorder.stopRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupSignalHandlers() {
|
|
||||||
signal(SIGINT) { _ in
|
|
||||||
Logger.info("Received SIGINT, initiating graceful shutdown...")
|
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
|
||||||
}
|
|
||||||
signal(SIGTERM) { _ in
|
|
||||||
Logger.info("Received SIGTERM, initiating graceful shutdown...")
|
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
|
|
||||||
switch format {
|
|
||||||
case .json:
|
|
||||||
return JSONAudioOutputHandler()
|
|
||||||
case .binary:
|
|
||||||
return BinaryAudioOutputHandler()
|
|
||||||
case .auto:
|
|
||||||
return AutoAudioOutputHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func convertProcessFlags() -> ([Int32], Bool) {
|
|
||||||
if !includeProcesses.isEmpty {
|
|
||||||
// Include specific processes only
|
|
||||||
return (includeProcesses, false)
|
|
||||||
} else if !excludeProcesses.isEmpty {
|
|
||||||
// Exclude specific processes (tap everything except these)
|
|
||||||
return (excludeProcesses, true)
|
|
||||||
} else {
|
|
||||||
// Default: tap everything
|
|
||||||
return ([], true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
|
|
||||||
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
|
|
||||||
case json = "json"
|
|
||||||
case binary = "binary"
|
|
||||||
case auto = "auto"
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .json:
|
|
||||||
return "Base64-encoded JSON (terminal-safe)"
|
|
||||||
case .binary:
|
|
||||||
return "Binary with JSON headers (pipe-optimised)"
|
|
||||||
case .auto:
|
|
||||||
return "Auto-detect based on TTY (default)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class AudioBuffer {
|
|
||||||
private var buffer = Data()
|
|
||||||
private let targetChunkDuration: Double
|
|
||||||
private let streamFormat: AudioStreamBasicDescription
|
|
||||||
|
|
||||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
|
||||||
self.streamFormat = format
|
|
||||||
self.targetChunkDuration = chunkDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
public func append(_ data: Data) {
|
|
||||||
buffer.append(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func processChunks() -> [AudioPacket] {
|
|
||||||
var packets: [AudioPacket] = []
|
|
||||||
|
|
||||||
while let packet = nextChunk() {
|
|
||||||
packets.append(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
return packets
|
|
||||||
}
|
|
||||||
|
|
||||||
public func flushRemaining() -> AudioPacket? {
|
|
||||||
guard !buffer.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: 0.0, // Unknown duration for final chunk
|
|
||||||
peakAmplitude: 0.0,
|
|
||||||
rawAudioData: buffer
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer.removeAll()
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nextChunk() -> AudioPacket? {
|
|
||||||
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
|
|
||||||
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
|
|
||||||
let bytesPerChunk = samplesPerChunk * bytesPerFrame
|
|
||||||
|
|
||||||
guard buffer.count >= bytesPerChunk else { return nil }
|
|
||||||
|
|
||||||
let chunkData = buffer.prefix(bytesPerChunk)
|
|
||||||
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
|
|
||||||
peakAmplitude: 0.0, // No analysis in raw mode
|
|
||||||
rawAudioData: Data(chunkData)
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer.removeFirst(bytesPerChunk)
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Simple audio format converter using AVFoundation
|
|
||||||
public class AudioFormatConverter {
|
|
||||||
private let avConverter: AVAudioConverter
|
|
||||||
private let sourceFormat: AVAudioFormat
|
|
||||||
private let targetFormat: AVAudioFormat
|
|
||||||
|
|
||||||
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
|
||||||
throws
|
|
||||||
{
|
|
||||||
var mutableSourceFormat = sourceFormat
|
|
||||||
var mutableTargetFormat = targetFormat
|
|
||||||
|
|
||||||
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
|
||||||
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
|
||||||
else {
|
|
||||||
throw AudioConverterError.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
|
||||||
throw AudioConverterError.creationFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sourceFormat = sourceAVFormat
|
|
||||||
self.targetFormat = targetAVFormat
|
|
||||||
self.avConverter = converter
|
|
||||||
|
|
||||||
Logger.debug(
|
|
||||||
"Audio converter created",
|
|
||||||
context: [
|
|
||||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
|
||||||
"target_sample_rate": String(targetAVFormat.sampleRate),
|
|
||||||
"source_channels": String(sourceAVFormat.channelCount),
|
|
||||||
"target_channels": String(targetAVFormat.channelCount),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Warn about upsampling once during initialization
|
|
||||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
|
||||||
Logger.info(
|
|
||||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
|
||||||
context: [
|
|
||||||
"source_rate": String(sourceAVFormat.sampleRate),
|
|
||||||
"target_rate": String(targetAVFormat.sampleRate),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the target format as AudioStreamBasicDescription
|
|
||||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
|
||||||
return targetFormat.streamDescription.pointee
|
|
||||||
}
|
|
||||||
|
|
||||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
|
||||||
let inputData = packet.rawAudioData
|
|
||||||
|
|
||||||
// Short-circuit if no conversion needed
|
|
||||||
if sourceFormat.sampleRate == targetFormat.sampleRate {
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate frame counts
|
|
||||||
let inputFrameCount =
|
|
||||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
|
||||||
let outputFrameCount = Int(
|
|
||||||
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
|
||||||
|
|
||||||
// Create input buffer
|
|
||||||
guard
|
|
||||||
let inputBuffer = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
|
||||||
else {
|
|
||||||
Logger.error("Failed to create input buffer")
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy input data to buffer
|
|
||||||
inputData.withUnsafeBytes { bytes in
|
|
||||||
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
|
||||||
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
|
|
||||||
}
|
|
||||||
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
|
|
||||||
|
|
||||||
// Create output buffer
|
|
||||||
guard
|
|
||||||
let outputBuffer = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
|
||||||
else {
|
|
||||||
Logger.error("Failed to create output buffer")
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform conversion - simpler approach
|
|
||||||
var error: NSError?
|
|
||||||
|
|
||||||
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
|
||||||
requestedPackets, outStatus in
|
|
||||||
// Always provide our input buffer and let converter manage it
|
|
||||||
outStatus.pointee = .haveData
|
|
||||||
return inputBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if conversion produced output (regardless of status code)
|
|
||||||
guard outputBuffer.frameLength > 0 else {
|
|
||||||
Logger.error(
|
|
||||||
"Audio conversion produced no output",
|
|
||||||
context: [
|
|
||||||
"status": String(describing: status),
|
|
||||||
"error": String(describing: error),
|
|
||||||
"input_frames": String(inputBuffer.frameLength),
|
|
||||||
"output_capacity": String(outputBuffer.frameCapacity),
|
|
||||||
])
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract converted data
|
|
||||||
let outputData = Data(
|
|
||||||
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
|
|
||||||
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
|
|
||||||
|
|
||||||
// Return new packet with converted audio (keeping original metadata for simplicity)
|
|
||||||
return AudioPacket(
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
duration: packet.duration,
|
|
||||||
peakAmplitude: packet.peakAmplitude,
|
|
||||||
rawAudioData: outputData
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Constructors
|
|
||||||
|
|
||||||
extension AudioFormatConverter {
|
|
||||||
/// Create a converter to a specific sample rate with mono PCM 16-bit output
|
|
||||||
/// Since the tap already converts to mono, we hardcode channels to 1
|
|
||||||
public static func toSampleRate(
|
|
||||||
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
|
||||||
) throws -> AudioFormatConverter {
|
|
||||||
var targetFormat = AudioStreamBasicDescription()
|
|
||||||
targetFormat.mSampleRate = sampleRate
|
|
||||||
targetFormat.mFormatID = kAudioFormatLinearPCM
|
|
||||||
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
|
||||||
targetFormat.mBytesPerPacket = 2
|
|
||||||
targetFormat.mFramesPerPacket = 1
|
|
||||||
targetFormat.mBytesPerFrame = 2
|
|
||||||
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
|
|
||||||
targetFormat.mBitsPerChannel = 16
|
|
||||||
|
|
||||||
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Common sample rates for validation
|
|
||||||
public static let supportedSampleRates: [Double] = [
|
|
||||||
8000, 16000, 22050, 24000, 32000, 44100, 48000,
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Validate if a sample rate is supported
|
|
||||||
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
|
||||||
return supportedSampleRates.contains(sampleRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error Types
|
|
||||||
|
|
||||||
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import AudioToolbox
|
|
||||||
import CoreAudio
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class AudioFormatManager {
|
|
||||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
|
||||||
var propertyAddress = getPropertyAddress(
|
|
||||||
selector: kAudioDevicePropertyStreamFormat,
|
|
||||||
scope: kAudioDevicePropertyScopeInput)
|
|
||||||
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
|
||||||
var streamFormat = AudioStreamBasicDescription()
|
|
||||||
let status = AudioObjectGetPropertyData(
|
|
||||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
|
||||||
|
|
||||||
guard status == noErr else {
|
|
||||||
fatalError("Failed to get stream format: \(status)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return streamFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
|
||||||
return AudioStreamMetadata(
|
|
||||||
sampleRate: format.mSampleRate,
|
|
||||||
channelsPerFrame: format.mChannelsPerFrame,
|
|
||||||
bitsPerChannel: format.mBitsPerChannel,
|
|
||||||
isFloat: format.mFormatFlags & kAudioFormatFlagIsFloat != 0,
|
|
||||||
captureMode: "audio",
|
|
||||||
deviceName: nil, // TODO: Get device name if needed
|
|
||||||
deviceUID: nil, // TODO: Get device UID if needed
|
|
||||||
encoding: format.mFormatFlags & kAudioFormatFlagIsFloat != 0 ? "pcm_f32le" : "pcm_s16le"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
Logger.debug(
|
|
||||||
"Using device's native format",
|
|
||||||
context: [
|
|
||||||
"channels": String(format.mChannelsPerFrame),
|
|
||||||
"sample_rate": String(format.mSampleRate),
|
|
||||||
"bits_per_channel": String(format.mBitsPerChannel),
|
|
||||||
"format_id": String(format.mFormatID),
|
|
||||||
"format_flags": String(format: "0x%08x", format.mFormatFlags),
|
|
||||||
"bytes_per_frame": String(format.mBytesPerFrame),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct AudioPacket {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float // useful for level monitoring
|
|
||||||
public let rawAudioData: Data
|
|
||||||
|
|
||||||
public init(
|
|
||||||
timestamp: Date,
|
|
||||||
duration: Double,
|
|
||||||
peakAmplitude: Float,
|
|
||||||
rawAudioData: Data
|
|
||||||
) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.duration = duration
|
|
||||||
self.peakAmplitude = peakAmplitude
|
|
||||||
self.rawAudioData = rawAudioData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Auto-detecting output handler based on TTY
|
|
||||||
public class AutoAudioOutputHandler: AudioOutputHandler {
|
|
||||||
private let handler: AudioOutputHandler
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
// Auto-detect based on whether stdout is a terminal
|
|
||||||
if isatty(STDOUT_FILENO) != 0 {
|
|
||||||
handler = JSONAudioOutputHandler()
|
|
||||||
} else {
|
|
||||||
handler = BinaryAudioOutputHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
handler.handleAudioPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
handler.handleMetadata(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
handler.handleStreamStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
handler.handleStreamStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Binary output with JSON headers (pipe-optimised)
|
|
||||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
// Create metadata without the audio data
|
|
||||||
let metadata = BinaryPacketHeader(from: packet)
|
|
||||||
|
|
||||||
// Write JSON metadata line
|
|
||||||
Logger.writeMessage(.audio, data: metadata)
|
|
||||||
|
|
||||||
// Write raw binary audio data directly to stdout
|
|
||||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Base64-encoded JSON output (terminal-safe)
|
|
||||||
public class JSONAudioOutputHandler: AudioOutputHandler {
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
let jsonPacket = JSONAudioPacket(from: packet)
|
|
||||||
Logger.writeMessage(.audio, data: jsonPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// JSON-serializable version of AudioPacket with base64-encoded audio data
|
|
||||||
public struct JSONAudioPacket: Codable {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float
|
|
||||||
public let audioData: String // base64 encoded audio data
|
|
||||||
|
|
||||||
public enum CodingKeys: String, CodingKey {
|
|
||||||
case timestamp
|
|
||||||
case duration
|
|
||||||
case peakAmplitude = "peak_amplitude"
|
|
||||||
case audioData = "audio_data"
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from packet: AudioPacket) {
|
|
||||||
self.timestamp = packet.timestamp
|
|
||||||
self.duration = packet.duration
|
|
||||||
self.peakAmplitude = packet.peakAmplitude
|
|
||||||
self.audioData = packet.rawAudioData.base64EncodedString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata-only packet for binary output (without base64 audio data)
|
|
||||||
public struct BinaryPacketHeader: Codable {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let peakAmplitude: Float
|
|
||||||
public let audioLength: Int // Length of raw audio data in bytes
|
|
||||||
|
|
||||||
public enum CodingKeys: String, CodingKey {
|
|
||||||
case timestamp
|
|
||||||
case duration
|
|
||||||
case peakAmplitude = "peak_amplitude"
|
|
||||||
case audioLength = "audio_length"
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from packet: AudioPacket) {
|
|
||||||
self.timestamp = packet.timestamp
|
|
||||||
self.duration = packet.duration
|
|
||||||
self.peakAmplitude = packet.peakAmplitude
|
|
||||||
self.audioLength = packet.rawAudioData.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public class Logger {
|
|
||||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [
|
|
||||||
.withInternetDateTime,
|
|
||||||
.withFractionalSeconds,
|
|
||||||
]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let jsonEncoder: JSONEncoder = {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(dateFormatter.string(from: date))
|
|
||||||
}
|
|
||||||
return encoder
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Write any message with the unified envelope
|
|
||||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
|
||||||
let message = Message(type: type, data: data)
|
|
||||||
do {
|
|
||||||
let jsonData = try jsonEncoder.encode(message)
|
|
||||||
FileHandle.standardOutput.write(jsonData)
|
|
||||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
|
||||||
} catch {
|
|
||||||
// TODO: handle at some point
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for different message types
|
|
||||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.info, data: logData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.error, data: logData)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
|
||||||
let logData = LogData(message: message, context: context)
|
|
||||||
writeMessage(.debug, data: logData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import CoreAudio
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import AudioTeeCore
|
||||||
|
|
||||||
|
// CoreAudio defines its own AudioBuffer struct, which collides with ours.
|
||||||
|
// Explicit module qualification avoids ambiguity in tests that import both.
|
||||||
|
private typealias AudioBuffer = AudioTeeCore.AudioBuffer
|
||||||
|
|
||||||
|
final class AudioBufferTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Creates a minimal AudioStreamBasicDescription for testing.
|
||||||
|
/// 16kHz, 16-bit, mono = 2 bytes per frame, 32000 bytes/sec.
|
||||||
|
private func makeFormat(
|
||||||
|
sampleRate: Double = 16000,
|
||||||
|
bytesPerFrame: UInt32 = 2,
|
||||||
|
bitsPerChannel: UInt32 = 16
|
||||||
|
) -> AudioStreamBasicDescription {
|
||||||
|
return AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate,
|
||||||
|
mFormatID: kAudioFormatLinearPCM,
|
||||||
|
mFormatFlags: kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger,
|
||||||
|
mBytesPerPacket: bytesPerFrame,
|
||||||
|
mFramesPerPacket: 1,
|
||||||
|
mBytesPerFrame: bytesPerFrame,
|
||||||
|
mChannelsPerFrame: 1,
|
||||||
|
mBitsPerChannel: bitsPerChannel,
|
||||||
|
mReserved: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a repeating byte pattern of the given length.
|
||||||
|
private func makeData(byte: UInt8, count: Int) -> Data {
|
||||||
|
return Data(repeating: byte, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends Data to an AudioBuffer via the raw pointer path,
|
||||||
|
/// matching how processAudio() calls append(from:count:).
|
||||||
|
private func appendData(_ data: Data, to buffer: AudioBuffer) {
|
||||||
|
data.withUnsafeBytes { bytes in
|
||||||
|
buffer.append(from: bytes.baseAddress!, count: bytes.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects chunks from the buffer as Data objects for test verification.
|
||||||
|
private func collectChunks(from buffer: AudioBuffer) -> [Data] {
|
||||||
|
var chunks: [Data] = []
|
||||||
|
buffer.processChunks { pointer, count in
|
||||||
|
chunks.append(Data(bytes: pointer, count: count))
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Basic append + processChunks
|
||||||
|
|
||||||
|
func testSingleChunkExtraction() {
|
||||||
|
// 16kHz, 2 bytes/frame, 0.1s chunk = 3200 bytes per chunk
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200 // 16000 * 0.1 * 2
|
||||||
|
|
||||||
|
let data = makeData(byte: 0xAB, count: chunkSize)
|
||||||
|
appendData(data, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
XCTAssertEqual(chunks[0], data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleChunksExtracted() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Append 2.5 chunks worth
|
||||||
|
appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
// Should get 2 complete chunks, remainder stays in buffer
|
||||||
|
XCTAssertEqual(chunks.count, 2)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
XCTAssertEqual(chunks[1].count, chunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInsufficientDataReturnsNoChunks() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Append less than one chunk
|
||||||
|
appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wrap-around
|
||||||
|
|
||||||
|
func testWrapAroundWrite() {
|
||||||
|
// 8kHz, 2 bytes/frame, 0.3s chunks → chunkSize = 4800, maxBuffer = 160000.
|
||||||
|
// 160000 / 4800 = 33.33 — chunks do NOT divide evenly into the buffer,
|
||||||
|
// so after enough writes the writeIndex will straddle the boundary.
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
|
||||||
|
let chunkSize = 4800 // 8000 * 0.3 * 2
|
||||||
|
|
||||||
|
// Write 33 chunks (158400 bytes), drain them all.
|
||||||
|
// writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary.
|
||||||
|
for _ in 0..<33 {
|
||||||
|
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
|
||||||
|
}
|
||||||
|
let drained = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(drained.count, 33)
|
||||||
|
|
||||||
|
// Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000.
|
||||||
|
// This MUST take the wrap-around else branch in append():
|
||||||
|
// firstChunkSize = 160000 - 158400 = 1600
|
||||||
|
// secondChunkSize = 4800 - 1600 = 3200
|
||||||
|
// Verify by using distinct byte patterns for the portion before and after the boundary.
|
||||||
|
var wrappingData = Data()
|
||||||
|
wrappingData.append(makeData(byte: 0xAA, count: 1600)) // fills to boundary
|
||||||
|
wrappingData.append(makeData(byte: 0xBB, count: 3200)) // wraps to start
|
||||||
|
XCTAssertEqual(wrappingData.count, chunkSize)
|
||||||
|
appendData(wrappingData, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0], wrappingData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWrapAroundRead() {
|
||||||
|
// Same setup as above: position readIndex so that a chunk extraction
|
||||||
|
// straddles the ring buffer boundary, exercising the else branch in nextChunk().
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
|
||||||
|
let chunkSize = 4800
|
||||||
|
|
||||||
|
// Write and drain 33 chunks. Both indices land at 158400.
|
||||||
|
for _ in 0..<33 {
|
||||||
|
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
|
||||||
|
}
|
||||||
|
_ = collectChunks(from: buffer)
|
||||||
|
|
||||||
|
// Write one chunk starting at 158400. The write itself wraps (tested above),
|
||||||
|
// but crucially the READ will also wrap: readIndex = 158400,
|
||||||
|
// 158400 + 4800 = 163200 > 160000 → else branch in nextChunk():
|
||||||
|
// firstChunkSize = 160000 - 158400 = 1600 (read from end of buffer)
|
||||||
|
// secondChunkSize = 4800 - 1600 = 3200 (read from start of buffer)
|
||||||
|
var crossBoundaryData = Data()
|
||||||
|
crossBoundaryData.append(makeData(byte: 0xCC, count: 1600))
|
||||||
|
crossBoundaryData.append(makeData(byte: 0xDD, count: 3200))
|
||||||
|
appendData(crossBoundaryData, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0], crossBoundaryData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overflow guard
|
||||||
|
|
||||||
|
func testOverflowPreventsWrite() {
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let maxBuffer = 160000
|
||||||
|
|
||||||
|
// Fill the buffer completely
|
||||||
|
appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer)
|
||||||
|
|
||||||
|
// Try to append more — should be silently rejected (overflow guard)
|
||||||
|
appendData(makeData(byte: 0x02, count: 100), to: buffer)
|
||||||
|
|
||||||
|
// Drain and verify we only got the original data
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
let totalBytes = chunks.reduce(0) { $0 + $1.count }
|
||||||
|
XCTAssertEqual(totalBytes, maxBuffer)
|
||||||
|
|
||||||
|
// Every byte should be 0x01, not 0x02
|
||||||
|
for chunk in chunks {
|
||||||
|
XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Incremental appends accumulate correctly
|
||||||
|
|
||||||
|
func testIncrementalAppendsThenChunk() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Simulate many small IO callbacks building up to one chunk
|
||||||
|
let callbackSize = 320 // 10 callbacks to fill one chunk
|
||||||
|
for i in 0..<10 {
|
||||||
|
appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
|
||||||
|
// Verify the data is in the correct order
|
||||||
|
for i in 0..<10 {
|
||||||
|
let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize))
|
||||||
|
XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chunk size
|
||||||
|
|
||||||
|
func testBytesPerChunkIsCorrect() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
|
||||||
|
// 16kHz * 0.1s * 2 bytes/frame = 3200
|
||||||
|
XCTAssertEqual(buffer.bytesPerChunk, 3200)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# AudioTee.js - Node.js Audio Streaming Package
|
|
||||||
|
|
||||||
This is a Node.js wrapper for AudioTee that provides a streaming interface to capture macOS system audio using Core Audio taps.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
- **Purpose**: Node.js package that wraps the AudioTee Swift binary via child processes
|
|
||||||
- **Target**: macOS 14.2+ with Node.js 14+
|
|
||||||
- **Use cases**: Real-time audio processing, ASR integration, Electron apps
|
|
||||||
- **Distribution**: npm package using node-pre-gyp for binary distribution
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- `index.js` - Main entry point, uses node-pre-gyp to locate binary
|
|
||||||
- `lib/AudioTeeStream.js` - Core streaming class that wraps AudioTee process
|
|
||||||
- `scripts/build.js` - Build script that copies AudioTee binary for distribution
|
|
||||||
- `test/test.js` - Test suite demonstrating usage
|
|
||||||
|
|
||||||
## Technical Standards
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Use functional programming patterns where possible
|
|
||||||
- Prefer `const` over `let`, avoid `var`
|
|
||||||
- Use arrow functions for callbacks and short functions
|
|
||||||
- No semicolons at end of lines (per user preference)
|
|
||||||
- Use template literals for string interpolation
|
|
||||||
- Prefer British English spelling (colour, realise, etc.)
|
|
||||||
|
|
||||||
### Node.js Specific
|
|
||||||
- Use EventEmitter pattern for streaming interfaces
|
|
||||||
- Handle child process lifecycle carefully (spawn, kill, cleanup)
|
|
||||||
- Use Buffer for binary data, not Uint8Array
|
|
||||||
- Implement proper error handling with descriptive messages
|
|
||||||
- Use readline interface for line-based protocol parsing
|
|
||||||
- Handle both JSON and binary protocol modes correctly
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Always emit errors via EventEmitter, don't throw synchronously
|
|
||||||
- Provide context in error messages (PIDs, file paths, etc.)
|
|
||||||
- Handle child process errors gracefully
|
|
||||||
- Validate input parameters and provide helpful error messages
|
|
||||||
|
|
||||||
### Protocol Implementation
|
|
||||||
- Correctly parse the mixed JSON/binary protocol from AudioTee
|
|
||||||
- Handle partial reads and buffer management for binary mode
|
|
||||||
- Emit events in the correct order (metadata → stream_start → audio → stream_stop)
|
|
||||||
- Preserve AudioTee's timestamp and metadata information
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- Minimize external dependencies (currently only node-pre-gyp)
|
|
||||||
- Use only Node.js built-in modules where possible
|
|
||||||
- Ensure compatibility with Node.js 14+ (no newer APIs)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Comprehensive JSDoc comments for public APIs
|
|
||||||
- Examples in README showing real-world usage patterns
|
|
||||||
- Clear event documentation with payload structure
|
|
||||||
- Error scenarios and troubleshooting guidance
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- Provide both interactive and automated test modes
|
|
||||||
- Test should work without requiring audio playback
|
|
||||||
- Handle permissions issues gracefully in tests
|
|
||||||
- Verify binary protocol parsing works correctly
|
|
||||||
|
|
||||||
## Binary Distribution
|
|
||||||
|
|
||||||
- Use node-pre-gyp for professional binary distribution
|
|
||||||
- Support both Intel and Apple Silicon Macs
|
|
||||||
- Graceful fallback if binary download fails
|
|
||||||
- Verify binary functionality during build process
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
When making changes:
|
|
||||||
|
|
||||||
1. **Test thoroughly** - Both JSON and binary protocols
|
|
||||||
2. **Handle edge cases** - Process crashes, permission issues, etc.
|
|
||||||
3. **Maintain compatibility** - Don't break existing APIs
|
|
||||||
4. **Update documentation** - Keep README examples current
|
|
||||||
5. **Follow semantic versioning** - Breaking changes require major version bump
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
```javascript
|
|
||||||
// Always emit errors, don't throw
|
|
||||||
this.emit('error', new Error(`Descriptive message: ${details}`))
|
|
||||||
|
|
||||||
// Provide context in errors
|
|
||||||
this.emit('error', new Error(`Failed to start AudioTee: ${error.message}`))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Emission
|
|
||||||
```javascript
|
|
||||||
// Use consistent event structure
|
|
||||||
this.emit('audio', {
|
|
||||||
timestamp: new Date(),
|
|
||||||
duration: number,
|
|
||||||
peakAmplitude: number,
|
|
||||||
audioData: Buffer
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Process Management
|
|
||||||
```javascript
|
|
||||||
// Always check process state before operations
|
|
||||||
if (this.process && !this.process.killed) {
|
|
||||||
this.process.kill('SIGTERM')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- Support for multiple concurrent streams
|
|
||||||
- WebSocket streaming interface
|
|
||||||
- TypeScript definitions
|
|
||||||
- React/Vue.js integration examples
|
|
||||||
- Performance monitoring and metrics
|
|
||||||
-173
@@ -1,173 +0,0 @@
|
|||||||
name: Build and Release AudioTee.js
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to build (e.g., v1.0.0)"
|
|
||||||
required: true
|
|
||||||
default: "v1.0.0"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: macos-latest
|
|
||||||
arch: arm64
|
|
||||||
node: "18"
|
|
||||||
- os: macos-13
|
|
||||||
arch: x64
|
|
||||||
node: "18"
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout audiotee-js
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Checkout AudioTee (parent project)
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: your-org/audiotee
|
|
||||||
path: audiotee
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node }}
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
- name: Setup Swift
|
|
||||||
uses: swift-actions/setup-swift@v1
|
|
||||||
with:
|
|
||||||
swift-version: "5.9"
|
|
||||||
|
|
||||||
- name: Build AudioTee binary
|
|
||||||
run: |
|
|
||||||
cd audiotee
|
|
||||||
swift build -c release
|
|
||||||
ls -la .build/release/
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Build AudioTee.js package
|
|
||||||
env:
|
|
||||||
AUDIOTEE_BINARY_PATH: ../audiotee/.build/release/audiotee
|
|
||||||
run: |
|
|
||||||
npm run build
|
|
||||||
ls -la bin/
|
|
||||||
|
|
||||||
- name: Test package
|
|
||||||
run: |
|
|
||||||
# Quick test to ensure the package loads and binary works
|
|
||||||
timeout 10s npm test quick || true
|
|
||||||
|
|
||||||
- name: Package binary for distribution
|
|
||||||
run: |
|
|
||||||
npm run package
|
|
||||||
|
|
||||||
- name: List package contents
|
|
||||||
run: |
|
|
||||||
ls -la build/
|
|
||||||
|
|
||||||
- name: Publish binary to GitHub releases
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
npm run publish-binary
|
|
||||||
|
|
||||||
publish-npm:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
run: |
|
|
||||||
# Update version to match release tag
|
|
||||||
npm version ${{ github.event.release.tag_name }} --no-git-tag-version
|
|
||||||
npm publish
|
|
||||||
|
|
||||||
test-installation:
|
|
||||||
needs: publish-npm
|
|
||||||
runs-on: macos-latest
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "18"
|
|
||||||
|
|
||||||
- name: Test npm installation
|
|
||||||
run: |
|
|
||||||
# Test that the package can be installed and works
|
|
||||||
npm install audiotee-js@${{ github.event.release.tag_name }}
|
|
||||||
|
|
||||||
# Create a simple test
|
|
||||||
cat > test-install.js << 'EOF'
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
console.log('✅ Package imported successfully');
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: 'json',
|
|
||||||
chunkDuration: 0.1
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ AudioTeeStream created successfully');
|
|
||||||
|
|
||||||
let metadataReceived = false;
|
|
||||||
|
|
||||||
stream.on('metadata', () => {
|
|
||||||
metadataReceived = true;
|
|
||||||
console.log('✅ Metadata received');
|
|
||||||
stream.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (error) => {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', () => {
|
|
||||||
if (metadataReceived) {
|
|
||||||
console.log('✅ Installation test passed!');
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.error('❌ No metadata received');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error('❌ Test timeout');
|
|
||||||
stream.stop();
|
|
||||||
process.exit(1);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
console.log('🚀 Starting AudioTee...');
|
|
||||||
stream.start();
|
|
||||||
EOF
|
|
||||||
|
|
||||||
node test-install.js
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Runtime
|
|
||||||
*.log
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage/
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Compiled binary
|
|
||||||
bin/
|
|
||||||
build/
|
|
||||||
lib-cov/
|
|
||||||
|
|
||||||
# Diagnostic reports
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.test
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Mac system files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Editor files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Test recordings
|
|
||||||
*.raw
|
|
||||||
*.wav
|
|
||||||
test-recordings/
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Source control
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Development files
|
|
||||||
.cursorrules
|
|
||||||
.github/
|
|
||||||
test/
|
|
||||||
coverage/
|
|
||||||
*.test.js
|
|
||||||
|
|
||||||
# Build artifacts (included via files in package.json)
|
|
||||||
build/
|
|
||||||
.build/
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Documentation (README.md is included via package.json files)
|
|
||||||
docs/
|
|
||||||
examples/
|
|
||||||
|
|
||||||
# Environment and config files
|
|
||||||
.env*
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Runtime
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Mac files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
|
|
||||||
# Test output
|
|
||||||
test-recordings/
|
|
||||||
*.raw
|
|
||||||
*.wav
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
|
|
||||||
# CI/CD artifacts that aren't needed in the package
|
|
||||||
.github/workflows/
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# AudioTee.js Development Guide
|
|
||||||
|
|
||||||
This guide covers setting up the development environment and workflow for AudioTee.js.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
audiotee-js/
|
|
||||||
├── package.json # npm package configuration with node-pre-gyp
|
|
||||||
├── index.js # Main entry point, uses node-pre-gyp to find binary
|
|
||||||
├── lib/
|
|
||||||
│ └── AudioTeeStream.js # Core streaming class
|
|
||||||
├── scripts/
|
|
||||||
│ └── build.js # Build script that copies AudioTee binary
|
|
||||||
├── test/
|
|
||||||
│ └── test.js # Interactive and automated tests
|
|
||||||
├── examples/
|
|
||||||
│ └── basic-usage.js # Usage examples and demos
|
|
||||||
├── .github/workflows/
|
|
||||||
│ └── release.yml # CI/CD for automated releases
|
|
||||||
└── README.md # User documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Initial Setup
|
|
||||||
|
|
||||||
### 1. Clone and Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <your-audiotee-js-repo>
|
|
||||||
cd audiotee-js
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Build AudioTee Binary
|
|
||||||
|
|
||||||
You'll need the AudioTee Swift project to build the binary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option A: If AudioTee is in parent directory (current setup)
|
|
||||||
cd ../audiotee
|
|
||||||
swift build -c release
|
|
||||||
cd ../audiotee-js
|
|
||||||
|
|
||||||
# Option B: If AudioTee is elsewhere, set the path
|
|
||||||
export AUDIOTEE_BINARY_PATH=/path/to/audiotee/.build/release/audiotee
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Build the Package
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This copies the AudioTee binary to `bin/audiotee` and makes it executable.
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactive test - requires audio playback
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Quick automated test
|
|
||||||
npm test quick
|
|
||||||
|
|
||||||
# Run examples
|
|
||||||
node examples/basic-usage.js
|
|
||||||
node examples/basic-usage.js 2 # Save to file example
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building for Different Architectures
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For Intel Macs (if you have access)
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# For Apple Silicon (if you have access)
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Clean build artifacts
|
|
||||||
npm run clean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing the Package Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test the package as if installed from npm
|
|
||||||
npm pack
|
|
||||||
npm install -g audiotee-js-1.0.0.tgz
|
|
||||||
|
|
||||||
# Test in another directory
|
|
||||||
cd /tmp
|
|
||||||
node -e "const { AudioTeeStream } = require('audiotee-js'); console.log('✅ Works!')"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release Process
|
|
||||||
|
|
||||||
### 1. Prepare Release
|
|
||||||
|
|
||||||
1. Update version in `package.json`
|
|
||||||
2. Update `CHANGELOG.md` (if you add one)
|
|
||||||
3. Test thoroughly on both Intel and Apple Silicon if possible
|
|
||||||
4. Commit changes
|
|
||||||
|
|
||||||
### 2. Create GitHub Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Then create a release on GitHub. This will trigger the automated build process.
|
|
||||||
|
|
||||||
### 3. Automated Release (via GitHub Actions)
|
|
||||||
|
|
||||||
The workflow will:
|
|
||||||
1. Build AudioTee binary for Intel and Apple Silicon
|
|
||||||
2. Package binaries using node-pre-gyp
|
|
||||||
3. Upload binaries to GitHub releases
|
|
||||||
4. Publish package to npm
|
|
||||||
5. Test the published package
|
|
||||||
|
|
||||||
### 4. Manual Release (if needed)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and package
|
|
||||||
npm run build
|
|
||||||
npm run package
|
|
||||||
|
|
||||||
# Publish binary to GitHub releases
|
|
||||||
npm run publish-binary
|
|
||||||
|
|
||||||
# Publish to npm
|
|
||||||
npm publish
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
- `AUDIOTEE_BINARY_PATH` - Path to AudioTee binary for building
|
|
||||||
- `GITHUB_TOKEN` - For publishing binaries to GitHub releases
|
|
||||||
- `NODE_AUTH_TOKEN` - For publishing to npm
|
|
||||||
|
|
||||||
### node-pre-gyp Configuration
|
|
||||||
|
|
||||||
The binary distribution is configured in `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"binary": {
|
|
||||||
"module_name": "audiotee",
|
|
||||||
"module_path": "./bin/",
|
|
||||||
"remote_path": "v{version}/",
|
|
||||||
"package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz",
|
|
||||||
"host": "https://github.com/your-org/audiotee-js/releases/download/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Update the `host` URL to match your repository.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Binary Not Found During Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if AudioTee is built
|
|
||||||
ls -la ../audiotee/.build/release/audiotee
|
|
||||||
|
|
||||||
# Or set custom path
|
|
||||||
export AUDIOTEE_BINARY_PATH=/path/to/your/audiotee/binary
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make sure binary is executable
|
|
||||||
chmod +x bin/audiotee
|
|
||||||
|
|
||||||
# Check binary works
|
|
||||||
./bin/audiotee --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### node-pre-gyp Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clear cache
|
|
||||||
npm run clean
|
|
||||||
rm -rf node_modules
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Debug node-pre-gyp
|
|
||||||
DEBUG=node-pre-gyp npm run package
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- Follow the patterns in `.cursorrules`
|
|
||||||
- Use functional programming where possible
|
|
||||||
- No semicolons (per project preference)
|
|
||||||
- Handle errors via EventEmitter, don't throw
|
|
||||||
- Use British English in documentation
|
|
||||||
- Comprehensive JSDoc for public APIs
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
Before releasing:
|
|
||||||
|
|
||||||
- [ ] Basic audio capture works
|
|
||||||
- [ ] Both JSON and binary formats work
|
|
||||||
- [ ] Sample rate conversion works
|
|
||||||
- [ ] Process filtering works (if testable)
|
|
||||||
- [ ] Error handling works (invalid args, missing binary, etc.)
|
|
||||||
- [ ] Package installs and works on clean system
|
|
||||||
- [ ] Examples in README work
|
|
||||||
- [ ] CI/CD builds successfully
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Test thoroughly
|
|
||||||
5. Update documentation
|
|
||||||
6. Submit a pull request
|
|
||||||
|
|
||||||
## Publishing Checklist
|
|
||||||
|
|
||||||
- [ ] Version updated in package.json
|
|
||||||
- [ ] Tests pass
|
|
||||||
- [ ] Documentation updated
|
|
||||||
- [ ] GitHub release created
|
|
||||||
- [ ] CI/CD completed successfully
|
|
||||||
- [ ] npm package published
|
|
||||||
- [ ] Installation test passes
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 AudioTee.js Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
# AudioTee.js
|
|
||||||
|
|
||||||
Node.js wrapper for [AudioTee](https://github.com/your-org/audiotee) - capture macOS system audio using Core Audio taps.
|
|
||||||
|
|
||||||
AudioTee.js provides a streaming interface to capture system audio in real-time, perfect for building applications that need to process audio from any running application on macOS.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🎵 **Real-time system audio capture** using Core Audio taps
|
|
||||||
- 📦 **Streaming interface** with Node.js EventEmitter API
|
|
||||||
- ⚡ **High performance** binary protocol support
|
|
||||||
- 🎛️ **Flexible configuration** - sample rates, chunk sizes, process filtering
|
|
||||||
- 🔇 **Process-specific capture** - include/exclude specific applications
|
|
||||||
- 📊 **Audio metadata** - format information and level monitoring
|
|
||||||
- 🛡️ **Error handling** - graceful failure and process management
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- **macOS 14.2+** (Sonoma or later)
|
|
||||||
- **Node.js 14+**
|
|
||||||
- **Audio recording permissions** (you'll be prompted on first use)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install audiotee-js
|
|
||||||
```
|
|
||||||
|
|
||||||
The package will automatically download the appropriate AudioTee binary for your system during installation.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
// Create a stream with 16kHz sample rate (great for ASR)
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
sampleRate: 16000,
|
|
||||||
format: 'binary',
|
|
||||||
chunkDuration: 0.2
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for audio metadata
|
|
||||||
stream.on('metadata', (metadata) => {
|
|
||||||
console.log('Audio format:', metadata);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process audio chunks
|
|
||||||
stream.on('audio', (packet) => {
|
|
||||||
console.log(`Received ${packet.audioData.length} bytes of audio`);
|
|
||||||
// packet.audioData is a Buffer containing raw PCM data
|
|
||||||
// packet.timestamp, packet.duration, packet.peakAmplitude also available
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
stream.on('error', (error) => {
|
|
||||||
console.error('AudioTee error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start capturing
|
|
||||||
stream.start();
|
|
||||||
|
|
||||||
// Stop when done
|
|
||||||
// stream.stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### AudioTeeStream
|
|
||||||
|
|
||||||
The main class for capturing system audio.
|
|
||||||
|
|
||||||
#### Constructor
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
new AudioTeeStream(options)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
|
||||||
|--------|------|---------|-------------|
|
|
||||||
| `format` | `string` | `'binary'` | Output format: `'json'`, `'binary'`, or `'auto'` |
|
|
||||||
| `sampleRate` | `number` | `undefined` | Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) |
|
|
||||||
| `chunkDuration` | `number` | `0.2` | Audio chunk duration in seconds (max 5.0) |
|
|
||||||
| `includeProcesses` | `number[]` | `[]` | Process IDs to capture (empty = all processes) |
|
|
||||||
| `excludeProcesses` | `number[]` | `[]` | Process IDs to exclude |
|
|
||||||
| `mute` | `boolean` | `false` | Mute processes being captured |
|
|
||||||
| `binaryPath` | `string` | `auto` | Custom path to AudioTee binary |
|
|
||||||
|
|
||||||
#### Methods
|
|
||||||
|
|
||||||
- **`start()`** - Start audio capture, returns `this` for chaining
|
|
||||||
- **`stop()`** - Stop audio capture
|
|
||||||
- **`isActive()`** - Returns `true` if currently capturing
|
|
||||||
- **`getMetadata()`** - Returns audio metadata (available after `metadata` event)
|
|
||||||
|
|
||||||
#### Events
|
|
||||||
|
|
||||||
- **`metadata`** - Audio format information
|
|
||||||
- **`stream_start`** - Capture has started
|
|
||||||
- **`audio`** - Audio data packet
|
|
||||||
- **`stream_stop`** - Capture has stopped
|
|
||||||
- **`log`** - Log messages from AudioTee
|
|
||||||
- **`error`** - Error occurred
|
|
||||||
- **`close`** - Process has closed
|
|
||||||
|
|
||||||
### Audio Packet Format
|
|
||||||
|
|
||||||
Audio events receive packets with this structure:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
timestamp: Date, // When this audio was captured
|
|
||||||
duration: number, // Duration in seconds
|
|
||||||
peakAmplitude: number, // Peak amplitude (0.0 - 1.0)
|
|
||||||
audioData: Buffer // Raw PCM audio data
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metadata Format
|
|
||||||
|
|
||||||
Metadata events provide audio format information:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
sample_rate: number, // e.g. 48000
|
|
||||||
channels_per_frame: number,// Always 1 (mono)
|
|
||||||
bits_per_channel: number, // e.g. 32
|
|
||||||
is_float: boolean, // true for float32, false for int16
|
|
||||||
encoding: string, // e.g. "pcm_f32le"
|
|
||||||
capture_mode: string, // "audio"
|
|
||||||
device_name: string|null, // Audio device name
|
|
||||||
device_uid: string|null // Audio device UID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic Recording
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream();
|
|
||||||
|
|
||||||
stream.on('metadata', console.log);
|
|
||||||
stream.on('audio', (packet) => {
|
|
||||||
console.log(`${packet.audioData.length} bytes, peak: ${packet.peakAmplitude}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Save to WAV File
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const fs = require('fs');
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
sampleRate: 44100,
|
|
||||||
format: 'binary'
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = fs.createWriteStream('recording.raw');
|
|
||||||
|
|
||||||
stream.on('audio', (packet) => {
|
|
||||||
output.write(packet.audioData);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.start();
|
|
||||||
|
|
||||||
// Stop after 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
stream.stop();
|
|
||||||
output.end();
|
|
||||||
}, 10000);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Process-Specific Capture
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
// Only capture audio from Spotify (you'd need to find Spotify's PID)
|
|
||||||
const spotifyPID = 1234; // Use Activity Monitor or `pgrep Spotify`
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
includeProcesses: [spotifyPID],
|
|
||||||
mute: true // Don't play through speakers
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('audio', (packet) => {
|
|
||||||
// Only Spotify's audio will be captured
|
|
||||||
console.log('Spotify audio:', packet.audioData.length, 'bytes');
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Real-time ASR Integration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { AudioTeeStream } = require('audiotee-js');
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
sampleRate: 16000, // Common ASR sample rate
|
|
||||||
chunkDuration: 0.1, // Faster chunks for real-time
|
|
||||||
format: 'binary'
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('audio', async (packet) => {
|
|
||||||
// Send to your ASR service
|
|
||||||
const transcript = await sendToASR(packet.audioData);
|
|
||||||
if (transcript) {
|
|
||||||
console.log('Transcription:', transcript);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the included test to verify everything works:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Basic interactive test
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Quick automated test
|
|
||||||
npm test quick
|
|
||||||
```
|
|
||||||
|
|
||||||
The test will capture audio for a few seconds and display statistics.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Permission Denied
|
|
||||||
|
|
||||||
AudioTee requires microphone permissions. You'll see a system dialog on first use - make sure to allow access.
|
|
||||||
|
|
||||||
### Binary Not Found
|
|
||||||
|
|
||||||
If you see "AudioTee binary not found", try rebuilding:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### No Audio Captured
|
|
||||||
|
|
||||||
- Check that audio is actually playing on your system
|
|
||||||
- Verify you have the latest macOS version (14.2+)
|
|
||||||
- Try running the Swift AudioTee directly to isolate the issue
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone and build the parent AudioTee project first
|
|
||||||
git clone https://github.com/your-org/audiotee.git
|
|
||||||
cd audiotee
|
|
||||||
swift build -c release
|
|
||||||
|
|
||||||
# Then build the Node.js package
|
|
||||||
cd audiotee-js
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Changes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test # Run basic test
|
|
||||||
npm run lint # Check code style
|
|
||||||
npm run clean # Clean build artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
- **Binary format** is more efficient than JSON for high-throughput applications
|
|
||||||
- **Lower chunk durations** increase CPU usage but reduce latency
|
|
||||||
- **Sample rate conversion** adds processing overhead - use native rates when possible
|
|
||||||
- The AudioTee binary uses real-time audio threads for minimal latency
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE) file.
|
|
||||||
|
|
||||||
## Related Projects
|
|
||||||
|
|
||||||
- [AudioTee](https://github.com/your-org/audiotee) - The underlying Swift CLI tool
|
|
||||||
- [node-core-audio](https://github.com/ZECTBynmo/node-core-audio) - Alternative Node.js audio library
|
|
||||||
- [AudioCap](https://github.com/insidegui/AudioCap) - macOS audio capture inspiration
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic AudioTee.js Usage Examples
|
|
||||||
*
|
|
||||||
* This file demonstrates common patterns for using AudioTee.js
|
|
||||||
* Run with: node examples/basic-usage.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { AudioTeeStream } = require("../index");
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
// Example 1: Basic audio capture with console output
|
|
||||||
function basicCapture() {
|
|
||||||
console.log("=== Example 1: Basic Audio Capture ===\n");
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: "binary",
|
|
||||||
sampleRate: 16000, // Good for speech recognition
|
|
||||||
chunkDuration: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
let packetCount = 0;
|
|
||||||
let totalBytes = 0;
|
|
||||||
|
|
||||||
stream.on("metadata", (metadata) => {
|
|
||||||
console.log("🎵 Audio metadata:");
|
|
||||||
console.log(` Sample rate: ${metadata.sample_rate} Hz`);
|
|
||||||
console.log(` Encoding: ${metadata.encoding}`);
|
|
||||||
console.log("");
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("audio", (packet) => {
|
|
||||||
packetCount++;
|
|
||||||
totalBytes += packet.audioData.length;
|
|
||||||
|
|
||||||
// Show real-time stats
|
|
||||||
process.stdout.write(
|
|
||||||
`\r📊 Packets: ${packetCount}, Bytes: ${totalBytes}, Peak: ${packet.peakAmplitude.toFixed(
|
|
||||||
3
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (error) => {
|
|
||||||
console.error("\n❌ Error:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-stop after 5 seconds for demo
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("\n\n✅ Stopping capture...");
|
|
||||||
stream.stop();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
console.log("🚀 Starting capture (will run for 5 seconds)...");
|
|
||||||
stream.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 2: Save audio to file
|
|
||||||
function saveToFile() {
|
|
||||||
console.log("\n=== Example 2: Save Audio to File ===\n");
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: "binary",
|
|
||||||
sampleRate: 44100, // CD quality
|
|
||||||
chunkDuration: 0.1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const outputFile = "recording.raw";
|
|
||||||
const writeStream = fs.createWriteStream(outputFile);
|
|
||||||
|
|
||||||
stream.on("metadata", (metadata) => {
|
|
||||||
console.log(`💾 Saving ${metadata.encoding} audio to ${outputFile}`);
|
|
||||||
console.log(
|
|
||||||
` Format: ${metadata.sample_rate}Hz, ${metadata.bits_per_channel}-bit`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("audio", (packet) => {
|
|
||||||
// Write raw audio data to file
|
|
||||||
writeStream.write(packet.audioData);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("stream_stop", () => {
|
|
||||||
writeStream.end();
|
|
||||||
console.log(`\n✅ Saved audio to ${outputFile}`);
|
|
||||||
|
|
||||||
// Show file size
|
|
||||||
const stats = fs.statSync(outputFile);
|
|
||||||
console.log(`📈 File size: ${(stats.size / 1024).toFixed(1)} KB`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stop after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
stream.stop();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
console.log("🎵 Recording for 3 seconds...");
|
|
||||||
stream.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 3: Monitor audio levels (VU meter style)
|
|
||||||
function audioLevelMonitor() {
|
|
||||||
console.log("\n=== Example 3: Audio Level Monitor ===\n");
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: "json", // JSON format for this example
|
|
||||||
chunkDuration: 0.05, // Fast updates for smooth level display
|
|
||||||
});
|
|
||||||
|
|
||||||
function drawLevelMeter(level) {
|
|
||||||
const maxBars = 20;
|
|
||||||
const bars = Math.floor(level * maxBars);
|
|
||||||
const meter = "█".repeat(bars) + "░".repeat(maxBars - bars);
|
|
||||||
const percentage = (level * 100).toFixed(1);
|
|
||||||
|
|
||||||
process.stdout.write(`\r🔊 ${meter} ${percentage}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on("audio", (packet) => {
|
|
||||||
drawLevelMeter(packet.peakAmplitude);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run for 10 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("\n\n✅ Level monitoring complete");
|
|
||||||
stream.stop();
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
console.log("🎚️ Audio level monitor (10 seconds):");
|
|
||||||
console.log(" Play some music to see the levels!\n");
|
|
||||||
stream.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 4: Process-specific capture
|
|
||||||
function captureSpecificProcess() {
|
|
||||||
console.log("\n=== Example 4: Process-Specific Capture ===\n");
|
|
||||||
|
|
||||||
// This would capture only from a specific application
|
|
||||||
// You'd need to find the PID first: `pgrep "Music"` or Activity Monitor
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
// includeProcesses: [1234], // Uncomment and set real PID
|
|
||||||
mute: true, // Don't play through speakers
|
|
||||||
format: "binary",
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("metadata", () => {
|
|
||||||
console.log(
|
|
||||||
"🎯 Capturing from specific process (demo mode - all processes)"
|
|
||||||
);
|
|
||||||
console.log(" To capture from specific app:");
|
|
||||||
console.log(' 1. Find PID: pgrep "App Name"');
|
|
||||||
console.log(" 2. Uncomment includeProcesses line above");
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("audio", (packet) => {
|
|
||||||
console.log(
|
|
||||||
`📦 Got ${packet.audioData.length} bytes from targeted process`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stop after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
stream.stop();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
stream.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run examples based on command line argument
|
|
||||||
const example = process.argv[2] || "1";
|
|
||||||
|
|
||||||
switch (example) {
|
|
||||||
case "1":
|
|
||||||
basicCapture();
|
|
||||||
break;
|
|
||||||
case "2":
|
|
||||||
saveToFile();
|
|
||||||
break;
|
|
||||||
case "3":
|
|
||||||
audioLevelMonitor();
|
|
||||||
break;
|
|
||||||
case "4":
|
|
||||||
captureSpecificProcess();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log("Usage: node basic-usage.js [1|2|3|4]");
|
|
||||||
console.log("");
|
|
||||||
console.log("Examples:");
|
|
||||||
console.log(" 1 - Basic audio capture");
|
|
||||||
console.log(" 2 - Save audio to file");
|
|
||||||
console.log(" 3 - Audio level monitor");
|
|
||||||
console.log(" 4 - Process-specific capture");
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
const path = require("path");
|
|
||||||
const binary = require("node-pre-gyp");
|
|
||||||
|
|
||||||
// Get the path to the downloaded binary
|
|
||||||
const bindingPath = binary.find(
|
|
||||||
path.resolve(path.join(__dirname, "package.json"))
|
|
||||||
);
|
|
||||||
const binaryPath = path.join(path.dirname(bindingPath), "audiotee");
|
|
||||||
|
|
||||||
const AudioTeeStream = require("./lib/AudioTeeStream");
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
AudioTeeStream,
|
|
||||||
getBinaryPath: () => binaryPath,
|
|
||||||
};
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
const { spawn } = require("child_process");
|
|
||||||
const { EventEmitter } = require("events");
|
|
||||||
const readline = require("readline");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AudioTeeStream - Node.js wrapper for AudioTee system audio capture
|
|
||||||
*
|
|
||||||
* Events:
|
|
||||||
* - 'metadata': Audio format information
|
|
||||||
* - 'stream_start': Recording has started
|
|
||||||
* - 'audio': Audio data chunk { timestamp, duration, peakAmplitude, audioData }
|
|
||||||
* - 'stream_stop': Recording has stopped
|
|
||||||
* - 'log': Log messages { level, message, context }
|
|
||||||
* - 'error': Errors
|
|
||||||
* - 'close': Process has closed
|
|
||||||
*/
|
|
||||||
class AudioTeeStream extends EventEmitter {
|
|
||||||
constructor(options = {}) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
// Binary path - use provided path or get from main module
|
|
||||||
this.binaryPath =
|
|
||||||
options.binaryPath ||
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
return require("../index").getBinaryPath();
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
"AudioTee binary path not available. Ensure package is properly installed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// AudioTee options
|
|
||||||
this.format = options.format || "binary"; // 'json', 'binary', or 'auto'
|
|
||||||
this.sampleRate = options.sampleRate;
|
|
||||||
this.chunkDuration = options.chunkDuration || 0.2;
|
|
||||||
this.includeProcesses = options.includeProcesses || [];
|
|
||||||
this.excludeProcesses = options.excludeProcesses || [];
|
|
||||||
this.mute = options.mute || false;
|
|
||||||
|
|
||||||
// Internal state
|
|
||||||
this.process = null;
|
|
||||||
this.metadata = null;
|
|
||||||
this.isStarted = false;
|
|
||||||
|
|
||||||
// Binary format state
|
|
||||||
this.pendingAudioMeta = null;
|
|
||||||
this.expectedBytes = 0;
|
|
||||||
this.binaryBuffer = Buffer.alloc(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start audio capture
|
|
||||||
* @returns {AudioTeeStream} this instance for chaining
|
|
||||||
*/
|
|
||||||
start() {
|
|
||||||
if (this.isStarted) {
|
|
||||||
throw new Error("AudioTeeStream is already started");
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.buildArguments();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.process = spawn(this.binaryPath, args, {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.emit(
|
|
||||||
"error",
|
|
||||||
new Error(`Failed to start AudioTee: ${error.message}`)
|
|
||||||
);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isStarted = true;
|
|
||||||
this.setupProtocolHandling();
|
|
||||||
this.setupProcessHandlers();
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop audio capture
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
if (this.process && !this.process.killed) {
|
|
||||||
this.process.kill("SIGTERM");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the stream is currently active
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
isActive() {
|
|
||||||
return this.isStarted && this.process && !this.process.killed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current audio metadata (available after 'metadata' event)
|
|
||||||
* @returns {Object|null}
|
|
||||||
*/
|
|
||||||
getMetadata() {
|
|
||||||
return this.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private methods
|
|
||||||
|
|
||||||
buildArguments() {
|
|
||||||
const args = [`--format=${this.format}`];
|
|
||||||
|
|
||||||
if (this.sampleRate) {
|
|
||||||
args.push(`--sample-rate=${this.sampleRate}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.chunkDuration !== 0.2) {
|
|
||||||
args.push(`--chunk-duration=${this.chunkDuration}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.includeProcesses.length) {
|
|
||||||
args.push(`--include-processes=${this.includeProcesses.join(" ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.excludeProcesses.length) {
|
|
||||||
args.push(`--exclude-processes=${this.excludeProcesses.join(" ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mute) {
|
|
||||||
args.push("--mute");
|
|
||||||
}
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupProtocolHandling() {
|
|
||||||
if (this.format === "binary") {
|
|
||||||
this.setupBinaryProtocol();
|
|
||||||
} else {
|
|
||||||
this.setupJSONProtocol();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupJSONProtocol() {
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: this.process.stdout,
|
|
||||||
crlfDelay: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on("line", (line) => this.handleJSONLine(line));
|
|
||||||
}
|
|
||||||
|
|
||||||
setupBinaryProtocol() {
|
|
||||||
// For binary format, we need to handle both JSON lines and raw binary data
|
|
||||||
let lineBuffer = "";
|
|
||||||
let inJsonMode = true;
|
|
||||||
|
|
||||||
this.process.stdout.on("data", (chunk) => {
|
|
||||||
if (inJsonMode) {
|
|
||||||
// Look for complete JSON lines
|
|
||||||
lineBuffer += chunk.toString();
|
|
||||||
|
|
||||||
let newlineIndex;
|
|
||||||
while ((newlineIndex = lineBuffer.indexOf("\n")) !== -1) {
|
|
||||||
const line = lineBuffer.slice(0, newlineIndex);
|
|
||||||
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
if (message.message_type === "audio" && this.format === "binary") {
|
|
||||||
// Prepare for binary data
|
|
||||||
this.expectedBytes = message.data.audio_length;
|
|
||||||
this.pendingAudioMeta = {
|
|
||||||
timestamp: new Date(message.data.timestamp),
|
|
||||||
duration: message.data.duration,
|
|
||||||
peakAmplitude: message.data.peak_amplitude,
|
|
||||||
};
|
|
||||||
inJsonMode = false;
|
|
||||||
} else {
|
|
||||||
this.handleJSONMessage(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.emit(
|
|
||||||
"error",
|
|
||||||
new Error(`Failed to parse JSON: ${error.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// We're expecting binary audio data
|
|
||||||
this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]);
|
|
||||||
|
|
||||||
if (this.binaryBuffer.length >= this.expectedBytes) {
|
|
||||||
// Extract the audio data
|
|
||||||
const audioData = this.binaryBuffer.slice(0, this.expectedBytes);
|
|
||||||
this.binaryBuffer = this.binaryBuffer.slice(this.expectedBytes);
|
|
||||||
|
|
||||||
// Emit the audio event
|
|
||||||
this.emit("audio", {
|
|
||||||
...this.pendingAudioMeta,
|
|
||||||
audioData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
this.expectedBytes = 0;
|
|
||||||
this.pendingAudioMeta = null;
|
|
||||||
inJsonMode = true;
|
|
||||||
|
|
||||||
// Process any remaining data as JSON
|
|
||||||
if (this.binaryBuffer.length > 0) {
|
|
||||||
lineBuffer += this.binaryBuffer.toString();
|
|
||||||
this.binaryBuffer = Buffer.alloc(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleJSONLine(line) {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
this.handleJSONMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
this.emit("error", new Error(`Failed to parse JSON: ${error.message}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleJSONMessage(message) {
|
|
||||||
switch (message.message_type) {
|
|
||||||
case "metadata":
|
|
||||||
this.metadata = message.data;
|
|
||||||
this.emit("metadata", this.metadata);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stream_start":
|
|
||||||
this.emit("stream_start");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "audio":
|
|
||||||
if (this.format === "json") {
|
|
||||||
// JSON format - audio data is base64 encoded
|
|
||||||
const audioBuffer = Buffer.from(message.data.audio_data, "base64");
|
|
||||||
this.emit("audio", {
|
|
||||||
timestamp: new Date(message.data.timestamp),
|
|
||||||
duration: message.data.duration,
|
|
||||||
peakAmplitude: message.data.peak_amplitude,
|
|
||||||
audioData: audioBuffer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Binary format audio is handled in setupBinaryProtocol
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stream_stop":
|
|
||||||
this.emit("stream_stop");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "info":
|
|
||||||
case "error":
|
|
||||||
case "debug":
|
|
||||||
this.emit("log", {
|
|
||||||
level: message.message_type,
|
|
||||||
message: message.data?.message || "Unknown log message",
|
|
||||||
context: message.data?.context,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.emit("log", {
|
|
||||||
level: "debug",
|
|
||||||
message: `Unknown message type: ${message.message_type}`,
|
|
||||||
context: { raw_message: message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupProcessHandlers() {
|
|
||||||
this.process.stderr.on("data", (data) => {
|
|
||||||
// AudioTee should not write to stderr in normal operation
|
|
||||||
this.emit("log", {
|
|
||||||
level: "error",
|
|
||||||
message: "AudioTee stderr output",
|
|
||||||
context: { output: data.toString().trim() },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process.on("close", (code, signal) => {
|
|
||||||
this.isStarted = false;
|
|
||||||
this.emit("close", { code, signal });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.process.on("error", (error) => {
|
|
||||||
this.isStarted = false;
|
|
||||||
this.emit("error", new Error(`AudioTee process error: ${error.message}`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AudioTeeStream;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "audiotee-js",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Node.js wrapper for AudioTee - capture macOS system audio using Core Audio taps",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"install": "node-pre-gyp install --fallback-to-build",
|
|
||||||
"build": "node scripts/build.js",
|
|
||||||
"test": "node test/test.js",
|
|
||||||
"package": "node-pre-gyp package",
|
|
||||||
"publish-binary": "node-pre-gyp publish",
|
|
||||||
"clean": "node-pre-gyp clean",
|
|
||||||
"lint": "eslint lib/ scripts/ test/ index.js",
|
|
||||||
"prepack": "npm run build"
|
|
||||||
},
|
|
||||||
"binary": {
|
|
||||||
"module_name": "audiotee",
|
|
||||||
"module_path": "./bin/",
|
|
||||||
"remote_path": "v{version}/",
|
|
||||||
"package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz",
|
|
||||||
"host": "https://github.com/your-org/audiotee-js/releases/download/",
|
|
||||||
"napi_versions": []
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"node-pre-gyp": "^0.17.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^8.0.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"index.js",
|
|
||||||
"lib/",
|
|
||||||
"scripts/build.js",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"os": ["darwin"],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"audio",
|
|
||||||
"macos",
|
|
||||||
"recording",
|
|
||||||
"system-audio",
|
|
||||||
"core-audio",
|
|
||||||
"streaming",
|
|
||||||
"real-time",
|
|
||||||
"asr",
|
|
||||||
"speech-recognition"
|
|
||||||
],
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/your-org/audiotee-js.git"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/your-org/audiotee-js/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/your-org/audiotee-js#readme",
|
|
||||||
"license": "MIT",
|
|
||||||
"author": "Your Name <your.email@example.com>"
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const os = require("os");
|
|
||||||
|
|
||||||
// Default to parent directory (when in same repo), allow override via env var
|
|
||||||
const DEFAULT_BINARY_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
".build",
|
|
||||||
"release",
|
|
||||||
"audiotee"
|
|
||||||
);
|
|
||||||
const AUDIOTEE_BINARY_PATH =
|
|
||||||
process.env.AUDIOTEE_BINARY_PATH || DEFAULT_BINARY_PATH;
|
|
||||||
|
|
||||||
function build() {
|
|
||||||
const arch = os.arch();
|
|
||||||
const platform = os.platform();
|
|
||||||
|
|
||||||
console.log(`Building for platform: ${platform}, architecture: ${arch}`);
|
|
||||||
|
|
||||||
if (platform !== "darwin") {
|
|
||||||
throw new Error("AudioTee only supports macOS");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create bin directory
|
|
||||||
const binDir = path.join(__dirname, "..", "bin");
|
|
||||||
if (!fs.existsSync(binDir)) {
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
|
||||||
console.log(`Created bin directory: ${binDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the source binary path
|
|
||||||
const sourcePath = path.resolve(AUDIOTEE_BINARY_PATH);
|
|
||||||
const targetPath = path.join(binDir, "audiotee");
|
|
||||||
|
|
||||||
console.log(`Looking for AudioTee binary at: ${sourcePath}`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(sourcePath)) {
|
|
||||||
console.error(`AudioTee binary not found at: ${sourcePath}`);
|
|
||||||
console.error("Please ensure AudioTee is built first:");
|
|
||||||
console.error(" cd ../audiotee && swift build -c release");
|
|
||||||
console.error("Or set AUDIOTEE_BINARY_PATH environment variable");
|
|
||||||
throw new Error(`AudioTee binary not found at: ${sourcePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Copying AudioTee binary from ${sourcePath} to ${targetPath}`);
|
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
|
||||||
|
|
||||||
// Make executable
|
|
||||||
fs.chmodSync(targetPath, 0o755);
|
|
||||||
|
|
||||||
// Verify the binary works
|
|
||||||
console.log("Verifying binary...");
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
try {
|
|
||||||
execSync(`"${targetPath}" --help`, { stdio: "pipe" });
|
|
||||||
console.log("Binary verification successful");
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Binary verification failed, but continuing...");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Build completed successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
function clean() {
|
|
||||||
const binDir = path.join(__dirname, "..", "bin");
|
|
||||||
if (fs.existsSync(binDir)) {
|
|
||||||
fs.rmSync(binDir, { recursive: true, force: true });
|
|
||||||
console.log("Cleaned bin directory");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
const command = process.argv[2];
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case "clean":
|
|
||||||
clean();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { build, clean };
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const { AudioTeeStream } = require("../index");
|
|
||||||
|
|
||||||
function runBasicTest() {
|
|
||||||
console.log("=== AudioTee.js Basic Test ===\n");
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: "binary",
|
|
||||||
sampleRate: 16000,
|
|
||||||
chunkDuration: 0.1,
|
|
||||||
});
|
|
||||||
|
|
||||||
let audioPacketCount = 0;
|
|
||||||
let totalAudioBytes = 0;
|
|
||||||
|
|
||||||
stream.on("metadata", (metadata) => {
|
|
||||||
console.log("📊 Audio Metadata:");
|
|
||||||
console.log(` Sample Rate: ${metadata.sample_rate} Hz`);
|
|
||||||
console.log(` Channels: ${metadata.channels_per_frame}`);
|
|
||||||
console.log(` Bits per Channel: ${metadata.bits_per_channel}`);
|
|
||||||
console.log(` Encoding: ${metadata.encoding}`);
|
|
||||||
console.log(` Float: ${metadata.is_float}\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("stream_start", () => {
|
|
||||||
console.log("🎵 Audio stream started\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("audio", (packet) => {
|
|
||||||
audioPacketCount++;
|
|
||||||
totalAudioBytes += packet.audioData.length;
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
`\r📦 Packets: ${audioPacketCount} | Audio bytes: ${totalAudioBytes} | Peak: ${packet.peakAmplitude.toFixed(
|
|
||||||
3
|
|
||||||
)} | Duration: ${packet.duration.toFixed(3)}s`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("stream_stop", () => {
|
|
||||||
console.log("\n\n🛑 Audio stream stopped");
|
|
||||||
console.log(
|
|
||||||
`📈 Final stats: ${audioPacketCount} packets, ${totalAudioBytes} bytes total\n`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("log", (log) => {
|
|
||||||
if (log.level === "error") {
|
|
||||||
console.error(`\n❌ ${log.level.toUpperCase()}: ${log.message}`);
|
|
||||||
if (log.context) {
|
|
||||||
console.error(` Context:`, log.context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (error) => {
|
|
||||||
console.error(`\n💥 Error: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", ({ code, signal }) => {
|
|
||||||
console.log(
|
|
||||||
`👋 AudioTee process closed (code: ${code}, signal: ${signal})`
|
|
||||||
);
|
|
||||||
process.exit(code || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Ctrl+C gracefully
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
console.log("\n\n🛑 Received SIGINT, stopping AudioTee...");
|
|
||||||
stream.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("🚀 Starting AudioTee stream...");
|
|
||||||
console.log("💡 Play some audio and watch the packets stream in!");
|
|
||||||
console.log("⏹️ Press Ctrl+C to stop\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
stream.start();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to start: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runQuickTest() {
|
|
||||||
console.log("=== AudioTee.js Quick Test ===\n");
|
|
||||||
|
|
||||||
const stream = new AudioTeeStream({
|
|
||||||
format: "json",
|
|
||||||
chunkDuration: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
let packetCount = 0;
|
|
||||||
const maxPackets = 5;
|
|
||||||
|
|
||||||
stream.on("metadata", (metadata) => {
|
|
||||||
console.log("✅ Received metadata:", metadata);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("audio", () => {
|
|
||||||
packetCount++;
|
|
||||||
console.log(`✅ Received audio packet ${packetCount}/${maxPackets}`);
|
|
||||||
|
|
||||||
if (packetCount >= maxPackets) {
|
|
||||||
console.log("✅ Quick test complete!");
|
|
||||||
stream.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (error) => {
|
|
||||||
console.error("❌ Test failed:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", () => {
|
|
||||||
console.log("👋 Test finished");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("⏰ Test timeout - AudioTee might not be working");
|
|
||||||
stream.stop();
|
|
||||||
process.exit(1);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
console.log("🚀 Running quick test (capturing 5 audio packets)...");
|
|
||||||
stream.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the appropriate test based on command line args
|
|
||||||
const testType = process.argv[2] || "basic";
|
|
||||||
|
|
||||||
switch (testType) {
|
|
||||||
case "quick":
|
|
||||||
runQuickTest();
|
|
||||||
break;
|
|
||||||
case "basic":
|
|
||||||
default:
|
|
||||||
runBasicTest();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user