- lib/pending-query.mjs: new state machine for query accumulation wake word, silence timer, send/cancel/pause/resume, instant dispatch, mode toggle (always listen / stop listening), mode query - query-demo.mjs: refactored to use Pending_Query; wake word on by default with silence timer; chimes for dispatch/working/cancel/activate - tts-server.mjs: track last_speak_at, expose /activity endpoint, chime playback via Python queue (soundfile + librosa), preload on startup - chatterbox-server.py: chime and preload commands via stdin protocol - lib/chatterbox-tts.mjs: play_chime and preload_chime methods - test-chime.mjs: simple chime test script - voices.yaml: configured ready/cancel/working/dispatch chimes - CLEANUP-PLAN.md: updated with current state, command vocabulary, future plans Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
6.0 KiB
JavaScript
230 lines
6.0 KiB
JavaScript
const LOUD_STREAK_NEEDED = 3
|
|
|
|
export class Pending_Query {
|
|
constructor({
|
|
silence_timeout = 6000,
|
|
use_timer = false,
|
|
use_classifier = false,
|
|
classifier = null,
|
|
send_words = new Set(['go', 'done', 'send']),
|
|
cancel_words = new Set(['abort', 'cancel']),
|
|
use_wake_word = false,
|
|
wake_words = new Set(['computer']),
|
|
always_listen_words = new Set(['always listen']),
|
|
stop_listening_words = new Set(['stop listening']),
|
|
mode_query_words = new Set(['mode query']),
|
|
instant_dispatch = new Map([['yes please', 'Yes please.']]),
|
|
pause_words = new Set(['pause']),
|
|
resume_words = new Set(['resume', 'continue']),
|
|
on_submit = null,
|
|
on_cancel = null,
|
|
on_empty_submit = null,
|
|
on_activate = null,
|
|
on_mode_change = null,
|
|
on_mode_query = null,
|
|
} = {}) {
|
|
this.accumulated = ''
|
|
this.silence_timeout = silence_timeout
|
|
this.use_timer = use_timer
|
|
this.use_classifier = use_classifier
|
|
this.classifier = classifier
|
|
this.send_words = send_words
|
|
this.cancel_words = cancel_words
|
|
this.use_wake_word = use_wake_word
|
|
this.wake_words = wake_words
|
|
this.always_listen_words = always_listen_words
|
|
this.stop_listening_words = stop_listening_words
|
|
this.mode_query_words = mode_query_words
|
|
this.instant_dispatch = instant_dispatch
|
|
this.pause_words = pause_words
|
|
this.resume_words = resume_words
|
|
this._paused = false
|
|
this.on_submit = on_submit
|
|
this.on_cancel = on_cancel
|
|
this.on_empty_submit = on_empty_submit
|
|
this.on_activate = on_activate
|
|
this.on_mode_change = on_mode_change
|
|
this.on_mode_query = on_mode_query
|
|
this._active = !use_wake_word
|
|
this._silence_timer = null
|
|
this._loud_streak = 0
|
|
}
|
|
|
|
get is_empty() {
|
|
return !this.accumulated.trim()
|
|
}
|
|
|
|
_has_real_words(text) {
|
|
return /[a-z]{2,}/i.test(text)
|
|
}
|
|
|
|
append(text) {
|
|
this.accumulated = this.accumulated ? `${this.accumulated}\n${text}` : text
|
|
if (this.use_timer) {
|
|
this._reset_silence_timer()
|
|
}
|
|
}
|
|
|
|
async check_classifier() {
|
|
if (!this.use_classifier || !this.classifier || this.is_empty) {
|
|
return false
|
|
}
|
|
const last_line = this.accumulated.split('\n').at(-1)
|
|
return await this.classifier(last_line)
|
|
}
|
|
|
|
on_audio(chunk) {
|
|
if (!this.use_timer || this.is_empty) {
|
|
return
|
|
}
|
|
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.byteLength >> 1)
|
|
let sum = 0
|
|
for (let i = 0; i < samples.length; i++) {
|
|
sum += samples[i] * samples[i]
|
|
}
|
|
const rms = Math.sqrt(sum / samples.length) / 32768
|
|
if (rms > 0.02) {
|
|
this._loud_streak++
|
|
if (this._loud_streak >= LOUD_STREAK_NEEDED) {
|
|
this._reset_silence_timer()
|
|
}
|
|
} else {
|
|
this._loud_streak = 0
|
|
}
|
|
}
|
|
|
|
async process_utterance(text) {
|
|
const norm = text.trim().toLowerCase().replace(/[^a-z ]/g, '').replace(/\s+/g, ' ').trim()
|
|
|
|
process.stderr.write(`[pending-query] utterance norm: ${JSON.stringify(norm)}\n`)
|
|
|
|
if (this.mode_query_words.has(norm)) {
|
|
process.stderr.write(`[pending-query] mode query — wake_word=${this.use_wake_word}, active=${this._active}\n`)
|
|
this.on_mode_query?.({ use_wake_word: this.use_wake_word, active: this._active })
|
|
return
|
|
}
|
|
|
|
if (this.always_listen_words.has(norm)) {
|
|
this.use_wake_word = false
|
|
this.use_timer = false
|
|
this._active = true
|
|
clearTimeout(this._silence_timer)
|
|
this._silence_timer = null
|
|
process.stderr.write('[pending-query] mode: always listening\n')
|
|
this.on_mode_change?.({ use_wake_word: false })
|
|
return
|
|
}
|
|
|
|
if (this.stop_listening_words.has(norm)) {
|
|
this.use_wake_word = true
|
|
this.use_timer = true
|
|
this._active = false
|
|
process.stderr.write('[pending-query] mode: wake word\n')
|
|
this.on_mode_change?.({ use_wake_word: true })
|
|
return
|
|
}
|
|
|
|
const norm_compact_early = norm.replace(/ /g, '')
|
|
|
|
if (this.resume_words.has(norm_compact_early)) {
|
|
if (this._paused) {
|
|
this._paused = false
|
|
process.stderr.write('[pending-query] resumed\n')
|
|
if (this._active && this.use_timer && !this.is_empty) {
|
|
this._reset_silence_timer()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (this.pause_words.has(norm_compact_early)) {
|
|
this._paused = true
|
|
clearTimeout(this._silence_timer)
|
|
this._silence_timer = null
|
|
process.stderr.write('[pending-query] paused\n')
|
|
return
|
|
}
|
|
|
|
if (this._paused) {
|
|
process.stderr.write(`[pending-query] paused, ignored: ${JSON.stringify(text)}\n`)
|
|
return
|
|
}
|
|
|
|
if (this.instant_dispatch.has(norm)) {
|
|
const query = this.instant_dispatch.get(norm)
|
|
process.stderr.write(`[pending-query] instant dispatch: ${JSON.stringify(query)}\n`)
|
|
this.on_submit?.(query)
|
|
return
|
|
}
|
|
|
|
const norm_compact = norm_compact_early
|
|
|
|
if (!this._active) {
|
|
if (this.wake_words.has(norm_compact)) {
|
|
this._active = true
|
|
process.stderr.write('[pending-query] wake word detected — active\n')
|
|
this.on_activate?.()
|
|
} else {
|
|
process.stderr.write(`[pending-query] idle, ignored: ${JSON.stringify(text)}\n`)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (this.cancel_words.has(norm_compact)) {
|
|
this.cancel()
|
|
return
|
|
}
|
|
if (this.send_words.has(norm_compact)) {
|
|
this.submit()
|
|
return
|
|
}
|
|
if (!this._has_real_words(text)) {
|
|
process.stderr.write(`[pending-query] noise fragment dropped: ${JSON.stringify(text)}\n`)
|
|
return
|
|
}
|
|
this.append(text)
|
|
process.stderr.write(`[pending-query] fragment: ${JSON.stringify(this.accumulated)}\n`)
|
|
if (await this.check_classifier()) {
|
|
this.submit()
|
|
}
|
|
}
|
|
|
|
submit() {
|
|
clearTimeout(this._silence_timer)
|
|
this._silence_timer = null
|
|
const text = this.accumulated.trim()
|
|
this.accumulated = ''
|
|
this._loud_streak = 0
|
|
if (this.use_wake_word) {
|
|
this._active = false
|
|
}
|
|
if (text) {
|
|
this.on_submit?.(text)
|
|
} else {
|
|
this.on_empty_submit?.()
|
|
}
|
|
}
|
|
|
|
cancel() {
|
|
clearTimeout(this._silence_timer)
|
|
this._silence_timer = null
|
|
this.accumulated = ''
|
|
this._loud_streak = 0
|
|
if (this.use_wake_word) {
|
|
this._active = false
|
|
}
|
|
this.on_cancel?.()
|
|
}
|
|
|
|
_reset_silence_timer() {
|
|
clearTimeout(this._silence_timer)
|
|
this._silence_timer = setTimeout(() => {
|
|
if (this.is_empty) {
|
|
return
|
|
}
|
|
process.stderr.write('[pending-query] silence timeout — submitting\n')
|
|
this.submit()
|
|
}, this.silence_timeout)
|
|
}
|
|
}
|