Download frontend/src/test/speechServiceStt.test.js from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/test/speechServiceStt.test.js
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/test/speechServiceStt.test.js
-
curl -L -o speechServiceStt.test.js https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/test/speechServiceStt.test.js
16 kB
| /** | |
| * The stop guard in `public/js/speech-service.js`. | |
| * | |
| * This is the fix for the reported symptom: hands-free voice logged | |
| * | |
| * stt_onstart | |
| * speech_ended speechMs: 1389 silenceMs: 805 | |
| * stt_stop_requested { reason: 'vad_silence' } | |
| * stt_onend { hadResult: false } | |
| * | |
| * with no interim, no result and no error. HomePilot's VAD reaches its silence | |
| * window on a short utterance before Chrome's recognizer has streamed enough | |
| * audio to finalize anything, and honouring that stop immediately ends the | |
| * session empty β cleanly, so not even a `no-speech` error is raised. | |
| * | |
| * `speech-service.js` is a classic script served from `public/`, so the suite | |
| * evaluates it the way the browser does rather than importing it. | |
| */ | |
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import { readFileSync } from 'node:fs'; | |
| import { dirname, resolve } from 'node:path'; | |
| import { fileURLToPath } from 'node:url'; | |
| const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); | |
| const SOURCE = readFileSync(resolve(ROOT, 'frontend/public/js/speech-service.js'), 'utf8'); | |
| /** Stand-in for the browser's SpeechRecognition, with no audio pipeline. */ | |
| class FakeRecognition { | |
| constructor() { | |
| this.continuous = false; | |
| this.interimResults = false; | |
| this.lang = ''; | |
| this.startCalls = 0; | |
| this.stopCalls = 0; | |
| this.abortCalls = 0; | |
| FakeRecognition.last = this; | |
| } | |
| start() { | |
| this.startCalls += 1; | |
| this.onstart?.(); | |
| } | |
| stop() { | |
| this.stopCalls += 1; | |
| } | |
| abort() { | |
| this.abortCalls += 1; | |
| } | |
| /** Deliver a final transcript the way Chrome's `onresult` does. */ | |
| emitFinal(transcript) { | |
| this.onresult?.({ | |
| resultIndex: 0, | |
| results: { length: 1, 0: { isFinal: true, 0: { transcript } } }, | |
| }); | |
| } | |
| emitInterim(transcript) { | |
| this.onresult?.({ | |
| resultIndex: 0, | |
| results: { length: 1, 0: { isFinal: false, 0: { transcript } } }, | |
| }); | |
| } | |
| /** The capture opened β the recognizer is receiving samples, whatever is in them. */ | |
| emitAudioStart() { | |
| this.onaudiostart?.(); | |
| } | |
| /** The recognizer's own detector found speech in those samples. */ | |
| emitSpeechStart() { | |
| this.onspeechstart?.(); | |
| } | |
| } | |
| function loadSpeechService(Recognition = FakeRecognition) { | |
| window.SpeechRecognition = Recognition; | |
| delete window.SpeechService; | |
| window.__HOMEPILOT_MIC_DEBUG__ = []; | |
| // eslint-disable-next-line no-new-func | |
| new Function(SOURCE)(); | |
| return window.SpeechService; | |
| } | |
| const traceEvents = () => (window.__HOMEPILOT_MIC_DEBUG__ || []).map((entry) => entry.event); | |
| describe('SpeechService speech-to-text', () => { | |
| let service; | |
| let nowMs = 0; | |
| let realPerformanceNow; | |
| /** Move both clocks together β the timer queue and the one the service reads. */ | |
| const advance = (ms) => { | |
| nowMs += ms; | |
| vi.advanceTimersByTime(ms); | |
| }; | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| // The service measures a turn with `performance.now()`, and the fake clock does not reach | |
| // it inside the evaluated classic script: every elapsed reading came back 0, so every stop | |
| // looked like it arrived during warm-up and the threshold tests below never reached the | |
| // threshold they name. Driving the clock by hand is the only way this file tests the | |
| // guards rather than the number zero. | |
| // Patched on `window` specifically: the service is evaluated as a classic script, so it | |
| // resolves `performance` off the global object, which is not necessarily the binding this | |
| // module sees. Spying on the module-scope one left the service reading a clock that never | |
| // moved. | |
| nowMs = 0; | |
| realPerformanceNow = window.performance.now; | |
| window.performance.now = () => nowMs; | |
| localStorage.clear(); | |
| service = loadSpeechService(); | |
| }); | |
| afterEach(() => { | |
| window.performance.now = realPerformanceNow; | |
| vi.useRealTimers(); | |
| vi.restoreAllMocks(); | |
| }); | |
| it('defers a stop that would cut recognition off during warm-up', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| expect(recognition.startCalls).toBe(1); | |
| // The VAD's silence window on a ~1.4s utterance lands well inside warm-up. | |
| advance(600); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(0); | |
| expect(traceEvents()).toContain('stop_deferred_warming_up'); | |
| // Once the minimum listen window has passed, the stop is applied for real. | |
| advance(service.STT_MIN_LISTEN_MS); | |
| expect(recognition.stopCalls).toBe(1); | |
| expect(traceEvents()).toContain('stop_applied_after_defer'); | |
| }); | |
| it('stops immediately once a result has landed', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| advance(300); | |
| recognition.emitFinal('turn the lights on'); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(1); | |
| expect(traceEvents()).not.toContain('stop_deferred_warming_up'); | |
| }); | |
| it('cancels a deferred stop when a result arrives while waiting', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| advance(200); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(0); | |
| recognition.emitFinal('play some music'); | |
| advance(5000); | |
| // The turn ended on its own terms; the deferred stop must not fire late | |
| // and cut off the *next* session. | |
| expect(recognition.stopCalls).toBe(0); | |
| }); | |
| describe('a recognizer that has captured audio but heard no speech', () => { | |
| /** | |
| * The reported failure. HomePilot's VAD hears the selected microphone and calls time on | |
| * the turn; the browser recognizer is on a different device and has heard nothing. The | |
| * VAD's verdict is evidence about its own microphone, not about this recognizer's, so | |
| * honouring it guarantees an empty turn β and leaves "was it deaf, or did we cut it off?" | |
| * unanswerable, which is exactly the question the diagnosis turns on. | |
| */ | |
| it('keeps listening past warm-up rather than guaranteeing an empty turn', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| // Past the warm-up guard, so only the new one can be holding it open. | |
| advance(2400); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(0); | |
| const deferral = (window.__HOMEPILOT_MIC_DEBUG__ || []).find( | |
| (entry) => entry.event === 'stop_deferred_warming_up', | |
| ); | |
| expect(deferral?.details?.deferredBy).toBe('no_speech_yet'); | |
| }); | |
| it('gives up at the grace window, so a deaf device still ends the turn', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| advance(2400); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| advance(service.STT_NO_SPEECH_GRACE_MS); | |
| expect(recognition.stopCalls).toBe(1); | |
| expect(traceEvents()).toContain('stop_applied_after_defer'); | |
| }); | |
| it('does not delay a turn the recognizer actually heard', async () => { | |
| // The whole point is to cost nothing when the recognizer is working: once it has heard | |
| // speech, the VAD's silence window is a perfectly good endpoint and waiting would make | |
| // every successful turn slower. | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| recognition.emitSpeechStart(); | |
| advance(2400); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(1); | |
| }); | |
| it('does not delay when interim words came back without a speechstart event', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| recognition.emitInterim('hello'); | |
| advance(2400); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| expect(recognition.stopCalls).toBe(1); | |
| }); | |
| it('still yields to a forced stop', async () => { | |
| // A turn lock, a teardown or the user pressing stop must never wait on a grace window. | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| advance(2400); | |
| service.stopSTT({ reason: 'manual_button', force: true }); | |
| expect(recognition.stopCalls).toBe(1); | |
| }); | |
| it('never holds a turn past the hard ceiling', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.emitAudioStart(); | |
| advance(service.STT_MAX_LISTEN_MS - 100); | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| advance(200); | |
| expect(recognition.stopCalls).toBe(1); | |
| }); | |
| }); | |
| it('honours a forced stop without waiting', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| service.stopSTT({ reason: 'manual_button', force: true }); | |
| expect(recognition.stopCalls).toBe(1); | |
| expect(service.getSttDiagnostics().stoppedBy).toBe('manual_button:forced'); | |
| }); | |
| it('never holds a silent session open past the maximum', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| service.stopSTT({ reason: 'vad_silence' }); | |
| advance(service.STT_MAX_LISTEN_MS); | |
| expect(recognition.stopCalls).toBe(1); | |
| }); | |
| it('records which lifecycle events the browser actually reported', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.onaudiostart?.(); | |
| recognition.onspeechstart?.(); | |
| recognition.emitInterim('turn the'); | |
| recognition.emitFinal('turn the lights on'); | |
| recognition.onend?.(); | |
| const diagnostics = service.getSttDiagnostics(); | |
| expect(diagnostics.sawAudioStart).toBe(true); | |
| expect(diagnostics.sawSpeechStart).toBe(true); | |
| expect(diagnostics.sawInterim).toBe(true); | |
| expect(diagnostics.sawResult).toBe(true); | |
| }); | |
| it('distinguishes a session that captured audio but heard no speech', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| recognition.onaudiostart?.(); | |
| recognition.onend?.(); | |
| const diagnostics = service.getSttDiagnostics(); | |
| expect(diagnostics.sawAudioStart).toBe(true); | |
| expect(diagnostics.sawSpeechStart).toBe(false); | |
| expect(diagnostics.sawResult).toBe(false); | |
| }); | |
| it('traces to the shared HomePilot:Mic buffer without recording transcript text', async () => { | |
| await service.startSTT(); | |
| FakeRecognition.last.emitFinal('a secret sentence'); | |
| const entries = window.__HOMEPILOT_MIC_DEBUG__; | |
| expect(entries.every((entry) => entry.scope === 'speech-service')).toBe(true); | |
| expect(JSON.stringify(entries)).not.toContain('a secret sentence'); | |
| const final = entries.find((entry) => entry.event === 'recognition_final'); | |
| expect(final.details.characters).toBe('a secret sentence'.length); | |
| }); | |
| it('surfaces the real reason when start() throws', async () => { | |
| const error = new Error('already started'); | |
| error.name = 'InvalidStateError'; | |
| vi.spyOn(FakeRecognition.prototype, 'start').mockImplementation(() => { throw error; }); | |
| const onError = vi.fn(); | |
| const started = await service.startSTT({ onError }); | |
| expect(started).toBe(false); | |
| expect(onError).toHaveBeenCalledWith('InvalidStateError'); | |
| expect(service.getSttDiagnostics().error).toBe('InvalidStateError'); | |
| }); | |
| describe('restarting the recognizer right after aborting it', () => { | |
| /** | |
| * The reported failure, from a call that listened but never transcribed: | |
| * | |
| * recognition_audiostart β a turn is live | |
| * abort_requested β the hand-off drops it | |
| * start_requested | |
| * start_failed β InvalidStateError | |
| * recognition_audioend | |
| * recognition_onerror | |
| * recognition_onend β the aborted session ends *after* the failed start | |
| * | |
| * `abort()` returns immediately but the browser keeps the session alive until it has | |
| * torn the capture down, and `start()` before then throws on the same object. The old | |
| * code cleared `isRecognizing` at the call site, so the guard saw an idle recognizer and | |
| * started straight into the race. Losing it killed voice input for the rest of the call: | |
| * the microphone stayed open, the input meter kept moving, and nothing was transcribed. | |
| */ | |
| class EndsAsynchronously extends FakeRecognition { | |
| start() { | |
| // Exactly Chrome's rule: one live session per object, and aborting does not end it. | |
| if (this.live) { | |
| const error = new Error('recognition has already started'); | |
| error.name = 'InvalidStateError'; | |
| throw error; | |
| } | |
| this.live = true; | |
| super.start(); | |
| } | |
| abort() { | |
| this.abortCalls += 1; | |
| // Note what is *not* here: `onend`. The browser delivers it on a later task. | |
| } | |
| /** The teardown the browser gets round to after the abort. */ | |
| finishEnding() { | |
| this.live = false; | |
| this.onend?.(); | |
| } | |
| } | |
| beforeEach(() => { | |
| service = loadSpeechService(EndsAsynchronously); | |
| }); | |
| it('waits for the aborted session to end instead of racing it', async () => { | |
| await service.startSTT(); | |
| const first = EndsAsynchronously.last; | |
| first.emitAudioStart(); | |
| service.abortSTT('handoff'); | |
| expect(first.abortCalls).toBe(1); | |
| // The hand-off restarts listening immediately, as the voice controller does. | |
| const restart = service.startSTT(); | |
| // Nothing has been started yet: the previous session has not ended. | |
| expect(first.startCalls).toBe(1); | |
| expect(traceEvents()).toContain('start_waiting_for_previous_end'); | |
| // The browser gets round to the teardown it owed us. | |
| first.finishEnding(); | |
| await expect(restart).resolves.toBe(true); | |
| expect(first.startCalls).toBe(2); | |
| expect(traceEvents()).not.toContain('start_failed'); | |
| }); | |
| it('recovers when the end never arrives, rather than losing voice input', async () => { | |
| await service.startSTT(); | |
| const recognition = EndsAsynchronously.last; | |
| service.abortSTT('handoff'); | |
| const restart = service.startSTT(); | |
| // `onend` is never delivered. The wait is a ceiling, not a promise of one. | |
| advance(service.RECOGNITION_END_TIMEOUT_MS); | |
| await Promise.resolve(); | |
| // The start that follows still hits InvalidStateError, and the retry waits out its own | |
| // window before reporting β a bounded failure, not a hang. | |
| advance(service.RECOGNITION_END_TIMEOUT_MS); | |
| await expect(restart).resolves.toBe(false); | |
| expect(traceEvents()).toContain('start_retry_after_invalid_state'); | |
| expect(recognition.abortCalls).toBe(1); | |
| }); | |
| it('starts cleanly when the previous session ended on its own', async () => { | |
| await service.startSTT(); | |
| const first = EndsAsynchronously.last; | |
| first.finishEnding(); | |
| // Nothing is winding down, so there is nothing to wait for. | |
| await expect(service.startSTT()).resolves.toBe(true); | |
| expect(traceEvents()).not.toContain('start_waiting_for_previous_end'); | |
| }); | |
| }); | |
| it('aborts on demand so another surface can take the microphone', async () => { | |
| await service.startSTT(); | |
| const recognition = FakeRecognition.last; | |
| expect(service.abortSTT('chat_composer_mic')).toBe(true); | |
| expect(recognition.abortCalls).toBe(1); | |
| expect(service.isRecognizing).toBe(false); | |
| }); | |
| it('uses the configured recognition language instead of hard-coded en-US', async () => { | |
| service.setRecognitionLang('es-ES'); | |
| await service.startSTT(); | |
| expect(FakeRecognition.last.lang).toBe('es-ES'); | |
| expect(localStorage.getItem('homepilot_stt_lang')).toBe('es-ES'); | |
| }); | |
| }); | |