ti-pote/apps/robot-client/src/services/audio.service.ts

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();
}
}