feat(voice): live dictation with browser pause restart and server audio chunking
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user