+82
-31
@@ -29,7 +29,7 @@ export class VoiceLoop extends EventEmitter {
|
||||
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, errors: {} };
|
||||
this.status = { asr: false, tts: false, capture: false, wake: 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) => {
|
||||
@@ -41,23 +41,10 @@ export class VoiceLoop extends EventEmitter {
|
||||
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
for (const [name, adapter] of [['tts', this.tts], ['asr', this.asr]]) {
|
||||
try {
|
||||
if (adapter) { await adapter.start?.(); this.status[name] = true; }
|
||||
else if (name === 'tts') this.status.errors.tts = 'Spoken replies are disabled';
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error);
|
||||
this.status.errors[name] = message;
|
||||
this.emit('error', new Error(`${name.toUpperCase()}: ${message}`));
|
||||
}
|
||||
}
|
||||
if (!this.tts) this.status.errors.tts = 'Spoken replies are disabled';
|
||||
this.running = true;
|
||||
if (this.status.asr) {
|
||||
this._armCapture();
|
||||
this._armWake();
|
||||
} else if (this.asr) {
|
||||
this._scheduleAsrRetry();
|
||||
}
|
||||
this._armWake();
|
||||
this._armCapture();
|
||||
}
|
||||
_notifyStatus() { try { this.daemon?.emit?.('StateChanged', this.daemon.state || 'ARMED'); } catch {} }
|
||||
_armWake() {
|
||||
@@ -65,12 +52,12 @@ export class VoiceLoop extends EventEmitter {
|
||||
catch (error) { this.status.errors.wake = error.message; this.emit('error', error); }
|
||||
}
|
||||
_armCapture() {
|
||||
if (!this.running || !this.status.asr || this.status.capture) return;
|
||||
if (!this.running || 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.status.asr) return;
|
||||
if (!this.running || this._captureRetry || this.status.capture || this.asr == null) return;
|
||||
this._captureRetry = setTimeout(() => {
|
||||
this._captureRetry = 0;
|
||||
this._armCapture();
|
||||
@@ -82,19 +69,65 @@ export class VoiceLoop extends EventEmitter {
|
||||
this._asrRetry = setTimeout(() => {
|
||||
this._asrRetry = 0;
|
||||
if (!this.running || this.status.asr || !this.asr) return;
|
||||
this.asr.start().then(() => {
|
||||
if (!this.running || this.status.asr) return;
|
||||
this.ensureAsr().catch(() => {});
|
||||
}, this.asrRetryMs);
|
||||
this._asrRetry.unref?.();
|
||||
}
|
||||
async ensureAsr() {
|
||||
if (!this.asr) return false;
|
||||
if (this.status.asr) return true;
|
||||
if (this._asrStarting) return this._asrStarting;
|
||||
this._asrStarting = (async () => {
|
||||
try {
|
||||
await this.asr.start();
|
||||
if (!this.running) return false;
|
||||
this.status.asr = true;
|
||||
delete this.status.errors.asr;
|
||||
this._armCapture();
|
||||
this._armWake();
|
||||
this._notifyStatus();
|
||||
}).catch((error) => {
|
||||
this.status.errors.asr = String(error?.message || error);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error);
|
||||
this.status.errors.asr = message;
|
||||
this.emit('error', new Error(`ASR: ${message}`));
|
||||
this._scheduleAsrRetry();
|
||||
});
|
||||
}, this.asrRetryMs);
|
||||
this._asrRetry.unref?.();
|
||||
return false;
|
||||
} finally {
|
||||
this._asrStarting = null;
|
||||
}
|
||||
})();
|
||||
return this._asrStarting;
|
||||
}
|
||||
async ensureTts() {
|
||||
if (!this.tts) { this.status.errors.tts = 'Spoken replies are disabled'; return false; }
|
||||
if (this.status.tts) return true;
|
||||
if (this._ttsStarting) return this._ttsStarting;
|
||||
this._ttsStarting = (async () => {
|
||||
try {
|
||||
await this.tts.start();
|
||||
this.status.tts = true;
|
||||
delete this.status.errors.tts;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = String(error?.message || error);
|
||||
this.status.errors.tts = message;
|
||||
this.emit('error', new Error(`TTS: ${message}`));
|
||||
return false;
|
||||
} finally {
|
||||
this._ttsStarting = null;
|
||||
}
|
||||
})();
|
||||
return this._ttsStarting;
|
||||
}
|
||||
async parkModels() {
|
||||
if (this._asrRetry) { clearTimeout(this._asrRetry); this._asrRetry = 0; }
|
||||
this._asrStarting = null;
|
||||
this._ttsStarting = null;
|
||||
await this.asr?.stop?.();
|
||||
if (this.tts && this.tts !== this.asr) await this.tts.stop?.();
|
||||
this.status.asr = false;
|
||||
this.status.tts = false;
|
||||
}
|
||||
async stop() {
|
||||
this.running = false;
|
||||
@@ -102,17 +135,34 @@ 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?.();
|
||||
}
|
||||
setPushToTalk(pressed) { this.ptt = Boolean(pressed) && this.status.asr && this.status.capture; if (this.ptt) this.wakeHeard('push-to-talk'); else this.vad.end(); }
|
||||
setPushToTalk(pressed) {
|
||||
if (!pressed) {
|
||||
this._pttHeld = false;
|
||||
this.ptt = false;
|
||||
this.vad.end();
|
||||
return Promise.resolve();
|
||||
}
|
||||
this._pttHeld = true;
|
||||
return this.ensureAsr().then((ok) => {
|
||||
if (!this._pttHeld) return;
|
||||
this.ptt = Boolean(ok && this.status.capture);
|
||||
if (this.ptt) this.wakeHeard('push-to-talk');
|
||||
}).catch((error) => this.emit('error', error));
|
||||
}
|
||||
pushAudio(chunk) {
|
||||
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
||||
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); return; }
|
||||
try { this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk?.length ? 0.01 : 0))); } catch {}
|
||||
if (!this.running || this.daemon?.locked) return;
|
||||
const sleeping = this.daemon?.state === 'SLEEPING';
|
||||
if (!sleeping && (this.isSpeaking || this.now() < this.cooldownUntil)) { this.metrics.feedbackDrop(); return; }
|
||||
if (!sleeping) {
|
||||
try { this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk?.length ? 0.01 : 0))); } catch {}
|
||||
}
|
||||
if (!this.ptt && this.listeningMode !== 'ptt') this.wake.push(chunk);
|
||||
if (sleeping) return;
|
||||
if (this.ptt || (this.listeningMode !== 'ptt' && this.daemon?.state === 'LISTENING')) this.vad.push(chunk);
|
||||
}
|
||||
wakeHeard(phrase) {
|
||||
if (!this.running || this.daemon?.locked) return;
|
||||
this.metrics.wakeAccepted(); this.daemon?.emit('WakeHeard', phrase); this.daemon?.arm?.(); this.emit('wake', phrase);
|
||||
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.status.asr || !this.asr?.transcribeAudio) return;
|
||||
@@ -147,6 +197,7 @@ export class VoiceLoop extends EventEmitter {
|
||||
}
|
||||
async speak(text) {
|
||||
const generation = this._generation || 0;
|
||||
if (this.tts && !this.status.tts && typeof this.tts.start === 'function') await this.ensureTts();
|
||||
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) {
|
||||
this._releaseSpeaking();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user