profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T8: push-to-talk recording and TTS

audio.ts wraps MediaRecorder for push-to-talk; PushToTalkButton binds
it to mouse hold and spacebar hold (ignored while a text input has
focus) with a recording-state indicator, uploading the clip via the
new api.uploadAudio(). tts.ts wraps speechSynthesis; a toggle in the
interview header speaks each new interviewer question once enabled.

Manually verified in headless Chrome with a fake media device: holding
the record button produces a transcript segment and advances coverage
(mock mode), and toggling TTS raises no errors.
commit bfdcfe0

7 changed files with +252 and −28

Jump to a changed file
  1. client/src/App.css +42 −0
  2. client/src/App.tsx +61 −22
  3. client/src/api.ts +16 −5
  4. client/src/audio.ts +40 −0
  5. client/src/components/PushToTalkButton.tsx +83 −0
  6. client/src/tts.ts +9 −0
  7. 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).