cursor having a crack at a wrapper module

This commit is contained in:
Nick Payne
2025-07-01 13:32:55 +01:00
parent fcbf9b0097
commit 7e7f6d34ca
13 changed files with 1774 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
const { spawn } = require("child_process");
const { EventEmitter } = require("events");
const readline = require("readline");
/**
* AudioTeeStream - Node.js wrapper for AudioTee system audio capture
*
* Events:
* - 'metadata': Audio format information
* - 'stream_start': Recording has started
* - 'audio': Audio data chunk { timestamp, duration, peakAmplitude, audioData }
* - 'stream_stop': Recording has stopped
* - 'log': Log messages { level, message, context }
* - 'error': Errors
* - 'close': Process has closed
*/
class AudioTeeStream extends EventEmitter {
constructor(options = {}) {
super();
// Binary path - use provided path or get from main module
this.binaryPath =
options.binaryPath ||
(() => {
try {
return require("../index").getBinaryPath();
} catch {
throw new Error(
"AudioTee binary path not available. Ensure package is properly installed."
);
}
})();
// AudioTee options
this.format = options.format || "binary"; // 'json', 'binary', or 'auto'
this.sampleRate = options.sampleRate;
this.chunkDuration = options.chunkDuration || 0.2;
this.includeProcesses = options.includeProcesses || [];
this.excludeProcesses = options.excludeProcesses || [];
this.mute = options.mute || false;
// Internal state
this.process = null;
this.metadata = null;
this.isStarted = false;
// Binary format state
this.pendingAudioMeta = null;
this.expectedBytes = 0;
this.binaryBuffer = Buffer.alloc(0);
}
/**
* Start audio capture
* @returns {AudioTeeStream} this instance for chaining
*/
start() {
if (this.isStarted) {
throw new Error("AudioTeeStream is already started");
}
const args = this.buildArguments();
try {
this.process = spawn(this.binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
this.emit(
"error",
new Error(`Failed to start AudioTee: ${error.message}`)
);
return this;
}
this.isStarted = true;
this.setupProtocolHandling();
this.setupProcessHandlers();
return this;
}
/**
* Stop audio capture
*/
stop() {
if (this.process && !this.process.killed) {
this.process.kill("SIGTERM");
}
}
/**
* Check if the stream is currently active
* @returns {boolean}
*/
isActive() {
return this.isStarted && this.process && !this.process.killed;
}
/**
* Get current audio metadata (available after 'metadata' event)
* @returns {Object|null}
*/
getMetadata() {
return this.metadata;
}
// Private methods
buildArguments() {
const args = [`--format=${this.format}`];
if (this.sampleRate) {
args.push(`--sample-rate=${this.sampleRate}`);
}
if (this.chunkDuration !== 0.2) {
args.push(`--chunk-duration=${this.chunkDuration}`);
}
if (this.includeProcesses.length) {
args.push(`--include-processes=${this.includeProcesses.join(" ")}`);
}
if (this.excludeProcesses.length) {
args.push(`--exclude-processes=${this.excludeProcesses.join(" ")}`);
}
if (this.mute) {
args.push("--mute");
}
return args;
}
setupProtocolHandling() {
if (this.format === "binary") {
this.setupBinaryProtocol();
} else {
this.setupJSONProtocol();
}
}
setupJSONProtocol() {
const rl = readline.createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
rl.on("line", (line) => this.handleJSONLine(line));
}
setupBinaryProtocol() {
// For binary format, we need to handle both JSON lines and raw binary data
let lineBuffer = "";
let inJsonMode = true;
this.process.stdout.on("data", (chunk) => {
if (inJsonMode) {
// Look for complete JSON lines
lineBuffer += chunk.toString();
let newlineIndex;
while ((newlineIndex = lineBuffer.indexOf("\n")) !== -1) {
const line = lineBuffer.slice(0, newlineIndex);
lineBuffer = lineBuffer.slice(newlineIndex + 1);
try {
const message = JSON.parse(line);
if (message.message_type === "audio" && this.format === "binary") {
// Prepare for binary data
this.expectedBytes = message.data.audio_length;
this.pendingAudioMeta = {
timestamp: new Date(message.data.timestamp),
duration: message.data.duration,
peakAmplitude: message.data.peak_amplitude,
};
inJsonMode = false;
} else {
this.handleJSONMessage(message);
}
} catch (error) {
this.emit(
"error",
new Error(`Failed to parse JSON: ${error.message}`)
);
}
}
} else {
// We're expecting binary audio data
this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]);
if (this.binaryBuffer.length >= this.expectedBytes) {
// Extract the audio data
const audioData = this.binaryBuffer.slice(0, this.expectedBytes);
this.binaryBuffer = this.binaryBuffer.slice(this.expectedBytes);
// Emit the audio event
this.emit("audio", {
...this.pendingAudioMeta,
audioData,
});
// Reset state
this.expectedBytes = 0;
this.pendingAudioMeta = null;
inJsonMode = true;
// Process any remaining data as JSON
if (this.binaryBuffer.length > 0) {
lineBuffer += this.binaryBuffer.toString();
this.binaryBuffer = Buffer.alloc(0);
}
}
}
});
}
handleJSONLine(line) {
try {
const message = JSON.parse(line);
this.handleJSONMessage(message);
} catch (error) {
this.emit("error", new Error(`Failed to parse JSON: ${error.message}`));
}
}
handleJSONMessage(message) {
switch (message.message_type) {
case "metadata":
this.metadata = message.data;
this.emit("metadata", this.metadata);
break;
case "stream_start":
this.emit("stream_start");
break;
case "audio":
if (this.format === "json") {
// JSON format - audio data is base64 encoded
const audioBuffer = Buffer.from(message.data.audio_data, "base64");
this.emit("audio", {
timestamp: new Date(message.data.timestamp),
duration: message.data.duration,
peakAmplitude: message.data.peak_amplitude,
audioData: audioBuffer,
});
}
// Binary format audio is handled in setupBinaryProtocol
break;
case "stream_stop":
this.emit("stream_stop");
break;
case "info":
case "error":
case "debug":
this.emit("log", {
level: message.message_type,
message: message.data?.message || "Unknown log message",
context: message.data?.context,
});
break;
default:
this.emit("log", {
level: "debug",
message: `Unknown message type: ${message.message_type}`,
context: { raw_message: message },
});
}
}
setupProcessHandlers() {
this.process.stderr.on("data", (data) => {
// AudioTee should not write to stderr in normal operation
this.emit("log", {
level: "error",
message: "AudioTee stderr output",
context: { output: data.toString().trim() },
});
});
this.process.on("close", (code, signal) => {
this.isStarted = false;
this.emit("close", { code, signal });
});
this.process.on("error", (error) => {
this.isStarted = false;
this.emit("error", new Error(`AudioTee process error: ${error.message}`));
});
}
}
module.exports = AudioTeeStream;