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
|
||||
}
|
||||
+11
-1
@@ -8,7 +8,17 @@ let package = Package(
|
||||
platforms: [
|
||||
.macOS("14.2")
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "audiotee")
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.executableTarget(
|
||||
name: "audiotee",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -16,16 +16,12 @@ Recording system audio is harder than it should be on macOS, and folks often wre
|
||||
|
||||
## Quick start
|
||||
|
||||
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:makeusabrew/audiotee.git
|
||||
cd audiotee
|
||||
swift run
|
||||
```
|
||||
|
||||
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -52,9 +48,6 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
|
||||
|
||||
### Audio conversion
|
||||
|
||||
Note that performing sample rate conversion will also convert the output bit depth to
|
||||
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
|
||||
|
||||
```bash
|
||||
# Convert to 16kHz mono (useful for ASR services)
|
||||
./audiotee --sample-rate 16000
|
||||
@@ -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.
|
||||
|
||||
Note that trying to include or exclude a PID which isn't currently playing audio will probably fail to convert to an Audio Object and will cause the process to exit.
|
||||
|
||||
```bash
|
||||
# Tap all system audio (default)
|
||||
./audiotee
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+25
-86
@@ -1,19 +1,9 @@
|
||||
import ArgumentParser
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
struct AudioTee {
|
||||
var format: OutputFormat = .auto
|
||||
var includeProcesses: [Int32] = []
|
||||
var excludeProcesses: [Int32] = []
|
||||
var mute: Bool = false
|
||||
var sampleRate: Double?
|
||||
var chunkDuration: Double = 0.2
|
||||
|
||||
init() {}
|
||||
|
||||
static func main() {
|
||||
let parser = SimpleArgumentParser(
|
||||
programName: "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.
|
||||
@@ -41,60 +31,33 @@ struct AudioTee {
|
||||
"""
|
||||
)
|
||||
|
||||
// Configure arguments
|
||||
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
|
||||
parser.addArrayOption(
|
||||
name: "include-processes",
|
||||
help: "Process IDs to include (space-separated, empty = all processes)")
|
||||
parser.addArrayOption(
|
||||
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
||||
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
||||
parser.addOption(
|
||||
name: "sample-rate",
|
||||
@Option(name: .shortAndLong, help: "Output format")
|
||||
var format: OutputFormat = .auto
|
||||
|
||||
@Option(
|
||||
name: .long, help: "Process IDs to include (space-separated, empty = all processes)")
|
||||
var includeProcesses: [Int32] = []
|
||||
|
||||
@Option(
|
||||
name: .long, help: "Process IDs to exclude (space-separated)")
|
||||
var excludeProcesses: [Int32] = []
|
||||
|
||||
@Flag(name: .long, help: "Mute processes being tapped")
|
||||
var mute: Bool = false
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
||||
parser.addOption(
|
||||
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
|
||||
var sampleRate: Double?
|
||||
|
||||
// Parse arguments
|
||||
do {
|
||||
try parser.parse()
|
||||
|
||||
var audioTee = AudioTee()
|
||||
|
||||
// Extract values
|
||||
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
|
||||
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
||||
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
|
||||
audioTee.mute = parser.getFlag("mute")
|
||||
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
||||
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
||||
|
||||
// Validate
|
||||
try audioTee.validate()
|
||||
|
||||
// Run
|
||||
try audioTee.run()
|
||||
|
||||
} catch ArgumentParserError.helpRequested {
|
||||
parser.printHelp()
|
||||
exit(0)
|
||||
} catch ArgumentParserError.validationFailed(let message) {
|
||||
print("Error: \(message)", to: &standardError)
|
||||
exit(1)
|
||||
} catch let error as ArgumentParserError {
|
||||
print("Error: \(error.description)", to: &standardError)
|
||||
parser.printHelp()
|
||||
exit(1)
|
||||
} catch {
|
||||
print("Error: \(error)", to: &standardError)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Audio chunk duration in seconds (default: 0.2)")
|
||||
var chunkDuration: Double = 0.2
|
||||
|
||||
func validate() throws {
|
||||
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
||||
throw ArgumentParserError.validationFailed(
|
||||
"Cannot specify both --include-processes and --exclude-processes")
|
||||
throw ValidationError("Cannot specify both --include-processes and --exclude-processes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,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 binary = "binary"
|
||||
case auto = "auto"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ArgumentParser
|
||||
import CoreAudio
|
||||
|
||||
public enum TapMuteBehavior: String, CaseIterable {
|
||||
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
|
||||
case unmuted = "unmuted"
|
||||
case muted = "muted"
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ public class AudioFormatConverter {
|
||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
||||
let inputData = packet.rawAudioData
|
||||
|
||||
// Short-circuit if no conversion needed
|
||||
if sourceFormat.sampleRate == targetFormat.sampleRate {
|
||||
return packet
|
||||
}
|
||||
|
||||
// Calculate frame counts
|
||||
let inputFrameCount =
|
||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||
|
||||
@@ -4,42 +4,6 @@ import Foundation
|
||||
|
||||
public class AudioFormatManager {
|
||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
||||
// First, wait for the device to become alive/ready
|
||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||
let pollInterval = 0.1 // 100ms poll interval
|
||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||
|
||||
Logger.debug(
|
||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||
|
||||
// Poll device readiness
|
||||
for poll in 1...maxPolls {
|
||||
if isAudioDeviceValid(deviceID) {
|
||||
Logger.debug(
|
||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||
break
|
||||
}
|
||||
|
||||
if poll == maxPolls {
|
||||
Logger.info(
|
||||
"Device did not become ready within timeout, proceeding anyway",
|
||||
context: [
|
||||
"device_id": String(deviceID),
|
||||
"timeout_seconds": String(deviceReadyTimeout),
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||
Logger.info("------- not ready; retrying...")
|
||||
|
||||
Thread.sleep(forTimeInterval: pollInterval)
|
||||
}
|
||||
|
||||
// Now attempt to get the stream format with limited retries
|
||||
let maxRetries = 3 // Reduced since device should be ready
|
||||
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
var propertyAddress = getPropertyAddress(
|
||||
selector: kAudioDevicePropertyStreamFormat,
|
||||
scope: kAudioDevicePropertyScopeInput)
|
||||
@@ -48,39 +12,13 @@ public class AudioFormatManager {
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||
|
||||
if status == noErr {
|
||||
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to get stream format: \(status)")
|
||||
}
|
||||
|
||||
return streamFormat
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
"------- Failed to get stream format after device ready check, retrying...",
|
||||
context: [
|
||||
"attempt": String(attempt),
|
||||
"max_retries": String(maxRetries),
|
||||
"status": String(status),
|
||||
"device_id": String(deviceID),
|
||||
])
|
||||
|
||||
// Don't delay on the last attempt
|
||||
if attempt < maxRetries {
|
||||
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||
Logger.error(
|
||||
"Failed to get device format after device readiness check and retries",
|
||||
context: [
|
||||
"device_id": String(deviceID),
|
||||
"device_was_ready": "true",
|
||||
])
|
||||
|
||||
fatalError(
|
||||
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
|
||||
)
|
||||
}
|
||||
|
||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||
return AudioStreamMetadata(
|
||||
sampleRate: format.mSampleRate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ArgumentParser
|
||||
import AudioToolbox
|
||||
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