From 2dcd05227a09b3a3ef30cdc424ca50b5b7ace00c Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon Date: Tue, 3 Mar 2026 19:57:57 +0000 Subject: [PATCH] feat(speech): add frontend speech API client and useSpeech hook Adds transcribeAudio() and fetchSpeechHealth() to the API client, and the useSpeech React hook for managing mic recording lifecycle. Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/client.ts | 30 +++++++++++ frontend/src/hooks/useSpeech.ts | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 frontend/src/hooks/useSpeech.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index de9a0c6d..a880aaab 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -20,3 +20,33 @@ export async function fetchServerInfo(): Promise { if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`); return res.json(); } + +export interface TranscriptionResult { + text: string; + language: string | null; + confidence: number | null; + duration_seconds: number; +} + +export interface SpeechHealth { + available: boolean; + backend?: string; + reason?: string; +} + +export async function transcribeAudio(audioBlob: Blob, filename = 'recording.webm'): Promise { + const formData = new FormData(); + formData.append('file', audioBlob, filename); + const res = await fetch(`${BASE}/v1/speech/transcribe`, { + method: 'POST', + body: formData, + }); + if (!res.ok) throw new Error(`Transcription failed: ${res.status}`); + return res.json(); +} + +export async function fetchSpeechHealth(): Promise { + const res = await fetch(`${BASE}/v1/speech/health`); + if (!res.ok) return { available: false }; + return res.json(); +} diff --git a/frontend/src/hooks/useSpeech.ts b/frontend/src/hooks/useSpeech.ts new file mode 100644 index 00000000..3e2d70a9 --- /dev/null +++ b/frontend/src/hooks/useSpeech.ts @@ -0,0 +1,92 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { transcribeAudio, fetchSpeechHealth } from '../api/client'; + +export type SpeechState = 'idle' | 'recording' | 'transcribing'; + +export function useSpeech() { + const [state, setState] = useState('idle'); + const [error, setError] = useState(null); + const [available, setAvailable] = useState(false); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + + // Check if speech backend is available on mount + useEffect(() => { + fetchSpeechHealth() + .then((health) => setAvailable(health.available)) + .catch(() => setAvailable(false)); + }, []); + + const startRecording = useCallback(async (): Promise => { + setError(null); + + if (!navigator.mediaDevices?.getUserMedia) { + setError('Microphone not supported in this browser'); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + + recorder.start(); + mediaRecorderRef.current = recorder; + setState('recording'); + } catch (err) { + setError('Microphone access denied'); + setState('idle'); + } + }, []); + + const stopRecording = useCallback(async (): Promise => { + return new Promise((resolve, reject) => { + const recorder = mediaRecorderRef.current; + if (!recorder || recorder.state !== 'recording') { + reject(new Error('Not recording')); + return; + } + + recorder.onstop = async () => { + setState('transcribing'); + + // Stop all audio tracks + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' }); + chunksRef.current = []; + + try { + const result = await transcribeAudio(blob); + setState('idle'); + resolve(result.text); + } catch (err) { + setState('idle'); + const msg = err instanceof Error ? err.message : 'Transcription failed'; + setError(msg); + reject(err); + } + }; + + recorder.stop(); + }); + }, []); + + return { + state, + error, + available, + startRecording, + stopRecording, + isRecording: state === 'recording', + isTranscribing: state === 'transcribing', + }; +}