import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; export const MIC_SAMPLE_RATE = 16_000; export const MIC_CHANNELS = 1; export const MIC_FORMAT = 's16'; /** Raw 16 kHz mono capture from PipeWire. The process is deliberately kept * outside gnome-shell and has a stable node name for routing in Helvum. */ export class PipeWireCapture extends EventEmitter { constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = MIC_SAMPLE_RATE, nodeName = 'Jarvis' } = {}) { super(); this.command = command; this.spawnImpl = spawnImpl; this.sampleRate = sampleRate; this.nodeName = nodeName; this.process = null; } start() { if (this.process) return this; this.process = this.spawnImpl(this.command, [ '--record', '--raw', '--format', MIC_FORMAT, '--rate', String(this.sampleRate), '--channels', String(MIC_CHANNELS), '--name', this.nodeName, ], { stdio: ['ignore', 'pipe', 'pipe'] }); this.process.stdout?.on('data', (chunk) => this.emit('audio', Buffer.from(chunk))); this.process.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim())); this.process.on('error', (error) => this.emit('error', error)); this.process.on('close', (code, signal) => { this.process = null; this.emit('close', { code, signal }); }); return this; } stop() { if (!this.process) return; this.process.kill('SIGTERM'); this.process = null; } } export function pcmRms(chunk) { const bytes = Buffer.from(chunk || ''); if (bytes.length < 2) return 0; let sum = 0; for (let i = 0; i + 1 < bytes.length; i += 2) { const sample = bytes.readInt16LE(i) / 32768; sum += sample * sample; } return Math.sqrt(sum / Math.floor(bytes.length / 2)); }