Commit
T8: push-to-talk recording and TTS
commit
bfdcfe0
7 changed files with +252 and −28
Jump to a changed file
- client/src/App.css +42 −0
- client/src/App.tsx +61 −22
- client/src/api.ts +16 −5
- client/src/audio.ts +40 −0
- client/src/components/PushToTalkButton.tsx +83 −0
- client/src/tts.ts +9 −0
- spec/TASKS.md +1 −1
modified client/src/App.css +42 −0
| @@ -105,6 +105,48 @@ | ||
| 105 | 105 | |
| 106 | 106 | /* Interview screen */ |
| 107 | 107 | |
| 108 | +.interview-header { | |
| 109 | + display: flex; | |
| 110 | + justify-content: space-between; | |
| 111 | + align-items: center; | |
| 112 | + gap: 16px; | |
| 113 | +} | |
| 114 | + | |
| 115 | +.tts-toggle { | |
| 116 | + display: flex; | |
| 117 | + align-items: center; | |
| 118 | + gap: 6px; | |
| 119 | + font-size: 14px; | |
| 120 | + color: var(--text); | |
| 121 | + white-space: nowrap; | |
| 122 | +} | |
| 123 | + | |
| 124 | +.push-to-talk { | |
| 125 | + margin-bottom: 12px; | |
| 126 | +} | |
| 127 | + | |
| 128 | +.record-button { | |
| 129 | + width: 100%; | |
| 130 | + padding: 14px; | |
| 131 | + border-radius: 8px; | |
| 132 | + border: 1px solid var(--border); | |
| 133 | + background: var(--bg-alt); | |
| 134 | + color: var(--text-h); | |
| 135 | + cursor: pointer; | |
| 136 | + font-size: 15px; | |
| 137 | +} | |
| 138 | + | |
| 139 | +.record-button.recording { | |
| 140 | + background: var(--danger); | |
| 141 | + color: white; | |
| 142 | + border-color: var(--danger); | |
| 143 | +} | |
| 144 | + | |
| 145 | +.record-button:disabled { | |
| 146 | + opacity: 0.6; | |
| 147 | + cursor: default; | |
| 148 | +} | |
| 149 | + | |
| 108 | 150 | .back-link { |
| 109 | 151 | background: none; |
| 110 | 152 | border: none; |
modified client/src/App.tsx +61 −22
| @@ -1,10 +1,12 @@ | ||
| 1 | -import { useEffect, useState } from 'react' | |
| 1 | +import { useEffect, useRef, useState } from 'react' | |
| 2 | 2 | import type { Session, SessionSummary } from 'shared/types' |
| 3 | 3 | import * as api from './api' |
| 4 | 4 | import './App.css' |
| 5 | 5 | import { CoveragePanel } from './components/CoveragePanel' |
| 6 | +import { PushToTalkButton } from './components/PushToTalkButton' | |
| 6 | 7 | import { QuestionCard } from './components/QuestionCard' |
| 7 | 8 | import { Transcript } from './components/Transcript' |
| 9 | +import { speak } from './tts' | |
| 8 | 10 | |
| 9 | 11 | function latestQuestion(session: Session): string | null { |
| 10 | 12 | for (let i = session.segments.length - 1; i >= 0; i--) { |
| @@ -89,6 +91,8 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 89 | 91 | const [answerText, setAnswerText] = useState('') |
| 90 | 92 | const [error, setError] = useState<string | null>(null) |
| 91 | 93 | const [submitting, setSubmitting] = useState(false) |
| 94 | + const [ttsEnabled, setTtsEnabled] = useState(false) | |
| 95 | + const lastSpokenQuestion = useRef<string | null>(null) | |
| 92 | 96 | |
| 93 | 97 | function refresh() { |
| 94 | 98 | return api.getSession(sessionId).then(setSession) |
| @@ -98,6 +102,15 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 98 | 102 | api.getSession(sessionId).then(setSession).catch((err: Error) => setError(err.message)) |
| 99 | 103 | }, [sessionId]) |
| 100 | 104 | |
| 105 | + useEffect(() => { | |
| 106 | + if (!ttsEnabled || !session) return | |
| 107 | + const question = latestQuestion(session) | |
| 108 | + if (question && question !== lastSpokenQuestion.current) { | |
| 109 | + lastSpokenQuestion.current = question | |
| 110 | + speak(question) | |
| 111 | + } | |
| 112 | + }, [ttsEnabled, session]) | |
| 113 | + | |
| 101 | 114 | async function submit(text: string) { |
| 102 | 115 | if (!text.trim()) return |
| 103 | 116 | setError(null) |
| @@ -113,6 +126,19 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 113 | 126 | } |
| 114 | 127 | } |
| 115 | 128 | |
| 129 | + async function submitAudio(blob: Blob) { | |
| 130 | + setError(null) | |
| 131 | + setSubmitting(true) | |
| 132 | + try { | |
| 133 | + await api.uploadAudio(sessionId, blob) | |
| 134 | + await refresh() | |
| 135 | + } catch (err) { | |
| 136 | + setError(err instanceof Error ? err.message : String(err)) | |
| 137 | + } finally { | |
| 138 | + setSubmitting(false) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 116 | 142 | async function handleSubmit(event: React.FormEvent) { |
| 117 | 143 | event.preventDefault() |
| 118 | 144 | await submit(answerText) |
| @@ -134,34 +160,47 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () | ||
| 134 | 160 | <button type="button" className="back-link" onClick={onBack}> |
| 135 | 161 | ← Back to sessions |
| 136 | 162 | </button> |
| 137 | - <h1>{session.name}</h1> | |
| 163 | + <div className="interview-header"> | |
| 164 | + <h1>{session.name}</h1> | |
| 165 | + <label className="tts-toggle"> | |
| 166 | + <input | |
| 167 | + type="checkbox" | |
| 168 | + checked={ttsEnabled} | |
| 169 | + onChange={(e) => setTtsEnabled(e.target.checked)} | |
| 170 | + /> | |
| 171 | + Read questions aloud | |
| 172 | + </label> | |
| 173 | + </div> | |
| 138 | 174 | |
| 139 | 175 | <div className="interview-layout"> |
| 140 | 176 | <div className="interview-main"> |
| 141 | 177 | <QuestionCard question={latestQuestion(session)} /> |
| 142 | 178 | <Transcript segments={session.segments} /> |
| 143 | 179 | |
| 144 | 180 | {session.status === 'interviewing' ? ( |
| 145 | - <form className="answer-form" onSubmit={handleSubmit}> | |
| 146 | - <input | |
| 147 | - value={answerText} | |
| 148 | - onChange={(e) => setAnswerText(e.target.value)} | |
| 149 | - placeholder="Type your answer…" | |
| 150 | - disabled={submitting} | |
| 151 | - autoFocus | |
| 152 | - /> | |
| 153 | - <button type="submit" disabled={submitting || !answerText.trim()}> | |
| 154 | - Send | |
| 155 | - </button> | |
| 156 | - <button | |
| 157 | - type="button" | |
| 158 | - className="done-button" | |
| 159 | - disabled={submitting} | |
| 160 | - onClick={() => submit('done')} | |
| 161 | - > | |
| 162 | - Done | |
| 163 | - </button> | |
| 164 | - </form> | |
| 181 | + <> | |
| 182 | + <PushToTalkButton disabled={submitting} onRecorded={(blob) => void submitAudio(blob)} /> | |
| 183 | + <form className="answer-form" onSubmit={handleSubmit}> | |
| 184 | + <input | |
| 185 | + value={answerText} | |
| 186 | + onChange={(e) => setAnswerText(e.target.value)} | |
| 187 | + placeholder="Type your answer…" | |
| 188 | + disabled={submitting} | |
| 189 | + autoFocus | |
| 190 | + /> | |
| 191 | + <button type="submit" disabled={submitting || !answerText.trim()}> | |
| 192 | + Send | |
| 193 | + </button> | |
| 194 | + <button | |
| 195 | + type="button" | |
| 196 | + className="done-button" | |
| 197 | + disabled={submitting} | |
| 198 | + onClick={() => submit('done')} | |
| 199 | + > | |
| 200 | + Done | |
| 201 | + </button> | |
| 202 | + </form> | |
| 203 | + </> | |
| 165 | 204 | ) : ( |
| 166 | 205 | <p className="interview-done">Interview complete.</p> |
| 167 | 206 | )} |
modified client/src/api.ts +16 −5
| @@ -8,18 +8,22 @@import type { | ||
| 8 | 8 | |
| 9 | 9 | const BASE = '/api' |
| 10 | 10 | |
| 11 | -async function requestJson<T>(url: string, init?: RequestInit): Promise<T> { | |
| 12 | - const res = await fetch(url, { | |
| 13 | - ...init, | |
| 14 | - headers: { 'Content-Type': 'application/json', ...init?.headers }, | |
| 15 | - }) | |
| 11 | +async function readJsonOrThrow<T>(res: Response): Promise<T> { | |
| 16 | 12 | if (!res.ok) { |
| 17 | 13 | const body = (await res.json().catch(() => null)) as { error?: string } | null |
| 18 | 14 | throw new Error(body?.error ?? `request failed with status ${res.status}`) |
| 19 | 15 | } |
| 20 | 16 | return res.json() as Promise<T> |
| 21 | 17 | } |
| 22 | 18 | |
| 19 | +async function requestJson<T>(url: string, init?: RequestInit): Promise<T> { | |
| 20 | + const res = await fetch(url, { | |
| 21 | + ...init, | |
| 22 | + headers: { 'Content-Type': 'application/json', ...init?.headers }, | |
| 23 | + }) | |
| 24 | + return readJsonOrThrow<T>(res) | |
| 25 | +} | |
| 26 | + | |
| 23 | 27 | export function createSession(req: CreateSessionRequest): Promise<Session> { |
| 24 | 28 | return requestJson<Session>(`${BASE}/sessions`, { method: 'POST', body: JSON.stringify(req) }) |
| 25 | 29 | } |
| @@ -38,3 +42,10 @@export function submitAnswer(id: string, req: AnswerRequest): Promise<AnswerResp | ||
| 38 | 42 | body: JSON.stringify(req), |
| 39 | 43 | }) |
| 40 | 44 | } |
| 45 | + | |
| 46 | +export async function uploadAudio(id: string, audio: Blob): Promise<AnswerResponse> { | |
| 47 | + const form = new FormData() | |
| 48 | + form.append('audio', audio, 'clip.webm') | |
| 49 | + const res = await fetch(`${BASE}/sessions/${id}/audio`, { method: 'POST', body: form }) | |
| 50 | + return readJsonOrThrow<AnswerResponse>(res) | |
| 51 | +} |
added client/src/audio.ts +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +export interface PushToTalkRecorder { | |
| 2 | + start(): Promise<void> | |
| 3 | + stop(): Promise<Blob> | |
| 4 | +} | |
| 5 | + | |
| 6 | +export function createPushToTalkRecorder(): PushToTalkRecorder { | |
| 7 | + let mediaRecorder: MediaRecorder | null = null | |
| 8 | + let stream: MediaStream | null = null | |
| 9 | + let chunks: BlobPart[] = [] | |
| 10 | + | |
| 11 | + return { | |
| 12 | + async start() { | |
| 13 | + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) | |
| 14 | + chunks = [] | |
| 15 | + mediaRecorder = new MediaRecorder(stream) | |
| 16 | + mediaRecorder.ondataavailable = (event) => { | |
| 17 | + if (event.data.size > 0) chunks.push(event.data) | |
| 18 | + } | |
| 19 | + mediaRecorder.start() | |
| 20 | + }, | |
| 21 | + | |
| 22 | + stop() { | |
| 23 | + return new Promise<Blob>((resolve, reject) => { | |
| 24 | + if (!mediaRecorder) { | |
| 25 | + reject(new Error('recording was not started')) | |
| 26 | + return | |
| 27 | + } | |
| 28 | + const recorder = mediaRecorder | |
| 29 | + const activeStream = stream | |
| 30 | + recorder.onstop = () => { | |
| 31 | + resolve(new Blob(chunks, { type: recorder.mimeType || 'audio/webm' })) | |
| 32 | + activeStream?.getTracks().forEach((track) => track.stop()) | |
| 33 | + } | |
| 34 | + recorder.stop() | |
| 35 | + mediaRecorder = null | |
| 36 | + stream = null | |
| 37 | + }) | |
| 38 | + }, | |
| 39 | + } | |
| 40 | +} |
added client/src/components/PushToTalkButton.tsx +83 −0
| @@ -0,0 +1,83 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from 'react' | |
| 2 | +import { createPushToTalkRecorder } from '../audio' | |
| 3 | + | |
| 4 | +interface PushToTalkButtonProps { | |
| 5 | + disabled?: boolean | |
| 6 | + onRecorded: (blob: Blob) => void | |
| 7 | +} | |
| 8 | + | |
| 9 | +function isTypingTarget(target: EventTarget | null): boolean { | |
| 10 | + if (!(target instanceof HTMLElement)) return false | |
| 11 | + return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps) { | |
| 15 | + const [recording, setRecording] = useState(false) | |
| 16 | + const [error, setError] = useState<string | null>(null) | |
| 17 | + const recorderRef = useRef(createPushToTalkRecorder()) | |
| 18 | + const activeRef = useRef(false) | |
| 19 | + | |
| 20 | + const startRecording = useCallback(async () => { | |
| 21 | + if (disabled || activeRef.current) return | |
| 22 | + activeRef.current = true | |
| 23 | + setError(null) | |
| 24 | + try { | |
| 25 | + await recorderRef.current.start() | |
| 26 | + setRecording(true) | |
| 27 | + } catch (err) { | |
| 28 | + activeRef.current = false | |
| 29 | + setError(err instanceof Error ? err.message : 'could not access the microphone') | |
| 30 | + } | |
| 31 | + }, [disabled]) | |
| 32 | + | |
| 33 | + const stopRecording = useCallback(async () => { | |
| 34 | + if (!activeRef.current) return | |
| 35 | + activeRef.current = false | |
| 36 | + setRecording(false) | |
| 37 | + try { | |
| 38 | + const blob = await recorderRef.current.stop() | |
| 39 | + onRecorded(blob) | |
| 40 | + } catch (err) { | |
| 41 | + setError(err instanceof Error ? err.message : 'recording failed') | |
| 42 | + } | |
| 43 | + }, [onRecorded]) | |
| 44 | + | |
| 45 | + useEffect(() => { | |
| 46 | + function handleKeyDown(event: KeyboardEvent) { | |
| 47 | + if (event.code !== 'Space' || event.repeat || isTypingTarget(event.target)) return | |
| 48 | + event.preventDefault() | |
| 49 | + void startRecording() | |
| 50 | + } | |
| 51 | + | |
| 52 | + function handleKeyUp(event: KeyboardEvent) { | |
| 53 | + if (event.code !== 'Space' || isTypingTarget(event.target)) return | |
| 54 | + event.preventDefault() | |
| 55 | + void stopRecording() | |
| 56 | + } | |
| 57 | + | |
| 58 | + window.addEventListener('keydown', handleKeyDown) | |
| 59 | + window.addEventListener('keyup', handleKeyUp) | |
| 60 | + return () => { | |
| 61 | + window.removeEventListener('keydown', handleKeyDown) | |
| 62 | + window.removeEventListener('keyup', handleKeyUp) | |
| 63 | + } | |
| 64 | + }, [startRecording, stopRecording]) | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div className="push-to-talk"> | |
| 68 | + <button | |
| 69 | + type="button" | |
| 70 | + className={`record-button ${recording ? 'recording' : ''}`} | |
| 71 | + disabled={disabled} | |
| 72 | + onMouseDown={() => void startRecording()} | |
| 73 | + onMouseUp={() => void stopRecording()} | |
| 74 | + onMouseLeave={() => { | |
| 75 | + if (recording) void stopRecording() | |
| 76 | + }} | |
| 77 | + > | |
| 78 | + {recording ? 'Recording… release to send' : 'Hold to talk (or press space)'} | |
| 79 | + </button> | |
| 80 | + {error && <p className="error">{error}</p>} | |
| 81 | + </div> | |
| 82 | + ) | |
| 83 | +} |
added client/src/tts.ts +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +export function speak(text: string): void { | |
| 2 | + if (!('speechSynthesis' in window)) return | |
| 3 | + window.speechSynthesis.cancel() | |
| 4 | + window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)) | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function stopSpeaking(): void { | |
| 8 | + if ('speechSynthesis' in window) window.speechSynthesis.cancel() | |
| 9 | +} |
modified spec/TASKS.md +1 −1
| @@ -37,7 +37,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 37 | 37 | - Depends: T4 (T5 makes it meaningful, but the API contract is enough to build against) |
| 38 | 38 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: `npm run dev` with MOCK_PROVIDERS=1, typing answers advances coverage in the panel. |
| 39 | 39 | |
| 40 | -- [ ] T8 Client: push-to-talk and TTS | |
| 40 | +- [x] T8 Client: push-to-talk and TTS | |
| 41 | 41 | - MediaRecorder hold-to-record button (spacebar and mouse), upload to the audio route, recording state indicator. TTS toggle speaking each new interviewer question via speechSynthesis. |
| 42 | 42 | - Depends: T6, T7 |
| 43 | 43 | - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: recording in Chrome produces a segment (mock mode). |