Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e7f6d34ca |
@@ -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
|
||||||
|
}
|
||||||
+9
-3
@@ -8,11 +8,17 @@ let package = Package(
|
|||||||
platforms: [
|
platforms: [
|
||||||
.macOS("14.2")
|
.macOS("14.2")
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
|
||||||
|
],
|
||||||
targets: [
|
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(
|
.executableTarget(
|
||||||
name: "audiotee",
|
name: "audiotee",
|
||||||
swiftSettings: [
|
dependencies: [
|
||||||
.define("ENABLE_TCC_SPI")
|
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
||||||
])
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# AudioTee
|
# AudioTee
|
||||||
|
|
||||||
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
|
AudioTee captures your Mac's system audio output and writes 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
|
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` 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.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -16,16 +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 base64-encoded chunks of it to your terminal every 200ms:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:makeusabrew/audiotee.git
|
git clone git@github.com:makeusabrew/audiotee.git
|
||||||
cd audiotee
|
cd audiotee
|
||||||
swift run
|
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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -52,9 +48,6 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
|
|||||||
|
|
||||||
### Audio conversion
|
### Audio conversion
|
||||||
|
|
||||||
Note that performing sample rate conversion will also convert the output bit depth to
|
|
||||||
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Convert to 16kHz mono (useful for ASR services)
|
# Convert to 16kHz mono (useful for ASR services)
|
||||||
./audiotee --sample-rate 16000
|
./audiotee --sample-rate 16000
|
||||||
@@ -67,8 +60,6 @@ Note that performing sample rate conversion will also convert the output bit dep
|
|||||||
|
|
||||||
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
|
||||||
@@ -243,35 +234,13 @@ Info, error, and debug messages (useful for monitoring):
|
|||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
|
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).
|
||||||
### Lazy permissions (default approach)
|
|
||||||
|
|
||||||
Simply run `./audiotee` and you'll be prompted for permissions the first time AudioTee tries to record audio from the tap. Note that some terminal emulators (at least `iTerm`) will **not** prompt at all, nor will the process fail: instead, AudioTee will happily run but will record a stream of empty data. The built in macOS terminal **does** prompt for permissions and blocks until granted.
|
|
||||||
|
|
||||||
### Explicit permissions management
|
|
||||||
|
|
||||||
Use the `--permissions` flag to check or request permissions ahead of time:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check current permission status
|
|
||||||
./audiotee --permissions
|
|
||||||
|
|
||||||
# Request permissions with user prompt
|
|
||||||
./audiotee --permissions --request
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that the same caveat as above exists here regarding terminal emulators. If you know why, or how to fix it, please help out.
|
|
||||||
|
|
||||||
**Exit codes** indicate permission status, making this approach ideal for scripting:
|
|
||||||
- `0`: Permissions granted
|
|
||||||
- `1`: Permission status unknown
|
|
||||||
- `2`: Permissions denied
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
||||||
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
|
- [AudioCap Implementation](https://github.com/insidegui/AudioCap)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,234 +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
|
|
||||||
} 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+48
-133
@@ -1,128 +1,67 @@
|
|||||||
|
import ArgumentParser
|
||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AudioTee {
|
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
|
var format: OutputFormat = .auto
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .long, help: "Process IDs to include (space-separated, empty = all processes)")
|
||||||
var includeProcesses: [Int32] = []
|
var includeProcesses: [Int32] = []
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .long, help: "Process IDs to exclude (space-separated)")
|
||||||
var excludeProcesses: [Int32] = []
|
var excludeProcesses: [Int32] = []
|
||||||
|
|
||||||
|
@Flag(name: .long, help: "Mute processes being tapped")
|
||||||
var mute: Bool = false
|
var mute: Bool = false
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .long,
|
||||||
|
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
||||||
var sampleRate: Double?
|
var sampleRate: Double?
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .long,
|
||||||
|
help: "Audio chunk duration in seconds (default: 0.2)")
|
||||||
var chunkDuration: Double = 0.2
|
var chunkDuration: Double = 0.2
|
||||||
var permissionsMode: Bool = false
|
|
||||||
var requestPermissions: 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.
|
|
||||||
|
|
||||||
Permission modes:
|
|
||||||
• --permissions: Check current audio recording permissions
|
|
||||||
• --permissions --request: Request audio recording permissions
|
|
||||||
|
|
||||||
Output formats:
|
|
||||||
• json: Base64-encoded audio in JSON messages (safe for terminals)
|
|
||||||
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
|
|
||||||
• auto: Automatically choose based on whether stdout is a terminal (default)
|
|
||||||
|
|
||||||
Process filtering:
|
|
||||||
• 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 --permissions # Check audio recording permissions
|
|
||||||
audiotee --permissions --request # Request audio recording permissions
|
|
||||||
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.addFlag(name: "permissions", help: "Check audio recording permissions")
|
|
||||||
parser.addFlag(name: "request", help: "Request permissions (use with --permissions)")
|
|
||||||
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
|
|
||||||
parser.addArrayOption(
|
|
||||||
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.permissionsMode = parser.getFlag("permissions")
|
|
||||||
audioTee.requestPermissions = parser.getFlag("request")
|
|
||||||
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
|
|
||||||
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
|
||||||
audioTee.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 {
|
func validate() throws {
|
||||||
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
||||||
throw ArgumentParserError.validationFailed(
|
throw ValidationError("Cannot specify both --include-processes and --exclude-processes")
|
||||||
"Cannot specify both --include-processes and --exclude-processes")
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestPermissions && !permissionsMode {
|
|
||||||
throw ArgumentParserError.validationFailed(
|
|
||||||
"--request can only be used with --permissions")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() throws {
|
func run() throws {
|
||||||
// Handle permissions mode
|
|
||||||
if permissionsMode {
|
|
||||||
let permissionsHandler = PermissionsHandler(shouldRequest: requestPermissions)
|
|
||||||
permissionsHandler.handle() // This will exit with appropriate code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue with normal audio tapping functionality
|
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
Logger.info("Starting AudioTee...")
|
||||||
@@ -220,27 +159,3 @@ struct AudioTee {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,4 +1,6 @@
|
|||||||
enum OutputFormat: String, CaseIterable {
|
import ArgumentParser
|
||||||
|
|
||||||
|
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
|
||||||
case json = "json"
|
case json = "json"
|
||||||
case binary = "binary"
|
case binary = "binary"
|
||||||
case auto = "auto"
|
case auto = "auto"
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import CoreFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Handles audio recording permissions for the CLI, including checking status and requesting permissions.
|
|
||||||
/// Uses exit codes to communicate permission status:
|
|
||||||
/// - 0: granted (authorized)
|
|
||||||
/// - 1: unknown
|
|
||||||
/// - 2: denied
|
|
||||||
struct PermissionsHandler {
|
|
||||||
private let shouldRequest: Bool
|
|
||||||
|
|
||||||
init(shouldRequest: Bool) {
|
|
||||||
self.shouldRequest = shouldRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the permissions workflow and exits with appropriate exit code
|
|
||||||
func handle() -> Never {
|
|
||||||
let permissionHandler = AudioRecordingPermission()
|
|
||||||
|
|
||||||
if shouldRequest {
|
|
||||||
print("Requesting audio recording permissions...")
|
|
||||||
permissionHandler.request()
|
|
||||||
|
|
||||||
// Wait for the permission request to complete
|
|
||||||
while permissionHandler.status == .unknown {
|
|
||||||
// Run the main run loop to allow DispatchQueue.main.async to execute
|
|
||||||
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, true)
|
|
||||||
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get final status and exit with appropriate code
|
|
||||||
let status = permissionHandler.status
|
|
||||||
print("Audio recording permission status: \(status.rawValue)")
|
|
||||||
|
|
||||||
switch status {
|
|
||||||
case .authorized:
|
|
||||||
exit(0) // granted
|
|
||||||
case .unknown:
|
|
||||||
exit(1) // unknown
|
|
||||||
case .denied:
|
|
||||||
exit(2) // denied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ public class AudioFormatConverter {
|
|||||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
||||||
let inputData = packet.rawAudioData
|
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 =
|
||||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
|
|||||||
@@ -4,81 +4,19 @@ import Foundation
|
|||||||
|
|
||||||
public class AudioFormatManager {
|
public class AudioFormatManager {
|
||||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
||||||
// First, wait for the device to become alive/ready
|
var propertyAddress = getPropertyAddress(
|
||||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
selector: kAudioDevicePropertyStreamFormat,
|
||||||
let pollInterval = 0.1 // 100ms poll interval
|
scope: kAudioDevicePropertyScopeInput)
|
||||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
||||||
|
var streamFormat = AudioStreamBasicDescription()
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
Logger.debug(
|
guard status == noErr else {
|
||||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
fatalError("Failed to get stream format: \(status)")
|
||||||
|
|
||||||
// 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
|
return streamFormat
|
||||||
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 {
|
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import OSLog
|
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// Adapted with a huge debt of gratitude from https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift
|
|
||||||
|
|
||||||
/// Uses TCC SPI in order to check/request system audio recording permission.
|
|
||||||
@Observable
|
|
||||||
final class AudioRecordingPermission {
|
|
||||||
// private let logger = Logger(subsystem: kAppSubsystem, category: String(describing: AudioRecordingPermission.self))
|
|
||||||
|
|
||||||
enum Status: String {
|
|
||||||
case unknown
|
|
||||||
case denied
|
|
||||||
case authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var status: Status = .unknown
|
|
||||||
|
|
||||||
init() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.updateStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus()
|
|
||||||
#else
|
|
||||||
status = .authorized
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
func request() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
// logger.debug(#function)
|
|
||||||
print("DEBUG: TCC SPI request called")
|
|
||||||
|
|
||||||
guard let request = Self.requestSPI else {
|
|
||||||
// logger.fault("Request SPI missing")
|
|
||||||
print("DEBUG: Request SPI is nil - TCC framework loading failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: Calling TCC request function...")
|
|
||||||
request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
// self.logger.info("Request finished with result: \(granted, privacy: .public)")
|
|
||||||
print("DEBUG: TCC request completed with result: \(granted)")
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
print("DEBUG: Updating status on main queue...")
|
|
||||||
if granted {
|
|
||||||
self.status = .authorized
|
|
||||||
print("DEBUG: Status set to authorized")
|
|
||||||
} else {
|
|
||||||
self.status = .denied
|
|
||||||
print("DEBUG: Status set to denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
print("DEBUG: ENABLE_TCC_SPI not defined")
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateStatus() {
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
// logger.debug(#function)
|
|
||||||
|
|
||||||
guard let preflight = Self.preflightSPI else {
|
|
||||||
// logger.fault("Preflight SPI missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = preflight("kTCCServiceAudioCapture" as CFString, nil)
|
|
||||||
|
|
||||||
if result == 1 {
|
|
||||||
status = .denied
|
|
||||||
} else if result == 0 {
|
|
||||||
status = .authorized
|
|
||||||
} else {
|
|
||||||
status = .unknown
|
|
||||||
}
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
|
|
||||||
#if ENABLE_TCC_SPI
|
|
||||||
private typealias PreflightFuncType = @convention(c) (CFString, CFDictionary?) -> Int
|
|
||||||
private typealias RequestFuncType = @convention(c) (
|
|
||||||
CFString, CFDictionary?, @escaping (Bool) -> Void
|
|
||||||
) -> Void
|
|
||||||
|
|
||||||
/// `dlopen` handle to the TCC framework.
|
|
||||||
private static let apiHandle: UnsafeMutableRawPointer? = {
|
|
||||||
let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC"
|
|
||||||
print("DEBUG: Attempting to load TCC framework from: \(tccPath)")
|
|
||||||
|
|
||||||
guard let handle = dlopen(tccPath, RTLD_NOW) else {
|
|
||||||
print("DEBUG: dlopen failed for TCC framework")
|
|
||||||
assertionFailure("dlopen failed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: TCC framework loaded successfully")
|
|
||||||
return handle
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// `dlsym` function handle for `TCCAccessPreflight`.
|
|
||||||
private static let preflightSPI: PreflightFuncType? = {
|
|
||||||
guard let apiHandle else { return nil }
|
|
||||||
|
|
||||||
let fnName = "TCCAccessPreflight"
|
|
||||||
|
|
||||||
guard let funcSym = dlsym(apiHandle, fnName) else {
|
|
||||||
assertionFailure("Couldn't find symbol")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fn = unsafeBitCast(funcSym, to: PreflightFuncType.self)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// `dlsym` function handle for `TCCAccessRequest`.
|
|
||||||
private static let requestSPI: RequestFuncType? = {
|
|
||||||
guard let apiHandle else {
|
|
||||||
print("DEBUG: No API handle for TCCAccessRequest")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fnName = "TCCAccessRequest"
|
|
||||||
print("DEBUG: Looking for symbol: \(fnName)")
|
|
||||||
|
|
||||||
guard let funcSym = dlsym(apiHandle, fnName) else {
|
|
||||||
print("DEBUG: Couldn't find symbol: \(fnName)")
|
|
||||||
assertionFailure("Couldn't find symbol")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
print("DEBUG: Found TCCAccessRequest symbol successfully")
|
|
||||||
let fn = unsafeBitCast(funcSym, to: RequestFuncType.self)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
}()
|
|
||||||
#endif // ENABLE_TCC_SPI
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ArgumentParser
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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>"
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user