8 Commits

Author SHA1 Message Date
Nick Payne c696fa0197 Merge branch 'main' into readiness-test 2025-07-04 08:53:17 +01:00
Nick Payne a0902f6eec remove swift-argument-parser - it was adding well over 1Mb of bloat to release builds 2025-07-02 20:58:28 +01:00
Nick Payne b7ce5e61d8 Merge branch 'main' into readiness-test 2025-07-02 16:48:35 +01:00
Nick Payne d10f16a119 if a sample rate conversion is requested, always honour it 2025-07-02 16:48:28 +01:00
Nick Payne a7be157d06 attempt to wait for device readiness 2025-07-02 16:13:38 +01:00
Nick Payne bfce968fb6 slightly better zeroes explanation. 2025-06-21 09:52:09 +01:00
Nick Payne 95f11b17c7 add a note about no audio zero encoding 2025-06-21 09:44:39 +01:00
Nick Payne 756c1b3535 a few readme clarifications 2025-06-21 09:41:20 +01:00
23 changed files with 431 additions and 1873 deletions
-15
View File
@@ -1,15 +0,0 @@
{
"originHash" : "5f2b81278809343fed36ed8c17e7d6930bfd5b85261cdf5dadb17ab7ffdfc0e3",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b",
"version" : "1.5.1"
}
}
],
"version" : 3
}
+1 -11
View File
@@ -8,17 +8,7 @@ let package = Package(
platforms: [
.macOS("14.2")
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
],
targets: [
// 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.
.executableTarget(
name: "audiotee",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
)
.executableTarget(name: "audiotee")
]
)
+12 -3
View File
@@ -1,12 +1,12 @@
# AudioTee
AudioTee captures your Mac's system audio output and writes PCM encoded chunks of it to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
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.
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` 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.
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
@@ -16,12 +16,16 @@ Recording system audio is harder than it should be on macOS, and folks often wre
## Quick start
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms:
```bash
git clone git@github.com:makeusabrew/audiotee.git
cd audiotee
swift run
```
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes.
## Build
```bash
@@ -48,6 +52,9 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
### Audio conversion
Note that performing sample rate conversion will also convert the output bit depth to
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
```bash
# Convert to 16kHz mono (useful for ASR services)
./audiotee --sample-rate 16000
@@ -60,6 +67,8 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
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
# Tap all system audio (default)
./audiotee
+234
View File
@@ -0,0 +1,234 @@
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
} else if type == OutputFormat.self {
guard let format = OutputFormat(rawValue: value) else {
throw ArgumentParserError.invalidValue(optionName, value)
}
return format as! T
}
throw ArgumentParserError.invalidValue(optionName, value)
}
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")
}
}
+109 -48
View File
@@ -1,63 +1,100 @@
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")
struct AudioTee {
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
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.
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
"""
)
// Configure arguments
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
parser.addArrayOption(
name: "include-processes",
help: "Process IDs to include (space-separated, empty = all processes)")
parser.addArrayOption(
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.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.format = try parser.getValue("format", as: OutputFormat.self)
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute")
audioTee.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 ValidationError("Cannot specify both --include-processes and --exclude-processes")
throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes")
}
}
@@ -159,3 +196,27 @@ struct AudioTee: ParsableCommand {
}
}
}
// Helper for stderr output
var standardError = FileHandle.standardError
extension FileHandle: @retroactive 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
}
}
}
+1 -3
View File
@@ -1,6 +1,4 @@
import ArgumentParser
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
enum OutputFormat: String, CaseIterable {
case json = "json"
case binary = "binary"
case auto = "auto"
+1 -2
View File
@@ -1,7 +1,6 @@
import ArgumentParser
import CoreAudio
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
public enum TapMuteBehavior: String, CaseIterable {
case unmuted = "unmuted"
case muted = "muted"
-5
View File
@@ -56,11 +56,6 @@ public class AudioFormatConverter {
public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.rawAudioData
// Short-circuit if no conversion needed
if sourceFormat.sampleRate == targetFormat.sampleRate {
return packet
}
// Calculate frame counts
let inputFrameCount =
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
+72 -10
View File
@@ -4,19 +4,81 @@ 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)
// 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)
guard status == noErr else {
fatalError("Failed to get stream format: \(status)")
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)
}
return streamFormat
// 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 {
+1 -2
View File
@@ -1,5 +1,4 @@
import ArgumentParser
import AudioToolbox
import Foundation
AudioTee.main()
AudioTee.main()
-119
View File
@@ -1,119 +0,0 @@
# AudioTee.js - Node.js Audio Streaming Package
This is a Node.js wrapper for AudioTee that provides a streaming interface to capture macOS system audio using Core Audio taps.
## Project Context
- **Purpose**: Node.js package that wraps the AudioTee Swift binary via child processes
- **Target**: macOS 14.2+ with Node.js 14+
- **Use cases**: Real-time audio processing, ASR integration, Electron apps
- **Distribution**: npm package using node-pre-gyp for binary distribution
## Architecture
- `index.js` - Main entry point, uses node-pre-gyp to locate binary
- `lib/AudioTeeStream.js` - Core streaming class that wraps AudioTee process
- `scripts/build.js` - Build script that copies AudioTee binary for distribution
- `test/test.js` - Test suite demonstrating usage
## Technical Standards
### Code Style
- Use functional programming patterns where possible
- Prefer `const` over `let`, avoid `var`
- Use arrow functions for callbacks and short functions
- No semicolons at end of lines (per user preference)
- Use template literals for string interpolation
- Prefer British English spelling (colour, realise, etc.)
### Node.js Specific
- Use EventEmitter pattern for streaming interfaces
- Handle child process lifecycle carefully (spawn, kill, cleanup)
- Use Buffer for binary data, not Uint8Array
- Implement proper error handling with descriptive messages
- Use readline interface for line-based protocol parsing
- Handle both JSON and binary protocol modes correctly
### Error Handling
- Always emit errors via EventEmitter, don't throw synchronously
- Provide context in error messages (PIDs, file paths, etc.)
- Handle child process errors gracefully
- Validate input parameters and provide helpful error messages
### Protocol Implementation
- Correctly parse the mixed JSON/binary protocol from AudioTee
- Handle partial reads and buffer management for binary mode
- Emit events in the correct order (metadata → stream_start → audio → stream_stop)
- Preserve AudioTee's timestamp and metadata information
### Dependencies
- Minimize external dependencies (currently only node-pre-gyp)
- Use only Node.js built-in modules where possible
- Ensure compatibility with Node.js 14+ (no newer APIs)
### Documentation
- Comprehensive JSDoc comments for public APIs
- Examples in README showing real-world usage patterns
- Clear event documentation with payload structure
- Error scenarios and troubleshooting guidance
### Testing
- Provide both interactive and automated test modes
- Test should work without requiring audio playback
- Handle permissions issues gracefully in tests
- Verify binary protocol parsing works correctly
## Binary Distribution
- Use node-pre-gyp for professional binary distribution
- Support both Intel and Apple Silicon Macs
- Graceful fallback if binary download fails
- Verify binary functionality during build process
## Development Guidelines
When making changes:
1. **Test thoroughly** - Both JSON and binary protocols
2. **Handle edge cases** - Process crashes, permission issues, etc.
3. **Maintain compatibility** - Don't break existing APIs
4. **Update documentation** - Keep README examples current
5. **Follow semantic versioning** - Breaking changes require major version bump
## Common Patterns
### Error Handling
```javascript
// Always emit errors, don't throw
this.emit('error', new Error(`Descriptive message: ${details}`))
// Provide context in errors
this.emit('error', new Error(`Failed to start AudioTee: ${error.message}`))
```
### Event Emission
```javascript
// Use consistent event structure
this.emit('audio', {
timestamp: new Date(),
duration: number,
peakAmplitude: number,
audioData: Buffer
})
```
### Process Management
```javascript
// Always check process state before operations
if (this.process && !this.process.killed) {
this.process.kill('SIGTERM')
}
```
## Future Considerations
- Support for multiple concurrent streams
- WebSocket streaming interface
- TypeScript definitions
- React/Vue.js integration examples
- Performance monitoring and metrics
-173
View File
@@ -1,173 +0,0 @@
name: Build and Release AudioTee.js
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., v1.0.0)"
required: true
default: "v1.0.0"
jobs:
build:
strategy:
matrix:
include:
- os: macos-latest
arch: arm64
node: "18"
- os: macos-13
arch: x64
node: "18"
runs-on: ${{ matrix.os }}
steps:
- name: Checkout audiotee-js
uses: actions/checkout@v4
- name: Checkout AudioTee (parent project)
uses: actions/checkout@v4
with:
repository: your-org/audiotee
path: audiotee
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
registry-url: "https://registry.npmjs.org"
- name: Setup Swift
uses: swift-actions/setup-swift@v1
with:
swift-version: "5.9"
- name: Build AudioTee binary
run: |
cd audiotee
swift build -c release
ls -la .build/release/
- name: Install Node.js dependencies
run: |
npm ci
- name: Build AudioTee.js package
env:
AUDIOTEE_BINARY_PATH: ../audiotee/.build/release/audiotee
run: |
npm run build
ls -la bin/
- name: Test package
run: |
# Quick test to ensure the package loads and binary works
timeout 10s npm test quick || true
- name: Package binary for distribution
run: |
npm run package
- name: List package contents
run: |
ls -la build/
- name: Publish binary to GitHub releases
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run publish-binary
publish-npm:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'release'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Update version to match release tag
npm version ${{ github.event.release.tag_name }} --no-git-tag-version
npm publish
test-installation:
needs: publish-npm
runs-on: macos-latest
if: github.event_name == 'release'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Test npm installation
run: |
# Test that the package can be installed and works
npm install audiotee-js@${{ github.event.release.tag_name }}
# Create a simple test
cat > test-install.js << 'EOF'
const { AudioTeeStream } = require('audiotee-js');
console.log('✅ Package imported successfully');
const stream = new AudioTeeStream({
format: 'json',
chunkDuration: 0.1
});
console.log('✅ AudioTeeStream created successfully');
let metadataReceived = false;
stream.on('metadata', () => {
metadataReceived = true;
console.log('✅ Metadata received');
stream.stop();
});
stream.on('error', (error) => {
console.error('❌ Error:', error.message);
process.exit(1);
});
stream.on('close', () => {
if (metadataReceived) {
console.log('✅ Installation test passed!');
process.exit(0);
} else {
console.error('❌ No metadata received');
process.exit(1);
}
});
setTimeout(() => {
console.error('❌ Test timeout');
stream.stop();
process.exit(1);
}, 5000);
console.log('🚀 Starting AudioTee...');
stream.start();
EOF
node test-install.js
-69
View File
@@ -1,69 +0,0 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime
*.log
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Compiled binary
bin/
build/
lib-cov/
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Environment variables
.env
.env.test
.env.local
.env.*.local
# Mac system files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Test recordings
*.raw
*.wav
test-recordings/
# Temporary files
tmp/
temp/
-57
View File
@@ -1,57 +0,0 @@
# Source control
.git/
.gitignore
# Development files
.cursorrules
.github/
test/
coverage/
*.test.js
# Build artifacts (included via files in package.json)
build/
.build/
# Dependencies
node_modules/
# Documentation (README.md is included via package.json files)
docs/
examples/
# Environment and config files
.env*
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime
*.pid
*.seed
*.pid.lock
# Mac files
.DS_Store
.DS_Store?
._*
# Test output
test-recordings/
*.raw
*.wav
# Temporary files
tmp/
temp/
# CI/CD artifacts that aren't needed in the package
.github/workflows/
-238
View File
@@ -1,238 +0,0 @@
# AudioTee.js Development Guide
This guide covers setting up the development environment and workflow for AudioTee.js.
## Project Structure
```
audiotee-js/
├── package.json # npm package configuration with node-pre-gyp
├── index.js # Main entry point, uses node-pre-gyp to find binary
├── lib/
│ └── AudioTeeStream.js # Core streaming class
├── scripts/
│ └── build.js # Build script that copies AudioTee binary
├── test/
│ └── test.js # Interactive and automated tests
├── examples/
│ └── basic-usage.js # Usage examples and demos
├── .github/workflows/
│ └── release.yml # CI/CD for automated releases
└── README.md # User documentation
```
## Initial Setup
### 1. Clone and Install Dependencies
```bash
git clone <your-audiotee-js-repo>
cd audiotee-js
npm install
```
### 2. Build AudioTee Binary
You'll need the AudioTee Swift project to build the binary:
```bash
# Option A: If AudioTee is in parent directory (current setup)
cd ../audiotee
swift build -c release
cd ../audiotee-js
# Option B: If AudioTee is elsewhere, set the path
export AUDIOTEE_BINARY_PATH=/path/to/audiotee/.build/release/audiotee
```
### 3. Build the Package
```bash
npm run build
```
This copies the AudioTee binary to `bin/audiotee` and makes it executable.
## Development Workflow
### Testing
```bash
# Interactive test - requires audio playback
npm test
# Quick automated test
npm test quick
# Run examples
node examples/basic-usage.js
node examples/basic-usage.js 2 # Save to file example
```
### Building for Different Architectures
```bash
# For Intel Macs (if you have access)
npm run build
# For Apple Silicon (if you have access)
npm run build
# Clean build artifacts
npm run clean
```
### Testing the Package Locally
```bash
# Test the package as if installed from npm
npm pack
npm install -g audiotee-js-1.0.0.tgz
# Test in another directory
cd /tmp
node -e "const { AudioTeeStream } = require('audiotee-js'); console.log('✅ Works!')"
```
## Release Process
### 1. Prepare Release
1. Update version in `package.json`
2. Update `CHANGELOG.md` (if you add one)
3. Test thoroughly on both Intel and Apple Silicon if possible
4. Commit changes
### 2. Create GitHub Release
```bash
git tag v1.0.0
git push origin v1.0.0
```
Then create a release on GitHub. This will trigger the automated build process.
### 3. Automated Release (via GitHub Actions)
The workflow will:
1. Build AudioTee binary for Intel and Apple Silicon
2. Package binaries using node-pre-gyp
3. Upload binaries to GitHub releases
4. Publish package to npm
5. Test the published package
### 4. Manual Release (if needed)
```bash
# Build and package
npm run build
npm run package
# Publish binary to GitHub releases
npm run publish-binary
# Publish to npm
npm publish
```
## Configuration
### Environment Variables
- `AUDIOTEE_BINARY_PATH` - Path to AudioTee binary for building
- `GITHUB_TOKEN` - For publishing binaries to GitHub releases
- `NODE_AUTH_TOKEN` - For publishing to npm
### node-pre-gyp Configuration
The binary distribution is configured in `package.json`:
```json
{
"binary": {
"module_name": "audiotee",
"module_path": "./bin/",
"remote_path": "v{version}/",
"package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz",
"host": "https://github.com/your-org/audiotee-js/releases/download/"
}
}
```
Update the `host` URL to match your repository.
## Troubleshooting
### Binary Not Found During Build
```bash
# Check if AudioTee is built
ls -la ../audiotee/.build/release/audiotee
# Or set custom path
export AUDIOTEE_BINARY_PATH=/path/to/your/audiotee/binary
npm run build
```
### Permission Issues
```bash
# Make sure binary is executable
chmod +x bin/audiotee
# Check binary works
./bin/audiotee --help
```
### node-pre-gyp Issues
```bash
# Clear cache
npm run clean
rm -rf node_modules
npm install
# Debug node-pre-gyp
DEBUG=node-pre-gyp npm run package
```
## Code Style
- Follow the patterns in `.cursorrules`
- Use functional programming where possible
- No semicolons (per project preference)
- Handle errors via EventEmitter, don't throw
- Use British English in documentation
- Comprehensive JSDoc for public APIs
## Testing Checklist
Before releasing:
- [ ] Basic audio capture works
- [ ] Both JSON and binary formats work
- [ ] Sample rate conversion works
- [ ] Process filtering works (if testable)
- [ ] Error handling works (invalid args, missing binary, etc.)
- [ ] Package installs and works on clean system
- [ ] Examples in README work
- [ ] CI/CD builds successfully
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Update documentation
6. Submit a pull request
## Publishing Checklist
- [ ] Version updated in package.json
- [ ] Tests pass
- [ ] Documentation updated
- [ ] GitHub release created
- [ ] CI/CD completed successfully
- [ ] npm package published
- [ ] Installation test passes
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 AudioTee.js Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-297
View File
@@ -1,297 +0,0 @@
# AudioTee.js
Node.js wrapper for [AudioTee](https://github.com/your-org/audiotee) - capture macOS system audio using Core Audio taps.
AudioTee.js provides a streaming interface to capture system audio in real-time, perfect for building applications that need to process audio from any running application on macOS.
## Features
- 🎵 **Real-time system audio capture** using Core Audio taps
- 📦 **Streaming interface** with Node.js EventEmitter API
- ⚡ **High performance** binary protocol support
- 🎛️ **Flexible configuration** - sample rates, chunk sizes, process filtering
- 🔇 **Process-specific capture** - include/exclude specific applications
- 📊 **Audio metadata** - format information and level monitoring
- 🛡️ **Error handling** - graceful failure and process management
## Requirements
- **macOS 14.2+** (Sonoma or later)
- **Node.js 14+**
- **Audio recording permissions** (you'll be prompted on first use)
## Installation
```bash
npm install audiotee-js
```
The package will automatically download the appropriate AudioTee binary for your system during installation.
## Quick Start
```javascript
const { AudioTeeStream } = require('audiotee-js');
// Create a stream with 16kHz sample rate (great for ASR)
const stream = new AudioTeeStream({
sampleRate: 16000,
format: 'binary',
chunkDuration: 0.2
});
// Listen for audio metadata
stream.on('metadata', (metadata) => {
console.log('Audio format:', metadata);
});
// Process audio chunks
stream.on('audio', (packet) => {
console.log(`Received ${packet.audioData.length} bytes of audio`);
// packet.audioData is a Buffer containing raw PCM data
// packet.timestamp, packet.duration, packet.peakAmplitude also available
});
// Handle errors
stream.on('error', (error) => {
console.error('AudioTee error:', error);
});
// Start capturing
stream.start();
// Stop when done
// stream.stop();
```
## API Reference
### AudioTeeStream
The main class for capturing system audio.
#### Constructor
```javascript
new AudioTeeStream(options)
```
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `format` | `string` | `'binary'` | Output format: `'json'`, `'binary'`, or `'auto'` |
| `sampleRate` | `number` | `undefined` | Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) |
| `chunkDuration` | `number` | `0.2` | Audio chunk duration in seconds (max 5.0) |
| `includeProcesses` | `number[]` | `[]` | Process IDs to capture (empty = all processes) |
| `excludeProcesses` | `number[]` | `[]` | Process IDs to exclude |
| `mute` | `boolean` | `false` | Mute processes being captured |
| `binaryPath` | `string` | `auto` | Custom path to AudioTee binary |
#### Methods
- **`start()`** - Start audio capture, returns `this` for chaining
- **`stop()`** - Stop audio capture
- **`isActive()`** - Returns `true` if currently capturing
- **`getMetadata()`** - Returns audio metadata (available after `metadata` event)
#### Events
- **`metadata`** - Audio format information
- **`stream_start`** - Capture has started
- **`audio`** - Audio data packet
- **`stream_stop`** - Capture has stopped
- **`log`** - Log messages from AudioTee
- **`error`** - Error occurred
- **`close`** - Process has closed
### Audio Packet Format
Audio events receive packets with this structure:
```javascript
{
timestamp: Date, // When this audio was captured
duration: number, // Duration in seconds
peakAmplitude: number, // Peak amplitude (0.0 - 1.0)
audioData: Buffer // Raw PCM audio data
}
```
### Metadata Format
Metadata events provide audio format information:
```javascript
{
sample_rate: number, // e.g. 48000
channels_per_frame: number,// Always 1 (mono)
bits_per_channel: number, // e.g. 32
is_float: boolean, // true for float32, false for int16
encoding: string, // e.g. "pcm_f32le"
capture_mode: string, // "audio"
device_name: string|null, // Audio device name
device_uid: string|null // Audio device UID
}
```
## Examples
### Basic Recording
```javascript
const { AudioTeeStream } = require('audiotee-js');
const stream = new AudioTeeStream();
stream.on('metadata', console.log);
stream.on('audio', (packet) => {
console.log(`${packet.audioData.length} bytes, peak: ${packet.peakAmplitude}`);
});
stream.start();
```
### Save to WAV File
```javascript
const fs = require('fs');
const { AudioTeeStream } = require('audiotee-js');
const stream = new AudioTeeStream({
sampleRate: 44100,
format: 'binary'
});
const output = fs.createWriteStream('recording.raw');
stream.on('audio', (packet) => {
output.write(packet.audioData);
});
stream.start();
// Stop after 10 seconds
setTimeout(() => {
stream.stop();
output.end();
}, 10000);
```
### Process-Specific Capture
```javascript
const { AudioTeeStream } = require('audiotee-js');
// Only capture audio from Spotify (you'd need to find Spotify's PID)
const spotifyPID = 1234; // Use Activity Monitor or `pgrep Spotify`
const stream = new AudioTeeStream({
includeProcesses: [spotifyPID],
mute: true // Don't play through speakers
});
stream.on('audio', (packet) => {
// Only Spotify's audio will be captured
console.log('Spotify audio:', packet.audioData.length, 'bytes');
});
stream.start();
```
### Real-time ASR Integration
```javascript
const { AudioTeeStream } = require('audiotee-js');
const stream = new AudioTeeStream({
sampleRate: 16000, // Common ASR sample rate
chunkDuration: 0.1, // Faster chunks for real-time
format: 'binary'
});
stream.on('audio', async (packet) => {
// Send to your ASR service
const transcript = await sendToASR(packet.audioData);
if (transcript) {
console.log('Transcription:', transcript);
}
});
stream.start();
```
## Testing
Run the included test to verify everything works:
```bash
# Basic interactive test
npm test
# Quick automated test
npm test quick
```
The test will capture audio for a few seconds and display statistics.
## Troubleshooting
### Permission Denied
AudioTee requires microphone permissions. You'll see a system dialog on first use - make sure to allow access.
### Binary Not Found
If you see "AudioTee binary not found", try rebuilding:
```bash
npm run build
```
### No Audio Captured
- Check that audio is actually playing on your system
- Verify you have the latest macOS version (14.2+)
- Try running the Swift AudioTee directly to isolate the issue
## Development
### Building from Source
```bash
# Clone and build the parent AudioTee project first
git clone https://github.com/your-org/audiotee.git
cd audiotee
swift build -c release
# Then build the Node.js package
cd audiotee-js
npm install
npm run build
```
### Testing Changes
```bash
npm test # Run basic test
npm run lint # Check code style
npm run clean # Clean build artifacts
```
## Performance Notes
- **Binary format** is more efficient than JSON for high-throughput applications
- **Lower chunk durations** increase CPU usage but reduce latency
- **Sample rate conversion** adds processing overhead - use native rates when possible
- The AudioTee binary uses real-time audio threads for minimal latency
## License
MIT License - see [LICENSE](LICENSE) file.
## Related Projects
- [AudioTee](https://github.com/your-org/audiotee) - The underlying Swift CLI tool
- [node-core-audio](https://github.com/ZECTBynmo/node-core-audio) - Alternative Node.js audio library
- [AudioCap](https://github.com/insidegui/AudioCap) - macOS audio capture inspiration
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env node
/**
* Basic AudioTee.js Usage Examples
*
* This file demonstrates common patterns for using AudioTee.js
* Run with: node examples/basic-usage.js
*/
const { AudioTeeStream } = require("../index");
const fs = require("fs");
// Example 1: Basic audio capture with console output
function basicCapture() {
console.log("=== Example 1: Basic Audio Capture ===\n");
const stream = new AudioTeeStream({
format: "binary",
sampleRate: 16000, // Good for speech recognition
chunkDuration: 0.2,
});
let packetCount = 0;
let totalBytes = 0;
stream.on("metadata", (metadata) => {
console.log("🎵 Audio metadata:");
console.log(` Sample rate: ${metadata.sample_rate} Hz`);
console.log(` Encoding: ${metadata.encoding}`);
console.log("");
});
stream.on("audio", (packet) => {
packetCount++;
totalBytes += packet.audioData.length;
// Show real-time stats
process.stdout.write(
`\r📊 Packets: ${packetCount}, Bytes: ${totalBytes}, Peak: ${packet.peakAmplitude.toFixed(
3
)}`
);
});
stream.on("error", (error) => {
console.error("\n❌ Error:", error.message);
process.exit(1);
});
// Auto-stop after 5 seconds for demo
setTimeout(() => {
console.log("\n\n✅ Stopping capture...");
stream.stop();
}, 5000);
console.log("🚀 Starting capture (will run for 5 seconds)...");
stream.start();
}
// Example 2: Save audio to file
function saveToFile() {
console.log("\n=== Example 2: Save Audio to File ===\n");
const stream = new AudioTeeStream({
format: "binary",
sampleRate: 44100, // CD quality
chunkDuration: 0.1,
});
const outputFile = "recording.raw";
const writeStream = fs.createWriteStream(outputFile);
stream.on("metadata", (metadata) => {
console.log(`💾 Saving ${metadata.encoding} audio to ${outputFile}`);
console.log(
` Format: ${metadata.sample_rate}Hz, ${metadata.bits_per_channel}-bit`
);
});
stream.on("audio", (packet) => {
// Write raw audio data to file
writeStream.write(packet.audioData);
});
stream.on("stream_stop", () => {
writeStream.end();
console.log(`\n✅ Saved audio to ${outputFile}`);
// Show file size
const stats = fs.statSync(outputFile);
console.log(`📈 File size: ${(stats.size / 1024).toFixed(1)} KB`);
});
// Stop after 3 seconds
setTimeout(() => {
stream.stop();
}, 3000);
console.log("🎵 Recording for 3 seconds...");
stream.start();
}
// Example 3: Monitor audio levels (VU meter style)
function audioLevelMonitor() {
console.log("\n=== Example 3: Audio Level Monitor ===\n");
const stream = new AudioTeeStream({
format: "json", // JSON format for this example
chunkDuration: 0.05, // Fast updates for smooth level display
});
function drawLevelMeter(level) {
const maxBars = 20;
const bars = Math.floor(level * maxBars);
const meter = "█".repeat(bars) + "░".repeat(maxBars - bars);
const percentage = (level * 100).toFixed(1);
process.stdout.write(`\r🔊 ${meter} ${percentage}%`);
}
stream.on("audio", (packet) => {
drawLevelMeter(packet.peakAmplitude);
});
// Run for 10 seconds
setTimeout(() => {
console.log("\n\n✅ Level monitoring complete");
stream.stop();
}, 10000);
console.log("🎚️ Audio level monitor (10 seconds):");
console.log(" Play some music to see the levels!\n");
stream.start();
}
// Example 4: Process-specific capture
function captureSpecificProcess() {
console.log("\n=== Example 4: Process-Specific Capture ===\n");
// This would capture only from a specific application
// You'd need to find the PID first: `pgrep "Music"` or Activity Monitor
const stream = new AudioTeeStream({
// includeProcesses: [1234], // Uncomment and set real PID
mute: true, // Don't play through speakers
format: "binary",
});
stream.on("metadata", () => {
console.log(
"🎯 Capturing from specific process (demo mode - all processes)"
);
console.log(" To capture from specific app:");
console.log(' 1. Find PID: pgrep "App Name"');
console.log(" 2. Uncomment includeProcesses line above");
});
stream.on("audio", (packet) => {
console.log(
`📦 Got ${packet.audioData.length} bytes from targeted process`
);
});
// Stop after 3 seconds
setTimeout(() => {
stream.stop();
}, 3000);
stream.start();
}
// Run examples based on command line argument
const example = process.argv[2] || "1";
switch (example) {
case "1":
basicCapture();
break;
case "2":
saveToFile();
break;
case "3":
audioLevelMonitor();
break;
case "4":
captureSpecificProcess();
break;
default:
console.log("Usage: node basic-usage.js [1|2|3|4]");
console.log("");
console.log("Examples:");
console.log(" 1 - Basic audio capture");
console.log(" 2 - Save audio to file");
console.log(" 3 - Audio level monitor");
console.log(" 4 - Process-specific capture");
}
-15
View File
@@ -1,15 +0,0 @@
const path = require("path");
const binary = require("node-pre-gyp");
// Get the path to the downloaded binary
const bindingPath = binary.find(
path.resolve(path.join(__dirname, "package.json"))
);
const binaryPath = path.join(path.dirname(bindingPath), "audiotee");
const AudioTeeStream = require("./lib/AudioTeeStream");
module.exports = {
AudioTeeStream,
getBinaryPath: () => binaryPath,
};
-298
View File
@@ -1,298 +0,0 @@
const { spawn } = require("child_process");
const { EventEmitter } = require("events");
const readline = require("readline");
/**
* AudioTeeStream - Node.js wrapper for AudioTee system audio capture
*
* Events:
* - 'metadata': Audio format information
* - 'stream_start': Recording has started
* - 'audio': Audio data chunk { timestamp, duration, peakAmplitude, audioData }
* - 'stream_stop': Recording has stopped
* - 'log': Log messages { level, message, context }
* - 'error': Errors
* - 'close': Process has closed
*/
class AudioTeeStream extends EventEmitter {
constructor(options = {}) {
super();
// Binary path - use provided path or get from main module
this.binaryPath =
options.binaryPath ||
(() => {
try {
return require("../index").getBinaryPath();
} catch {
throw new Error(
"AudioTee binary path not available. Ensure package is properly installed."
);
}
})();
// AudioTee options
this.format = options.format || "binary"; // 'json', 'binary', or 'auto'
this.sampleRate = options.sampleRate;
this.chunkDuration = options.chunkDuration || 0.2;
this.includeProcesses = options.includeProcesses || [];
this.excludeProcesses = options.excludeProcesses || [];
this.mute = options.mute || false;
// Internal state
this.process = null;
this.metadata = null;
this.isStarted = false;
// Binary format state
this.pendingAudioMeta = null;
this.expectedBytes = 0;
this.binaryBuffer = Buffer.alloc(0);
}
/**
* Start audio capture
* @returns {AudioTeeStream} this instance for chaining
*/
start() {
if (this.isStarted) {
throw new Error("AudioTeeStream is already started");
}
const args = this.buildArguments();
try {
this.process = spawn(this.binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
this.emit(
"error",
new Error(`Failed to start AudioTee: ${error.message}`)
);
return this;
}
this.isStarted = true;
this.setupProtocolHandling();
this.setupProcessHandlers();
return this;
}
/**
* Stop audio capture
*/
stop() {
if (this.process && !this.process.killed) {
this.process.kill("SIGTERM");
}
}
/**
* Check if the stream is currently active
* @returns {boolean}
*/
isActive() {
return this.isStarted && this.process && !this.process.killed;
}
/**
* Get current audio metadata (available after 'metadata' event)
* @returns {Object|null}
*/
getMetadata() {
return this.metadata;
}
// Private methods
buildArguments() {
const args = [`--format=${this.format}`];
if (this.sampleRate) {
args.push(`--sample-rate=${this.sampleRate}`);
}
if (this.chunkDuration !== 0.2) {
args.push(`--chunk-duration=${this.chunkDuration}`);
}
if (this.includeProcesses.length) {
args.push(`--include-processes=${this.includeProcesses.join(" ")}`);
}
if (this.excludeProcesses.length) {
args.push(`--exclude-processes=${this.excludeProcesses.join(" ")}`);
}
if (this.mute) {
args.push("--mute");
}
return args;
}
setupProtocolHandling() {
if (this.format === "binary") {
this.setupBinaryProtocol();
} else {
this.setupJSONProtocol();
}
}
setupJSONProtocol() {
const rl = readline.createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
rl.on("line", (line) => this.handleJSONLine(line));
}
setupBinaryProtocol() {
// For binary format, we need to handle both JSON lines and raw binary data
let lineBuffer = "";
let inJsonMode = true;
this.process.stdout.on("data", (chunk) => {
if (inJsonMode) {
// Look for complete JSON lines
lineBuffer += chunk.toString();
let newlineIndex;
while ((newlineIndex = lineBuffer.indexOf("\n")) !== -1) {
const line = lineBuffer.slice(0, newlineIndex);
lineBuffer = lineBuffer.slice(newlineIndex + 1);
try {
const message = JSON.parse(line);
if (message.message_type === "audio" && this.format === "binary") {
// Prepare for binary data
this.expectedBytes = message.data.audio_length;
this.pendingAudioMeta = {
timestamp: new Date(message.data.timestamp),
duration: message.data.duration,
peakAmplitude: message.data.peak_amplitude,
};
inJsonMode = false;
} else {
this.handleJSONMessage(message);
}
} catch (error) {
this.emit(
"error",
new Error(`Failed to parse JSON: ${error.message}`)
);
}
}
} else {
// We're expecting binary audio data
this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]);
if (this.binaryBuffer.length >= this.expectedBytes) {
// Extract the audio data
const audioData = this.binaryBuffer.slice(0, this.expectedBytes);
this.binaryBuffer = this.binaryBuffer.slice(this.expectedBytes);
// Emit the audio event
this.emit("audio", {
...this.pendingAudioMeta,
audioData,
});
// Reset state
this.expectedBytes = 0;
this.pendingAudioMeta = null;
inJsonMode = true;
// Process any remaining data as JSON
if (this.binaryBuffer.length > 0) {
lineBuffer += this.binaryBuffer.toString();
this.binaryBuffer = Buffer.alloc(0);
}
}
}
});
}
handleJSONLine(line) {
try {
const message = JSON.parse(line);
this.handleJSONMessage(message);
} catch (error) {
this.emit("error", new Error(`Failed to parse JSON: ${error.message}`));
}
}
handleJSONMessage(message) {
switch (message.message_type) {
case "metadata":
this.metadata = message.data;
this.emit("metadata", this.metadata);
break;
case "stream_start":
this.emit("stream_start");
break;
case "audio":
if (this.format === "json") {
// JSON format - audio data is base64 encoded
const audioBuffer = Buffer.from(message.data.audio_data, "base64");
this.emit("audio", {
timestamp: new Date(message.data.timestamp),
duration: message.data.duration,
peakAmplitude: message.data.peak_amplitude,
audioData: audioBuffer,
});
}
// Binary format audio is handled in setupBinaryProtocol
break;
case "stream_stop":
this.emit("stream_stop");
break;
case "info":
case "error":
case "debug":
this.emit("log", {
level: message.message_type,
message: message.data?.message || "Unknown log message",
context: message.data?.context,
});
break;
default:
this.emit("log", {
level: "debug",
message: `Unknown message type: ${message.message_type}`,
context: { raw_message: message },
});
}
}
setupProcessHandlers() {
this.process.stderr.on("data", (data) => {
// AudioTee should not write to stderr in normal operation
this.emit("log", {
level: "error",
message: "AudioTee stderr output",
context: { output: data.toString().trim() },
});
});
this.process.on("close", (code, signal) => {
this.isStarted = false;
this.emit("close", { code, signal });
});
this.process.on("error", (error) => {
this.isStarted = false;
this.emit("error", new Error(`AudioTee process error: ${error.message}`));
});
}
}
module.exports = AudioTeeStream;
-61
View File
@@ -1,61 +0,0 @@
{
"name": "audiotee-js",
"version": "1.0.0",
"description": "Node.js wrapper for AudioTee - capture macOS system audio using Core Audio taps",
"main": "index.js",
"scripts": {
"install": "node-pre-gyp install --fallback-to-build",
"build": "node scripts/build.js",
"test": "node test/test.js",
"package": "node-pre-gyp package",
"publish-binary": "node-pre-gyp publish",
"clean": "node-pre-gyp clean",
"lint": "eslint lib/ scripts/ test/ index.js",
"prepack": "npm run build"
},
"binary": {
"module_name": "audiotee",
"module_path": "./bin/",
"remote_path": "v{version}/",
"package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz",
"host": "https://github.com/your-org/audiotee-js/releases/download/",
"napi_versions": []
},
"dependencies": {
"node-pre-gyp": "^0.17.0"
},
"devDependencies": {
"eslint": "^8.0.0"
},
"files": [
"index.js",
"lib/",
"scripts/build.js",
"README.md"
],
"os": ["darwin"],
"engines": {
"node": ">=14.0.0"
},
"keywords": [
"audio",
"macos",
"recording",
"system-audio",
"core-audio",
"streaming",
"real-time",
"asr",
"speech-recognition"
],
"repository": {
"type": "git",
"url": "git+https://github.com/your-org/audiotee-js.git"
},
"bugs": {
"url": "https://github.com/your-org/audiotee-js/issues"
},
"homepage": "https://github.com/your-org/audiotee-js#readme",
"license": "MIT",
"author": "Your Name <your.email@example.com>"
}
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const os = require("os");
// Default to parent directory (when in same repo), allow override via env var
const DEFAULT_BINARY_PATH = path.join(
__dirname,
"..",
"..",
".build",
"release",
"audiotee"
);
const AUDIOTEE_BINARY_PATH =
process.env.AUDIOTEE_BINARY_PATH || DEFAULT_BINARY_PATH;
function build() {
const arch = os.arch();
const platform = os.platform();
console.log(`Building for platform: ${platform}, architecture: ${arch}`);
if (platform !== "darwin") {
throw new Error("AudioTee only supports macOS");
}
// Create bin directory
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
console.log(`Created bin directory: ${binDir}`);
}
// Resolve the source binary path
const sourcePath = path.resolve(AUDIOTEE_BINARY_PATH);
const targetPath = path.join(binDir, "audiotee");
console.log(`Looking for AudioTee binary at: ${sourcePath}`);
if (!fs.existsSync(sourcePath)) {
console.error(`AudioTee binary not found at: ${sourcePath}`);
console.error("Please ensure AudioTee is built first:");
console.error(" cd ../audiotee && swift build -c release");
console.error("Or set AUDIOTEE_BINARY_PATH environment variable");
throw new Error(`AudioTee binary not found at: ${sourcePath}`);
}
console.log(`Copying AudioTee binary from ${sourcePath} to ${targetPath}`);
fs.copyFileSync(sourcePath, targetPath);
// Make executable
fs.chmodSync(targetPath, 0o755);
// Verify the binary works
console.log("Verifying binary...");
const { execSync } = require("child_process");
try {
execSync(`"${targetPath}" --help`, { stdio: "pipe" });
console.log("Binary verification successful");
} catch (error) {
console.warn("Binary verification failed, but continuing...");
}
console.log("Build completed successfully");
}
function clean() {
const binDir = path.join(__dirname, "..", "bin");
if (fs.existsSync(binDir)) {
fs.rmSync(binDir, { recursive: true, force: true });
console.log("Cleaned bin directory");
}
}
if (require.main === module) {
const command = process.argv[2];
switch (command) {
case "clean":
clean();
break;
default:
build();
}
}
module.exports = { build, clean };
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env node
const { AudioTeeStream } = require("../index");
function runBasicTest() {
console.log("=== AudioTee.js Basic Test ===\n");
const stream = new AudioTeeStream({
format: "binary",
sampleRate: 16000,
chunkDuration: 0.1,
});
let audioPacketCount = 0;
let totalAudioBytes = 0;
stream.on("metadata", (metadata) => {
console.log("📊 Audio Metadata:");
console.log(` Sample Rate: ${metadata.sample_rate} Hz`);
console.log(` Channels: ${metadata.channels_per_frame}`);
console.log(` Bits per Channel: ${metadata.bits_per_channel}`);
console.log(` Encoding: ${metadata.encoding}`);
console.log(` Float: ${metadata.is_float}\n`);
});
stream.on("stream_start", () => {
console.log("🎵 Audio stream started\n");
});
stream.on("audio", (packet) => {
audioPacketCount++;
totalAudioBytes += packet.audioData.length;
process.stdout.write(
`\r📦 Packets: ${audioPacketCount} | Audio bytes: ${totalAudioBytes} | Peak: ${packet.peakAmplitude.toFixed(
3
)} | Duration: ${packet.duration.toFixed(3)}s`
);
});
stream.on("stream_stop", () => {
console.log("\n\n🛑 Audio stream stopped");
console.log(
`📈 Final stats: ${audioPacketCount} packets, ${totalAudioBytes} bytes total\n`
);
});
stream.on("log", (log) => {
if (log.level === "error") {
console.error(`\n${log.level.toUpperCase()}: ${log.message}`);
if (log.context) {
console.error(` Context:`, log.context);
}
}
});
stream.on("error", (error) => {
console.error(`\n💥 Error: ${error.message}`);
process.exit(1);
});
stream.on("close", ({ code, signal }) => {
console.log(
`👋 AudioTee process closed (code: ${code}, signal: ${signal})`
);
process.exit(code || 0);
});
// Handle Ctrl+C gracefully
process.on("SIGINT", () => {
console.log("\n\n🛑 Received SIGINT, stopping AudioTee...");
stream.stop();
});
console.log("🚀 Starting AudioTee stream...");
console.log("💡 Play some audio and watch the packets stream in!");
console.log("⏹️ Press Ctrl+C to stop\n");
try {
stream.start();
} catch (error) {
console.error(`Failed to start: ${error.message}`);
process.exit(1);
}
}
function runQuickTest() {
console.log("=== AudioTee.js Quick Test ===\n");
const stream = new AudioTeeStream({
format: "json",
chunkDuration: 0.2,
});
let packetCount = 0;
const maxPackets = 5;
stream.on("metadata", (metadata) => {
console.log("✅ Received metadata:", metadata);
});
stream.on("audio", () => {
packetCount++;
console.log(`✅ Received audio packet ${packetCount}/${maxPackets}`);
if (packetCount >= maxPackets) {
console.log("✅ Quick test complete!");
stream.stop();
}
});
stream.on("error", (error) => {
console.error("❌ Test failed:", error.message);
process.exit(1);
});
stream.on("close", () => {
console.log("👋 Test finished");
process.exit(0);
});
setTimeout(() => {
console.log("⏰ Test timeout - AudioTee might not be working");
stream.stop();
process.exit(1);
}, 10000);
console.log("🚀 Running quick test (capturing 5 audio packets)...");
stream.start();
}
// Run the appropriate test based on command line args
const testType = process.argv[2] || "basic";
switch (testType) {
case "quick":
runQuickTest();
break;
case "basic":
default:
runBasicTest();
break;
}