Download frontend/src/test/sttEngineHandoff.test.ts from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 8.9 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/test/sttEngineHandoff.test.ts
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/test/sttEngineHandoff.test.ts
-
curl -L -o sttEngineHandoff.test.ts https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/test/sttEngineHandoff.test.ts
8.9 kB
| /** | |
| * Changing the speech-to-text engine while something is already listening. | |
| * | |
| * ββ The mechanism, and why it is a second subscription βββββββββββββββββββββββββββββββββββ | |
| * | |
| * A surface that is merely *configured* by the engine reads it from the runtime state and | |
| * re-renders β `subscribeSttRuntime`, which is what the Voice capture effect keys on. A | |
| * surface that is **capturing right now** has a different problem: it is mid-turn on the old | |
| * engine, holding the microphone, with the user's half-dictated sentence in a draft. For it | |
| * the change is not a value to render, it is a hand-off to perform. | |
| * | |
| * `subscribeEffectiveEngine` is that signal, and it exists so the two cases a plain value | |
| * comparison gets wrong are filtered once rather than in every consumer: | |
| * | |
| * - the **first** resolve of a session (`null β web-speech`) is the session starting, not a | |
| * hand-off β firing there would interrupt a capture that has only just opened; | |
| * - a refresh that lands the **same** engine must not interrupt a turn in progress at all. | |
| * | |
| * The consequence of not having it: the chat composer resolved the engine once, at the moment | |
| * the microphone button was pressed, and never looked again. Changing the engine in Settings | |
| * mid-dictation left it recording through the engine the user had just moved away from β | |
| * audio still going to the browser recognizer after they chose on-device transcription for | |
| * privacy, which is the one way this can be wrong that actually matters. | |
| */ | |
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import { readFileSync } from 'node:fs'; | |
| import { dirname, resolve } from 'node:path'; | |
| import { fileURLToPath } from 'node:url'; | |
| vi.mock('../ui/media/sttService', () => ({ | |
| getSttCapability: vi.fn(async () => ({ | |
| available: true, provider: 'whisper-local', remote: false, hint: null, | |
| })), | |
| transcribeBlob: vi.fn(), | |
| resetSttCapabilityCache: vi.fn(), | |
| openSelectedMicrophone: vi.fn(), | |
| recordAndTranscribe: vi.fn(), | |
| SttUnavailableError: class SttUnavailableError extends Error {}, | |
| })); | |
| import { | |
| applySttSessionOverride, | |
| describeEngineHandoff, | |
| ensureSttRuntimeResolved, | |
| resetSttRuntimeForTests, | |
| subscribeEffectiveEngine, | |
| } from '../ui/media/sttRuntime'; | |
| const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); | |
| const app = readFileSync(resolve(ROOT, 'frontend/src/ui/App.tsx'), 'utf8'); | |
| beforeEach(() => { | |
| localStorage.clear(); | |
| resetSttRuntimeForTests(); | |
| (window as unknown as { SpeechRecognition: unknown }).SpeechRecognition = class {}; | |
| (globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder = class {}; | |
| Object.defineProperty(navigator, 'mediaDevices', { | |
| configurable: true, | |
| value: { getUserMedia: vi.fn(), enumerateDevices: vi.fn(async () => []) }, | |
| }); | |
| }); | |
| describe('the engine hand-off signal', () => { | |
| it('stays quiet while the session is deciding its first engine', async () => { | |
| // `null β web-speech` is the session starting. A hand-off here would tear down a capture | |
| // that has only just opened, on behalf of a change that never happened. | |
| const seen: string[] = []; | |
| subscribeEffectiveEngine((next) => seen.push(next)); | |
| await ensureSttRuntimeResolved(); | |
| expect(seen).toEqual([]); | |
| }); | |
| it('fires once when the engine really changes', async () => { | |
| await ensureSttRuntimeResolved(); | |
| const seen: Array<[string, string]> = []; | |
| subscribeEffectiveEngine((next, previous) => seen.push([previous, next])); | |
| applySttSessionOverride('homepilot-backend', 'because', 'chat'); | |
| expect(seen).toEqual([['web-speech', 'homepilot-backend']]); | |
| }); | |
| it('does not fire when a change lands the same engine', async () => { | |
| await ensureSttRuntimeResolved(); | |
| applySttSessionOverride('homepilot-backend', 'because', 'chat'); | |
| const seen: string[] = []; | |
| subscribeEffectiveEngine((next) => seen.push(next)); | |
| applySttSessionOverride('homepilot-backend', 'again', 'voice'); | |
| // Re-announcing would restart a healthy capture for nothing. | |
| expect(seen).toEqual([]); | |
| }); | |
| it('reaches every surface, not just the one that caused it', async () => { | |
| await ensureSttRuntimeResolved(); | |
| const voice: string[] = []; | |
| const chat: string[] = []; | |
| subscribeEffectiveEngine((next) => voice.push(next)); | |
| subscribeEffectiveEngine((next) => chat.push(next)); | |
| applySttSessionOverride('homepilot-backend', 'because', 'chat'); | |
| expect(voice).toEqual(['homepilot-backend']); | |
| expect(chat).toEqual(['homepilot-backend']); | |
| }); | |
| it('keeps going when one subscriber throws', async () => { | |
| // A surface that fails to hand over must not strand the others on the old engine. | |
| await ensureSttRuntimeResolved(); | |
| const survived: string[] = []; | |
| subscribeEffectiveEngine(() => { throw new Error('broken surface'); }); | |
| subscribeEffectiveEngine((next) => survived.push(next)); | |
| applySttSessionOverride('homepilot-backend', 'because', 'chat'); | |
| expect(survived).toEqual(['homepilot-backend']); | |
| }); | |
| it('stops firing once unsubscribed', async () => { | |
| await ensureSttRuntimeResolved(); | |
| const seen: string[] = []; | |
| const off = subscribeEffectiveEngine((next) => seen.push(next)); | |
| off(); | |
| applySttSessionOverride('homepilot-backend', 'because', 'chat'); | |
| expect(seen).toEqual([]); | |
| }); | |
| }); | |
| describe('what the user is told', () => { | |
| it('names where the audio goes now, both ways', () => { | |
| // The engine decides which service sees the audio β the browser recognizer sends it to | |
| // Google, HomePilot's own keeps it on the machine β so a hand-off is never silent. | |
| expect(describeEngineHandoff('homepilot-backend', 'whisper-local')) | |
| .toContain('on this computer'); | |
| expect(describeEngineHandoff('homepilot-backend', 'whisper-local')) | |
| .toContain('whisper-local'); | |
| expect(describeEngineHandoff('web-speech', null)) | |
| .toContain('system default input'); | |
| }); | |
| it('reads as a confirmation rather than a fault report', () => { | |
| // The user asked for this in Settings. Repeating the paragraph that explains a deaf | |
| // recognizer would put a problem report in front of somebody who has just fixed one. | |
| const line = describeEngineHandoff('homepilot-backend', null); | |
| expect(line).toContain('Switched to'); | |
| expect(line).not.toContain('could not'); | |
| expect(line).not.toContain('found no speech'); | |
| }); | |
| }); | |
| describe('the chat composer hands the microphone over', () => { | |
| /* | |
| * Source-level, because the behaviour lives in the ordering of two effects rather than in a | |
| * value: the stop is asynchronous, so reopening the microphone from inside the subscription | |
| * would race the teardown for the same device. | |
| */ | |
| it('subscribes to the engine rather than resolving once at click time', () => { | |
| expect(app).toContain('subscribeEffectiveEngine'); | |
| expect(app).toContain('composer_mic_engine_handoff'); | |
| }); | |
| it('stops the running turn in a way that keeps what was said', () => { | |
| // Stopping the backend path transcribes the clip rather than discarding it; stopping the | |
| // browser path lets its final result land. Either way the words already spoken reach the | |
| // draft, and `micBaseTextRef` means the restart appends to it. | |
| const handoff = app.slice(app.indexOf('subscribeEffectiveEngine((next)')); | |
| expect(handoff).toContain('setMicTranscribing(true)'); | |
| expect(handoff).toContain('micStopRecordingRef.current()'); | |
| // Suppresses the auto-resume, which would reopen the *old* engine as this stop lands. | |
| expect(handoff).toContain('micStoppingRef.current = true'); | |
| expect(handoff).toContain("stopWebSpeech('chat', { reason: 'stt_engine_changed', force: true })"); | |
| }); | |
| it('restarts only after listening has actually stopped', () => { | |
| // The restart waits on `isListening` falling rather than running inside the subscription. | |
| expect(app).toContain('pendingEngineHandoffRef'); | |
| const restart = app.slice(app.indexOf('const next = pendingEngineHandoffRef.current')); | |
| expect(restart.slice(0, 260)).toContain('if (!next || isListening) return'); | |
| expect(restart.slice(0, 260)).toContain('startDictationOn(next)'); | |
| }); | |
| it('starts through the same path a click would', () => { | |
| // A second, subtly different start path is how the two diverge and only one gets fixed. | |
| expect(app).toContain('const startDictationOn = useCallback'); | |
| expect(app).toContain('await startDictationOn(effectiveEngine)'); | |
| }); | |
| it('does nothing to a composer that is not listening', () => { | |
| // Changing the engine with the microphone closed is a preference change, not a hand-off. | |
| const handoff = app.slice(app.indexOf('subscribeEffectiveEngine((next)')); | |
| expect(handoff).toContain('if (!isListeningRef.current) return'); | |
| }); | |
| }); | |