import { ChildProcess, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { type AudioConfig } from '../config/index.js'; import { createLogger, type Logger } from '../utils/index.js'; export interface AudioServiceEvents { /** Emitted when a raw PCM audio chunk is captured from the microphone */ audio_chunk: (chunk: Buffer) => void; /** Emitted when playback of a response finishes */ playback_done: () => void; /** Emitted on audio errors */ error: (error: Error) => void; } /** * Audio service for Raspberry Pi. * * Uses ALSA tools (arecord/aplay) via child processes. * Works with any ALSA-compatible audio device: * - I2S (INMP441 mic, MAX98357 amp) connected directly to Pi GPIO * - USB audio devices * - Default system audio * * Audio format: PCM signed 16-bit little-endian, mono, 16kHz */ export class AudioService extends EventEmitter { private captureProcess: ChildProcess | null = null; private readonly logger: Logger; private _isCapturing = false; private _isPlaying = false; private _stoppedManually = false; constructor(private readonly config: AudioConfig) { super(); this.logger = createLogger('audio', 'info'); } get isCapturing(): boolean { return this._isCapturing; } get isPlaying(): boolean { return this._isPlaying; } /** * Start capturing audio from the microphone. * Emits 'audio_chunk' events with raw PCM buffers. */ startCapture(): void { if (this._isCapturing) { this.logger.warn('Already capturing audio'); return; } this.logger.info( { device: this.config.captureDevice, sampleRate: this.config.sampleRate }, 'Starting audio capture', ); // arecord outputs raw PCM to stdout // -D: ALSA device // -f: format (S16_LE = signed 16-bit little-endian) // -r: sample rate // -c: channels // -t: type (raw = no header) // --buffer-size: in frames, controls latency const bufferFrames = Math.floor(this.config.sampleRate * (this.config.chunkDurationMs / 1000)); this.captureProcess = spawn('arecord', [ '-D', this.config.captureDevice, '-f', 'S16_LE', '-r', String(this.config.sampleRate), '-c', String(this.config.channels), '-t', 'raw', '--buffer-size', String(bufferFrames * 4), '--period-size', String(bufferFrames), ], { stdio: ['ignore', 'pipe', 'pipe'], }); this._isCapturing = true; let chunkCount = 0; this.captureProcess.stdout?.on('data', (chunk: Buffer) => { chunkCount++; if (chunkCount === 1) { this.logger.info({ bytes: chunk.length }, 'First audio chunk received from arecord'); } this.emit('audio_chunk', chunk); }); this.captureProcess.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) { this.logger.debug({ msg }, 'arecord stderr'); } }); this.captureProcess.on('error', (err) => { this.logger.error({ err }, 'arecord process error'); this._isCapturing = false; this.emit('error', new Error(`Audio capture failed: ${err.message}`)); }); this.captureProcess.on('exit', (code) => { this._isCapturing = false; if (code !== null && code !== 0 && !this._stoppedManually) { this.logger.warn({ code }, 'arecord exited with non-zero code'); } this._stoppedManually = false; }); } /** * Stop capturing audio from the microphone. */ stopCapture(): void { if (!this.captureProcess) return; this.logger.info('Stopping audio capture'); this._stoppedManually = true; this.captureProcess.kill('SIGTERM'); this.captureProcess = null; this._isCapturing = false; } /** * Play audio through the speaker. * Accepts either raw PCM or WAV (with RIFF header) data. * * @returns Promise that resolves when playback is complete */ async play(audioBuffer: Buffer): Promise { if (this._isPlaying) { this.logger.warn('Already playing audio, queueing...'); } this._isPlaying = true; const isWav = audioBuffer.length > 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'; return new Promise((resolve, reject) => { const args = isWav ? ['-D', this.config.playbackDevice, '-t', 'wav', '-'] : [ '-D', this.config.playbackDevice, '-f', 'S16_LE', '-r', String(this.config.sampleRate), '-c', String(this.config.channels), '-t', 'raw', '-', ]; const playProcess = spawn('aplay', args, { stdio: ['pipe', 'ignore', 'pipe'], }); playProcess.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg && !msg.startsWith('Playing') && !msg.startsWith('Warning')) { this.logger.error({ msg }, 'aplay stderr'); } }); playProcess.on('error', (err) => { this._isPlaying = false; reject(new Error(`Audio playback failed: ${err.message}`)); }); playProcess.on('exit', (code) => { this._isPlaying = false; if (code === 0 || code === null) { this.emit('playback_done'); resolve(); } else { reject(new Error(`aplay exited with code ${code}`)); } }); // Write audio data to aplay's stdin and close it playProcess.stdin?.write(audioBuffer); playProcess.stdin?.end(); }); } /** * Stop any currently playing audio. */ stopPlayback(): void { // aplay is spawned per-play, so we can't easily stop it here // For interrupt support, we'd track the play process this._isPlaying = false; } /** * Clean up resources. */ async destroy(): Promise { this.stopCapture(); this.removeAllListeners(); } }