57 lines
2.1 KiB
JavaScript
57 lines
2.1 KiB
JavaScript
const STATES = ['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING'];
|
|
const COMMANDS = new Map([
|
|
['cancel', 'cancel'], ['stop', 'cancel'], ['never mind', 'cancel'],
|
|
['hands off', 'cancel'], ['stop clicking', 'cancel'], ["that's enough", 'cancel'],
|
|
['go to sleep', 'sleep'], ['privacy mode', 'sleep'],
|
|
]);
|
|
|
|
export class VoiceStateMachine {
|
|
constructor({ now = () => Date.now(), idleMs = 30 * 60 * 1000 } = {}) {
|
|
this.now = now;
|
|
this.idleMs = idleMs;
|
|
this.state = 'ARMED';
|
|
this.lastActivity = now();
|
|
}
|
|
|
|
transition(next, reason = 'unspecified') {
|
|
if (!STATES.includes(next)) throw new Error(`unknown Jarvis state: ${next}`);
|
|
const allowed = {
|
|
ARMED: ['LISTENING', 'SLEEPING'], LISTENING: ['THINKING', 'ARMED', 'SLEEPING', 'SPEAKING'],
|
|
THINKING: ['SPEAKING', 'ARMED', 'LISTENING'], SPEAKING: ['LISTENING', 'ARMED', 'THINKING'],
|
|
SLEEPING: ['LISTENING', 'ARMED'],
|
|
};
|
|
if (next !== this.state && !allowed[this.state].includes(next)) {
|
|
throw new Error(`invalid state transition ${this.state} -> ${next}`);
|
|
}
|
|
const previous = this.state;
|
|
this.state = next;
|
|
this.lastActivity = this.now();
|
|
return { previous, state: next, reason };
|
|
}
|
|
|
|
wake() { return this.transition('LISTENING', 'wake'); }
|
|
utterance() { return this.transition('THINKING', 'utterance'); }
|
|
typedUtterance() {
|
|
if (this.state === 'ARMED' || this.state === 'SLEEPING') this.wake();
|
|
return this.utterance();
|
|
}
|
|
speak() { return this.transition('SPEAKING', 'reply'); }
|
|
finishSpeaking() { return this.transition('LISTENING', 'playback-finished'); }
|
|
cancel() { return this.transition('ARMED', 'cancel'); }
|
|
sleep() { return this.transition('SLEEPING', 'idle-or-privacy'); }
|
|
|
|
command(text) {
|
|
const command = COMMANDS.get(String(text || '').trim().toLowerCase());
|
|
if (command === 'cancel') this.cancel();
|
|
if (command === 'sleep') this.sleep();
|
|
return command || null;
|
|
}
|
|
|
|
expireIdle() {
|
|
if (['ARMED', 'LISTENING'].includes(this.state) && this.now() - this.lastActivity >= this.idleMs) this.sleep();
|
|
return this.state;
|
|
}
|
|
}
|
|
|
|
export { STATES };
|