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