1 Commits

Author SHA1 Message Date
Nick Payne 7e7f6d34ca cursor having a crack at a wrapper module 2025-07-01 13:32:55 +01:00
43 changed files with 2448 additions and 860 deletions
-2
View File
@@ -7,5 +7,3 @@ 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
+15
View File
@@ -0,0 +1,15 @@
{
"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
}
+18 -38
View File
@@ -4,41 +4,21 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "audiotee", name: "audiotee",
platforms: [ platforms: [
.macOS("14.2") .macOS("14.2")
], ],
products: [ dependencies: [
// Library that can be imported by other packages .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
.library( ],
name: "AudioTeeCore", targets: [
targets: ["AudioTeeCore"] // Targets are the basic building blocks of a package, defining a module or a test suite.
), // Targets can depend on other targets in this package and products from dependencies.
// CLI executable .executableTarget(
.executable( name: "audiotee",
name: "audiotee", dependencies: [
targets: ["AudioTeeCLI"] .product(name: "ArgumentParser", package: "swift-argument-parser")
) ]
], )
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"
)
]
)
+143 -69
View File
@@ -1,24 +1,12 @@
# AudioTee # AudioTee
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.** AudioTee captures your Mac's system audio output and writes 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.
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can capture whatever's playing through your speakers to a file like this: By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not configurable) and preserves your output device's sample rate unless you pass a `--sample-rate` flag. Only the default output device is currently supported.
```bash My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
/path/to/audiotee > output.pcm
```
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. Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` and `Core/AudioRecorder`. Everything's wired together in `CLI/AudioTee`. The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
System audio is captured using the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - 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
@@ -28,26 +16,12 @@ 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
@@ -62,29 +36,20 @@ swift build -c release
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon. Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
```bash ```bash
# Write raw PCM audio to stdout (logs go to stderr) # Auto-detect output format (JSON in terminal, binary when piped)
./audiotee ./audiotee
# Redirect audio to a file # Always use JSON format (terminal-safe)
./audiotee > output.pcm ./audiotee --format json
# Pipe to another program # Always use binary format (pipe-optimised)
./audiotee | your_audio_processing_tool ./audiotee --format binary
# Redirect logs as well
./audiotee > captured_audio.pcm 2> audiotee.log
``` ```
### Audio conversion ### Audio conversion
Note that performing _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
# No sample rate preserves your device's default (probably 44.1 or 48kHz with 32-bit float bit depth) # Convert to 16kHz mono (useful for ASR services)
./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
@@ -95,8 +60,6 @@ Note that performing _any_ sample rate conversion will also convert the output b
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
@@ -122,51 +85,162 @@ Note that trying to include or exclude a PID which isn't currently playing audio
./audiotee --chunk-duration 0.1 ./audiotee --chunk-duration 0.1
``` ```
## Output ## Output formats
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`. AudioTee supports two output formats optimised for different use cases:
### Audio format ### JSON format (`--format json` or auto in terminal)
- **Format**: Raw PCM audio data JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
- **Channels**: 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)
### Logs and monitoring ### Binary format (`--format binary` or auto when piped)
All program logs are written to `stderr` and can be captured separately: JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes.
```bash ## Protocol
# Capture audio and logs separately
./audiotee > audio.pcm 2> audiotee.log
# View logs in real-time while capturing audio ### Message types
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
All messages (except raw binary audio chunks) follow this envelope structure:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "...",
"data": { ... }
}
``` ```
#### 1. Metadata
Sent once at startup to describe the audio format:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "metadata",
"data": {
"sample_rate": 48000,
"channels_per_frame": 1,
"bits_per_channel": 32,
"is_float": true,
"capture_mode": "audio",
"device_name": null,
"device_uid": null,
"encoding": "pcm_f32le"
}
}
```
#### 2. Stream start
Indicates audio data will follow:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "stream_start",
"data": null
}
```
#### 3. Audio data
**JSON format:**
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "audio",
"data": {
"timestamp": "2024-03-21T15:30:45.123Z",
"duration": 0.2,
"peak_amplitude": 0.45,
"audio_data": "base64_encoded_raw_audio..."
}
}
```
**Binary format:**
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "audio",
"data": {
"timestamp": "2024-03-21T15:30:45.123Z",
"duration": 0.2,
"peak_amplitude": 0.45,
"audio_length": 9600
}
}
```
_Followed immediately by 9600 bytes of raw binary audio data_
#### 4. Stream stop
Sent when recording stops:
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "stream_stop",
"data": null
}
```
#### 5. Log messages
Info, error, and debug messages (useful for monitoring):
```json
{
"timestamp": "2024-03-21T15:30:45.123Z",
"message_type": "info",
"data": {
"message": "Starting AudioTee...",
"context": { "output_format": "auto" }
}
}
```
### Consuming output
**JSON format:**
1. Parse each line as JSON using the envelope structure
2. Use `metadata` message to understand the audio format
3. For `audio` messages, decode `audio_data` from base64 to get raw PCM data
4. Do something with each chunk of data
**Binary format:**
1. Parse JSON metadata lines using the envelope structure
2. Use `metadata` message to understand the audio format
3. For `audio` messages, read `audio_length` bytes of raw binary data after the JSON line
4. Do something with each chunk of data
**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk.
## Command Line options ## Command Line options
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes) - `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none) - `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
- `--mute`: Mute processes being tapped - `--mute`: Mute processes being tapped
- `--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, 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. There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
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
## 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
-229
View File
@@ -1,229 +0,0 @@
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")
}
}
-209
View File
@@ -1,209 +0,0 @@
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
var flush: Bool = false
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
audiotee --flush # Flush stdout after each chunk
"""
)
// 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.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption(
name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
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.flush = parser.getFlag("flush")
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()
Logger.info("Starting AudioTee...")
// 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,
isMono: !stereo
)
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 = BinaryAudioOutputHandler(flushAfterWrite: flush)
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 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
}
}
}
-105
View File
@@ -1,105 +0,0 @@
import CoreAudio
import Foundation
public class AudioBuffer {
private var buffer: [UInt8]
private var writeIndex: Int = 0
private var readIndex: Int = 0
private var availableBytes: Int = 0
private let maxBufferSize: Int
private let bytesPerChunk: Int
private let chunkDuration: Double
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
// Pre-calculate chunk parameters
let bytesPerFrame = Int(format.mBytesPerFrame)
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
// Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
self.maxBufferSize = bytesPerSecond * 10
// Pre-allocated ring buffer
self.buffer = Array(repeating: 0, count: maxBufferSize)
}
public func append(_ data: Data) {
guard availableBytes + data.count <= maxBufferSize else {
Logger.error(
"Audio buffer overflow",
context: [
"requested": String(data.count),
"available": String(maxBufferSize - availableBytes),
])
return
}
data.withUnsafeBytes { bytes in
let sourceBytes = bytes.bindMemory(to: UInt8.self)
let dataSize = sourceBytes.count
// Check if we can copy in one block (no wrap-around)
if writeIndex + dataSize <= maxBufferSize {
// only one write needed
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
writeIndex = (writeIndex + dataSize) % maxBufferSize
} else {
// two writes needed due to wrap-around
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = secondChunkSize
}
}
availableBytes += data.count
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
private func nextChunk() -> AudioPacket? {
// Check if we have enough data for a complete chunk
guard availableBytes >= bytesPerChunk else { return nil }
var chunkData = Data(capacity: bytesPerChunk)
// Check if we can copy in one block (no wrap-around)
if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else {
// two copies needed due to wrap-around
let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
readIndex = secondChunkSize
}
availableBytes -= bytesPerChunk
return AudioPacket(
timestamp: Date(),
duration: chunkDuration,
data: chunkData
)
}
}
@@ -1,116 +0,0 @@
import AudioToolbox
import CoreAudio
import Foundation
public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
// First, wait for the device to become alive/ready
let deviceReadyTimeout = 2.0 // 2 seconds max wait
let pollInterval = 0.1 // 100ms poll interval
let maxPolls = Int(deviceReadyTimeout / pollInterval)
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) {
Logger.debug(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
Logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
"timeout_seconds": String(deviceReadyTimeout),
])
break
}
Logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
}
// Now attempt to get the stream format with limited retries
let maxRetries = 3 // Reduced since device should be ready
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
for attempt in 1...maxRetries {
var propertyAddress = getPropertyAddress(
selector: kAudioDevicePropertyStreamFormat,
scope: kAudioDevicePropertyScopeInput)
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
var streamFormat = AudioStreamBasicDescription()
let status = AudioObjectGetPropertyData(
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr {
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
}
Logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
"max_retries": String(maxRetries),
"status": String(status),
"device_id": String(deviceID),
])
// Don't delay on the last attempt
if attempt < maxRetries {
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
}
}
// If all attempts failed after device readiness confirmation, this is a genuine error
Logger.error(
"Failed to get device format after device readiness check and retries",
context: [
"device_id": String(deviceID),
"device_was_ready": "true",
])
fatalError(
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
)
}
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
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),
]
)
}
}
+161
View File
@@ -0,0 +1,161 @@
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)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
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)"
}
}
}
@@ -2,12 +2,10 @@ 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, isMono: Bool) { public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
self.processes = processes self.processes = processes
self.muteBehavior = muteBehavior self.muteBehavior = muteBehavior
self.isExclusive = isExclusive self.isExclusive = isExclusive
self.isMono = isMono
} }
} }
@@ -1,6 +1,7 @@
import ArgumentParser
import CoreAudio import CoreAudio
public enum TapMuteBehavior: String, CaseIterable { public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
case unmuted = "unmuted" case unmuted = "unmuted"
case muted = "muted" case muted = "muted"
+61
View File
@@ -0,0 +1,61 @@
import CoreAudio
import Foundation
public class AudioBuffer {
private var buffer = Data()
private let targetChunkDuration: Double
private let streamFormat: AudioStreamBasicDescription
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
self.streamFormat = format
self.targetChunkDuration = chunkDuration
}
public func append(_ data: Data) {
buffer.append(data)
}
public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = []
while let packet = nextChunk() {
packets.append(packet)
}
return packets
}
public func flushRemaining() -> AudioPacket? {
guard !buffer.isEmpty else { return nil }
let packet = AudioPacket(
timestamp: Date(),
duration: 0.0, // Unknown duration for final chunk
peakAmplitude: 0.0,
rawAudioData: buffer
)
buffer.removeAll()
return packet
}
private func nextChunk() -> AudioPacket? {
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
let bytesPerChunk = samplesPerChunk * bytesPerFrame
guard buffer.count >= bytesPerChunk else { return nil }
let chunkData = buffer.prefix(bytesPerChunk)
let packet = AudioPacket(
timestamp: Date(),
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
peakAmplitude: 0.0, // No analysis in raw mode
rawAudioData: Data(chunkData)
)
buffer.removeFirst(bytesPerChunk)
return packet
}
}
@@ -54,7 +54,12 @@ public class AudioFormatConverter {
} }
public func transform(_ packet: AudioPacket) -> AudioPacket { public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.data let inputData = packet.rawAudioData
// Short-circuit if no conversion needed
if sourceFormat.sampleRate == targetFormat.sampleRate {
return packet
}
// Calculate frame counts // Calculate frame counts
let inputFrameCount = let inputFrameCount =
@@ -119,10 +124,17 @@ public class AudioFormatConverter {
return AudioPacket( return AudioPacket(
timestamp: packet.timestamp, timestamp: packet.timestamp,
duration: packet.duration, duration: packet.duration,
data: outputData peakAmplitude: packet.peakAmplitude,
rawAudioData: outputData
) )
} }
}
// MARK: - Convenience Constructors
extension AudioFormatConverter {
/// Create a converter to a specific sample rate with mono PCM 16-bit output
/// Since the tap already converts to mono, we hardcode channels to 1
public static func toSampleRate( public static func toSampleRate(
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription _ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
) throws -> AudioFormatConverter { ) throws -> AudioFormatConverter {
@@ -130,17 +142,26 @@ public class AudioFormatConverter {
targetFormat.mSampleRate = sampleRate targetFormat.mSampleRate = sampleRate
targetFormat.mFormatID = kAudioFormatLinearPCM targetFormat.mFormatID = kAudioFormatLinearPCM
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
targetFormat.mBytesPerPacket = 2
targetFormat.mFramesPerPacket = 1 targetFormat.mFramesPerPacket = 1
targetFormat.mBytesPerFrame = 2
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
targetFormat.mBitsPerChannel = 16 targetFormat.mBitsPerChannel = 16
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerFrame =
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat) return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
} }
/// Common sample rates for validation
public static let supportedSampleRates: [Double] = [
8000, 16000, 22050, 24000, 32000, 44100, 48000,
]
/// Validate if a sample rate is supported
public static func isValidSampleRate(_ sampleRate: Double) -> Bool { public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate) return supportedSampleRates.contains(sampleRate)
} }
} }
// MARK: - Error Types
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
+54
View File
@@ -0,0 +1,54 @@
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),
]
)
}
}
@@ -3,15 +3,18 @@ import Foundation
public struct AudioPacket { public struct AudioPacket {
public let timestamp: Date public let timestamp: Date
public let duration: Double public let duration: Double
public let data: Data public let peakAmplitude: Float // useful for level monitoring
public let rawAudioData: Data
public init( public init(
timestamp: Date, timestamp: Date,
duration: Double, duration: Double,
data: Data peakAmplitude: Float,
rawAudioData: Data
) { ) {
self.timestamp = timestamp self.timestamp = timestamp
self.duration = duration self.duration = duration
self.data = data self.peakAmplitude = peakAmplitude
self.rawAudioData = rawAudioData
} }
} }
@@ -5,23 +5,24 @@ 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 finalFormat: AudioStreamBasicDescription! private var sourceFormat: 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
public init( init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil, deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2 chunkDuration: Double = 0.2
) { ) {
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 = 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
@@ -51,22 +52,29 @@ public class AudioRecorder {
} }
} }
public func startRecording() { func startRecording() {
Logger.debug("Starting audio recording") Logger.debug("Starting audio recording")
// Log format info and send metadata for final format guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
fatalError("Audio formats not initialized")
}
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
// Log format info and send metadata for FINAL format
AudioFormatManager.logFormatInfo(finalFormat) 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
setupAndStartIOProc() setupAndStartIOProc()
Logger.info("Audio device started successfully") Logger.info("Audio device started successfully")
} }
// Note to self, what about installTap? Would require audio engine and a node? // FIXME: note to self, what about installTap? Would require audio engine and a node?
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
private func setupAndStartIOProc() { private func setupAndStartIOProc() {
Logger.debug("Creating IO proc") Logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID( var status = AudioDeviceCreateIOProcID(
@@ -107,23 +115,24 @@ public class AudioRecorder {
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
audioBuffer?.append(audioData) audioBuffer?.append(audioData)
processAudioBuffer()
return noErr
}
public func stopRecording() {
processAudioBuffer()
outputHandler.handleStreamStop()
cleanupIOProc()
}
private func processAudioBuffer() {
// Process and send complete chunks, applying conversion if needed // Process and send complete chunks, applying conversion if needed
audioBuffer?.processChunks().forEach { packet in audioBuffer?.processChunks().forEach { packet in
let processedPacket = converter?.transform(packet) ?? packet let processedPacket = converter?.transform(packet) ?? packet
outputHandler.handleAudioPacket(processedPacket) outputHandler.handleAudioPacket(processedPacket)
} }
return noErr
}
func stopRecording() {
// Send any remaining buffered audio, applying conversion if needed
if let finalPacket = audioBuffer?.flushRemaining() {
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
outputHandler.handleAudioPacket(processedPacket)
}
outputHandler.handleStreamStop()
cleanupIOProc()
} }
private func cleanupIOProc() { private func cleanupIOProc() {
@@ -3,12 +3,14 @@ import AudioToolbox
import CoreAudio import CoreAudio
import Foundation import Foundation
public class AudioTapManager { class AudioTapManager {
private var tapID: AudioObjectID? private var tapID: AudioObjectID?
private var deviceID: AudioObjectID? private var deviceID: AudioObjectID?
public init() {} init() {
// Empty init - setup happens in setupAudioTap()
}
deinit { deinit {
Logger.debug("Cleaning up audio tap manager") Logger.debug("Cleaning up audio tap manager")
@@ -24,7 +26,7 @@ public class AudioTapManager {
} }
/// Sets up the audio tap and aggregate device /// Sets up the audio tap and aggregate device
public func setupAudioTap(with config: TapConfiguration) throws { func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager") Logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config) tapID = try createSystemAudioTap(with: config)
@@ -40,30 +42,34 @@ public class AudioTapManager {
} }
/// Returns the aggregate device ID for recording /// Returns the aggregate device ID for recording
public func getDeviceID() -> AudioObjectID? { 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") 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 = config.isMono description.isMono = true
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( 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),
]) ])
@@ -147,4 +153,4 @@ public class AudioTapManager {
throw AudioTeeError.tapAssignmentFailed(status) throw AudioTeeError.tapAssignmentFailed(status)
} }
} }
} }
@@ -1,3 +1,4 @@
import Foundation import Foundation
/// Protocol for handling audio output in different formats /// Protocol for handling audio output in different formats
@@ -0,0 +1,31 @@
import Foundation
/// Auto-detecting output handler based on TTY
public class AutoAudioOutputHandler: AudioOutputHandler {
private let handler: AudioOutputHandler
public init() {
// Auto-detect based on whether stdout is a terminal
if isatty(STDOUT_FILENO) != 0 {
handler = JSONAudioOutputHandler()
} else {
handler = BinaryAudioOutputHandler()
}
}
public func handleAudioPacket(_ packet: AudioPacket) {
handler.handleAudioPacket(packet)
}
public func handleMetadata(_ metadata: AudioStreamMetadata) {
handler.handleMetadata(metadata)
}
public func handleStreamStart() {
handler.handleStreamStart()
}
public func handleStreamStop() {
handler.handleStreamStop()
}
}
@@ -1,17 +1,18 @@
import Foundation import Foundation
/// Binary output with JSON headers (pipe-optimised)
public class BinaryAudioOutputHandler: AudioOutputHandler { public class BinaryAudioOutputHandler: AudioOutputHandler {
private let flushAfterWrite: Bool public init() {}
public init(flushAfterWrite: Bool = false) {
self.flushAfterWrite = flushAfterWrite
}
public func handleAudioPacket(_ packet: AudioPacket) { public func handleAudioPacket(_ packet: AudioPacket) {
// Create metadata without the audio data
let metadata = BinaryPacketHeader(from: packet)
// Write JSON metadata line
Logger.writeMessage(.audio, data: metadata)
// Write raw binary audio data directly to stdout // Write raw binary audio data directly to stdout
FileHandle.standardOutput.write(packet.data) FileHandle.standardOutput.write(packet.rawAudioData)
if flushAfterWrite {
fflush(stdout)
}
} }
public func handleMetadata(_ metadata: AudioStreamMetadata) { public func handleMetadata(_ metadata: AudioStreamMetadata) {
@@ -0,0 +1,23 @@
import Foundation
/// Base64-encoded JSON output (terminal-safe)
public class JSONAudioOutputHandler: AudioOutputHandler {
public init() {}
public func handleAudioPacket(_ packet: AudioPacket) {
let jsonPacket = JSONAudioPacket(from: packet)
Logger.writeMessage(.audio, data: jsonPacket)
}
public func handleMetadata(_ metadata: AudioStreamMetadata) {
Logger.writeMessage(.metadata, data: metadata)
}
public func handleStreamStart() {
Logger.writeMessage(.streamStart, data: Optional<String>.none)
}
public func handleStreamStop() {
Logger.writeMessage(.streamStop, data: Optional<String>.none)
}
}
@@ -7,6 +7,9 @@ public enum MessageType: String, Codable {
case streamStart = "stream_start" case 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
+45
View File
@@ -0,0 +1,45 @@
import Foundation
/// JSON-serializable version of AudioPacket with base64-encoded audio data
public struct JSONAudioPacket: Codable {
public let timestamp: Date
public let duration: Double
public let peakAmplitude: Float
public let audioData: String // base64 encoded audio data
public enum CodingKeys: String, CodingKey {
case timestamp
case duration
case peakAmplitude = "peak_amplitude"
case audioData = "audio_data"
}
public init(from packet: AudioPacket) {
self.timestamp = packet.timestamp
self.duration = packet.duration
self.peakAmplitude = packet.peakAmplitude
self.audioData = packet.rawAudioData.base64EncodedString()
}
}
/// Metadata-only packet for binary output (without base64 audio data)
public struct BinaryPacketHeader: Codable {
public let timestamp: Date
public let duration: Double
public let peakAmplitude: Float
public let audioLength: Int // Length of raw audio data in bytes
public enum CodingKeys: String, CodingKey {
case timestamp
case duration
case peakAmplitude = "peak_amplitude"
case audioLength = "audio_length"
}
public init(from packet: AudioPacket) {
self.timestamp = packet.timestamp
self.duration = packet.duration
self.peakAmplitude = packet.peakAmplitude
self.audioLength = packet.rawAudioData.count
}
}
@@ -24,8 +24,8 @@ public class Logger {
let message = Message(type: type, data: data) let message = Message(type: type, data: data)
do { do {
let jsonData = try jsonEncoder.encode(message) let jsonData = try jsonEncoder.encode(message)
FileHandle.standardError.write(jsonData) FileHandle.standardOutput.write(jsonData)
FileHandle.standardError.write("\n".data(using: .utf8)!) FileHandle.standardOutput.write("\n".data(using: .utf8)!)
} catch { } catch {
// TODO: handle at some point // TODO: handle at some point
} }
@@ -89,9 +89,9 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
} }
extension String { extension String {
func print(to fileHandle: FileHandle) { func print(to fileHandle: FileHandle) {
if let data = (self + "\n").data(using: .utf8) { if let data = (self + "\n").data(using: .utf8) {
fileHandle.write(data) fileHandle.write(data)
}
} }
} }
}
@@ -1,5 +1,5 @@
import AudioTeeCore import ArgumentParser
import AudioToolbox import AudioToolbox
import Foundation import Foundation
AudioTee.main() AudioTee.main()
@@ -1,30 +0,0 @@
import XCTest
@testable import AudioTeeCore
final class AudioPacketTests: XCTestCase {
func testPacketCreation() {
let timestamp = Date()
let duration = 1.0
let data = Data([0x01, 0x02, 0x03, 0x04])
let packet = AudioPacket(
timestamp: timestamp,
duration: duration,
data: data
)
XCTAssertEqual(packet.timestamp, timestamp)
XCTAssertEqual(packet.duration, duration)
XCTAssertEqual(packet.data, data)
}
func testPacketDataSize() {
let packet = AudioPacket(
timestamp: Date(),
duration: 0.5,
data: Data(repeating: 0xFF, count: 1024)
)
XCTAssertEqual(packet.data.count, 1024)
}
}
+119
View File
@@ -0,0 +1,119 @@
# 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
View File
@@ -0,0 +1,173 @@
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
+69
View File
@@ -0,0 +1,69 @@
# 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/
+57
View File
@@ -0,0 +1,57 @@
# 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/
+238
View File
@@ -0,0 +1,238 @@
# 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
+21
View File
@@ -0,0 +1,21 @@
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.
+297
View File
@@ -0,0 +1,297 @@
# 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
+196
View File
@@ -0,0 +1,196 @@
#!/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");
}
+15
View File
@@ -0,0 +1,15 @@
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,
};
+298
View File
@@ -0,0 +1,298 @@
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;
+61
View File
@@ -0,0 +1,61 @@
{
"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>"
}
+88
View File
@@ -0,0 +1,88 @@
#!/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 };
+142
View File
@@ -0,0 +1,142 @@
#!/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;
}