Files
claude-voice-experiment/lib/pending-query.mjs
mikael-lovqvists-claude-agent 20873be786 Add README, faster-whisper backend, and session fixes
- README explaining experimental/transparency purpose
- faster-whisper STT backend (fw-stt.mjs, faster-whisper-server.py, install-faster-whisper.sh)
- Bug fixes: Buffer alignment in on_audio, --debug-waveform URL parsing, silent fetch errors, instant dispatch timer leak
- Global uncaughtException/unhandledRejection handlers in query-demo.mjs
- Design docs: CHANGELOG, COMMAND-DISPATCH, INTERFACE-THEORY, VOICE-POLICY
- Systemd service unit templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 06:39:14 +00:00

245 lines
6.5 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 (text.match(/\w/g)?.length ?? 0) >= 1
}
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 aligned = (chunk.byteOffset % 2 === 0) ? chunk : Buffer.from(chunk)
const samples = new Int16Array(aligned.buffer, aligned.byteOffset, aligned.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) {
process.stderr.write(`[pending-query] audio reset timer (rms=${rms.toFixed(3)})\n`)
this._reset_silence_timer()
}
} else {
this._loud_streak = 0
}
}
_collapse_repeated_words(norm) {
return norm.replace(/\b(\w+)(?:\s+\1){2,}\b/g, '$1')
}
async process_utterance(text) {
const norm = this._collapse_repeated_words(
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`)
clearTimeout(this._silence_timer)
this._silence_timer = null
this.accumulated = ''
this._loud_streak = 0
if (this.use_wake_word) {
this._active = false
}
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)
}
}