feat(voice): live dictation with browser pause restart and server audio chunking

This commit is contained in:
sandrodach committed 2026-10-30 09:27:30 +01:00
1 parent 446f8e4649
commit 6795edbf4b
5 files changed
+415 -77

No files matched your search

@@ -130,4 +130,121 @@ describe('useVoiceInput', () => {
await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false);
});
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
const instances: any[] = [];
class FakeSpeechRecognition {
lang = '';
interimResults = false;
continuous = false;
onresult: ((e: any) => void) | null = null;
onerror: ((e: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
instances.push(this);
}
start() {}
stop() {
this.onend?.();
}
abort() {}
}
(window as any).SpeechRecognition = FakeSpeechRecognition;
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('browser'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
expect(instances).toHaveLength(1);
// First utterance before a pause
act(() => {
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
// Browser ends recognition on speech pause -> immediate restart (#2)
act(() => {
instances[0].onend?.();
});
expect(instances).toHaveLength(2);
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
// Another immediate onend within <300ms waits until 300ms have passed
act(() => {
instances[1].onend?.();
});
expect(instances).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(300);
});
expect(instances).toHaveLength(3);
// Second utterance appends to base text
act(() => {
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
// Explicit stop() finishes with final=true
await act(async () => {
await result.current.stop();
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
expect(result.current.state).toBe('idle');
});
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const resolvers: Array<(text: string) => void> = [];
transcribe.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolvers.push(resolve);
})
);
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect(stopTrack).not.toHaveBeenCalled();
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(2);
expect(stopTrack).toHaveBeenCalledTimes(1);
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
await act(async () => {
resolvers[1]('zweiter Teil');
});
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
await act(async () => {
resolvers[0]('Erster Teil');
});
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
expect(result.current.state).toBe('idle');
});
});