204 lines
5.8 KiB
TypeScript
204 lines
5.8 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
this.stopCapture();
|
|
this.removeAllListeners();
|
|
}
|
|
}
|