Files
claude-voice-experiment/lib/pending-query.mjs
mikael-lovqvists-claude-agent a7fa2fd218 Add Pending_Query class and voice interaction improvements
- 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>
2026-05-31 03:59:11 +00:00

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