HomePilot / frontend /src /test /sttEngineHandoff.test.ts
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
e144167
Raw History Blame Contribute Delete
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');
});
});