feat(voice): all microphone buttons use the server's speech recognition
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- New useVoiceInput hook (lib/voice.ts): "server" records with MediaRecorder and sends to /voice/transcribe, "browser" keeps the live recognition, "off" or missing browser support hides the microphone. - Quick dump bar, mobile capture sheet and hold-to-record use it; live waves from the real input level (VoiceWave) and "Wird erkannt…". - Recordings stop after STT_MAX_SECONDS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7801cd13b9
commit
7fa21e485b
9 files changed
+753
-248
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const getAuthStatus = vi.fn();
|
||||
const transcribe = vi.fn();
|
||||
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a), transcribe: (...a: any[]) => transcribe(...a) } }));
|
||||
|
||||
import { useVoiceInput } from '../useVoiceInput';
|
||||
import { resetVoiceConfig } from '../voice';
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = (t: string) => t === 'audio/webm;codecs=opus';
|
||||
state: 'inactive' | 'recording' = 'inactive';
|
||||
mimeType = 'audio/webm;codecs=opus';
|
||||
ondataavailable: ((e: { data: Blob }) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
constructor(public stream: any) {}
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable?.({ data: new Blob(['audio'], { type: this.mimeType }) });
|
||||
this.onstop?.();
|
||||
}
|
||||
}
|
||||
|
||||
const stopTrack = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
resetVoiceConfig();
|
||||
getAuthStatus.mockReset();
|
||||
transcribe.mockReset();
|
||||
stopTrack.mockReset();
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).MediaRecorder;
|
||||
delete (window as any).SpeechRecognition;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useVoiceInput', () => {
|
||||
it('server mode: records, sends the audio to the own server and returns its text', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
transcribe.mockResolvedValue('Milch kaufen');
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
expect(result.current.available).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
expect(result.current.state).toBe('listening');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(transcribe).toHaveBeenCalledTimes(1);
|
||||
expect((transcribe.mock.calls[0][0] as Blob).type).toBe('audio/webm;codecs=opus');
|
||||
expect(onText).toHaveBeenCalledWith('Milch kaufen', true);
|
||||
expect(result.current.state).toBe('idle');
|
||||
expect(stopTrack).toHaveBeenCalled(); // microphone released
|
||||
});
|
||||
|
||||
it('server mode: cancel throws the recording away', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
act(() => result.current.cancel());
|
||||
expect(transcribe).not.toHaveBeenCalled();
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('server mode: a failing server reports an error instead of text', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
transcribe.mockRejectedValue(new Error('Spracherkennung fehlgeschlagen'));
|
||||
const onText = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText, onError }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(onError).toHaveBeenCalledWith('Spracherkennung fehlgeschlagen');
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('server mode: stops by itself after the maximum length', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 5 } });
|
||||
transcribe.mockResolvedValue('Zu lang');
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => expect(onText).toHaveBeenCalledWith('Zu lang', true));
|
||||
});
|
||||
|
||||
it('hides the microphone when switched off, and without browser recognition', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'off', maxSeconds: 600 } });
|
||||
const off = renderHook(() => useVoiceInput({ onText: vi.fn() }));
|
||||
await waitFor(() => expect(off.result.current.mode).toBe('off'));
|
||||
expect(off.result.current.available).toBe(false);
|
||||
|
||||
resetVoiceConfig();
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
|
||||
const none = renderHook(() => useVoiceInput({ onText: vi.fn() }));
|
||||
await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
|
||||
expect(none.result.current.available).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user