+39
-9
@@ -23,16 +23,17 @@ export class VoiceLoop extends EventEmitter {
|
||||
constructor({ daemon, capture = new PipeWireCapture(), playback = new PipeWirePlayback(), wake = new WakeEngine(), vad = new VadSegmenter(), asr, tts, cooldownMs = POST_PLAYBACK_COOLDOWN_MS, listeningMode = 'conversation', now = () => Date.now(), captureRetryMs = 2000, asrRetryMs = 4000 } = {}) {
|
||||
super(); this.daemon = daemon; this.capture = capture; this.playback = playback; this.wake = wake; this.vad = vad; this.asr = asr; this.tts = tts; this.cooldownMs = cooldownMs; this.listeningMode = listeningMode; this.now = now;
|
||||
this.captureRetryMs = captureRetryMs; this.asrRetryMs = asrRetryMs;
|
||||
this.isSpeaking = false; this.cooldownUntil = 0; this.running = false; this.ptt = false; this._generation = 0; this._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics(); this._transcriptions = new Set();
|
||||
this.isSpeaking = false; this.cooldownUntil = 0; this.running = false; this.ptt = false; this.muted = false; this._generation = 0; this._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics(); this._transcriptions = new Set();
|
||||
capture.on('audio', (chunk) => this.pushAudio(chunk));
|
||||
capture.on('error', (error) => { this.status.capture = false; this.status.errors.capture = error.message; this.emit('error', error); this._scheduleCaptureRetry(); });
|
||||
capture.on('close', () => { this.status.capture = false; if (this.running) { this.status.errors.capture = 'Microphone stream closed'; this._scheduleCaptureRetry(); } });
|
||||
wake.on('unavailable', () => { this.status.wake = false; });
|
||||
wake.on('error', (error) => { this.status.wake = false; this.emit('error', error); });
|
||||
this.status = { asr: false, tts: false, capture: false, wake: false, microphone: this.asr != null, speech: this.tts != null, errors: {} };
|
||||
this.status = { asr: false, tts: false, capture: false, wake: false, muted: false, microphone: this.asr != null, speech: this.tts != null, errors: {} };
|
||||
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
||||
vad.on('level', (rms) => daemon?.emit('ListeningLevel', rms));
|
||||
vad.on('utterance', (audio) => {
|
||||
if (this.muted || this.daemon?.muted) return;
|
||||
const task = this.transcribe(audio).catch((error) => this.emit('error', error));
|
||||
this._transcriptions.add(task);
|
||||
task.finally(() => this._transcriptions.delete(task)).catch(() => {});
|
||||
@@ -48,16 +49,21 @@ export class VoiceLoop extends EventEmitter {
|
||||
}
|
||||
_notifyStatus() { try { this.daemon?.emit?.('StateChanged', this.daemon.state || 'ARMED'); } catch {} }
|
||||
_armWake() {
|
||||
if (this.muted) {
|
||||
try { this.wake.pause?.(); } catch {}
|
||||
this.status.wake = false;
|
||||
return;
|
||||
}
|
||||
try { if (this.listeningMode !== 'ptt') this.wake.start?.(); this.wake.resume(); this.status.wake = this.listeningMode !== 'ptt' && Boolean(this.wake.command || this.wake.detect); }
|
||||
catch (error) { this.status.errors.wake = error.message; this.emit('error', error); }
|
||||
}
|
||||
_armCapture() {
|
||||
if (!this.running || this.status.capture || this.asr == null) return;
|
||||
if (!this.running || this.muted || this.status.capture || this.asr == null) return;
|
||||
try { this.capture.start(); this.status.capture = true; delete this.status.errors.capture; this._notifyStatus(); }
|
||||
catch (error) { this.status.errors.capture = error.message; this.emit('error', error); this._scheduleCaptureRetry(); }
|
||||
}
|
||||
_scheduleCaptureRetry() {
|
||||
if (!this.running || this._captureRetry || this.status.capture || this.asr == null) return;
|
||||
if (!this.running || this.muted || this._captureRetry || this.status.capture || this.asr == null) return;
|
||||
this._captureRetry = setTimeout(() => {
|
||||
this._captureRetry = 0;
|
||||
this._armCapture();
|
||||
@@ -135,7 +141,30 @@ export class VoiceLoop extends EventEmitter {
|
||||
if (this._asrRetry) { clearTimeout(this._asrRetry); this._asrRetry = 0; }
|
||||
this.interrupt(); this.capture.stop(); this.wake.close(); this.vad.reset(); this.playback.stop(); await Promise.allSettled([this._speechQueue, ...this._transcriptions]); await this.asr?.stop?.(); if (this.tts !== this.asr) await this.tts?.stop?.();
|
||||
}
|
||||
setMuted(muted) {
|
||||
const next = Boolean(muted);
|
||||
this.muted = next;
|
||||
this.status.muted = next;
|
||||
if (next) {
|
||||
this._pttHeld = false;
|
||||
this.ptt = false;
|
||||
try { this.vad.reset?.(); } catch {}
|
||||
try { this.wake.pause?.(); } catch {}
|
||||
this.status.wake = false;
|
||||
if (this._captureRetry) { clearTimeout(this._captureRetry); this._captureRetry = 0; }
|
||||
try { this.capture.stop(); } catch {}
|
||||
this.status.capture = false;
|
||||
this._notifyStatus();
|
||||
return;
|
||||
}
|
||||
if (this.running) {
|
||||
this._armCapture();
|
||||
this._armWake();
|
||||
this._notifyStatus();
|
||||
}
|
||||
}
|
||||
setPushToTalk(pressed) {
|
||||
if (this.muted && pressed) return Promise.resolve();
|
||||
if (!pressed) {
|
||||
this._pttHeld = false;
|
||||
this.ptt = false;
|
||||
@@ -150,7 +179,7 @@ export class VoiceLoop extends EventEmitter {
|
||||
}).catch((error) => this.emit('error', error));
|
||||
}
|
||||
pushAudio(chunk) {
|
||||
if (!this.running || this.daemon?.locked) return;
|
||||
if (!this.running || this.muted || this.daemon?.locked) return;
|
||||
const sleeping = this.daemon?.state === 'SLEEPING';
|
||||
if (!sleeping && (this.isSpeaking || this.now() < this.cooldownUntil)) { this.metrics.feedbackDrop(); return; }
|
||||
if (!sleeping) {
|
||||
@@ -161,14 +190,15 @@ export class VoiceLoop extends EventEmitter {
|
||||
if (this.ptt || (this.listeningMode !== 'ptt' && this.daemon?.state === 'LISTENING')) this.vad.push(chunk);
|
||||
}
|
||||
wakeHeard(phrase) {
|
||||
if (!this.running || this.daemon?.locked) return;
|
||||
if (!this.running || this.muted || this.daemon?.muted || this.daemon?.locked) return;
|
||||
this.metrics.wakeAccepted(); this.daemon?.emit('WakeHeard', phrase); this.ensureAsr().catch((error) => this.emit('error', error)); this.daemon?.arm?.(); this.emit('wake', phrase);
|
||||
}
|
||||
async transcribe(audio) {
|
||||
if (this.muted || this.daemon?.muted) return;
|
||||
if (!this.status.asr || !this.asr?.transcribeAudio) return;
|
||||
const generation = this._generation;
|
||||
const text = await this.asr.transcribeAudio(audio).catch((error) => { this.emit('error', error); return ''; });
|
||||
if (!this.running || this.daemon?.locked || generation !== this._generation) return;
|
||||
if (!this.running || this.muted || this.daemon?.muted || this.daemon?.locked || generation !== this._generation) return;
|
||||
if (!isMeaningfulTranscript(text)) { this.metrics.wakeRejected(); return; }
|
||||
this.metrics.utterance();
|
||||
this.daemon?.emit('PartialTranscript', text); this.daemon?.emit('FinalTranscript', text);
|
||||
@@ -184,11 +214,11 @@ export class VoiceLoop extends EventEmitter {
|
||||
this._generation += 1;
|
||||
this.playback.stop();
|
||||
this.isSpeaking = false;
|
||||
this.wake.resume();
|
||||
if (!this.muted) this.wake.resume();
|
||||
}
|
||||
_releaseSpeaking() {
|
||||
this.isSpeaking = false;
|
||||
this.wake.resume();
|
||||
if (!this.muted) this.wake.resume();
|
||||
if (this.daemon?.state === 'SPEAKING') {
|
||||
try { this.daemon.voice?.finishSpeaking?.(); } catch {}
|
||||
if (this.daemon._finishSpeech) this.daemon._finishSpeech();
|
||||
|
||||
Reference in New Issue
Block a user