@@ -268,6 +293,10 @@ onUnmounted(() => {
Enter
New line
+
+ Space
+ Hold to speak (voice, when enabled)
+
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 8af972c..9e1b2e7 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -619,3 +619,51 @@ export function getNewsItems(params: GetNewsItemsParams = {}) {
`/api/briefing/news?${p}`
)
}
+
+// ─── Voice ────────────────────────────────────────────────────────────────────
+
+export interface VoiceStatusResult {
+ enabled: boolean
+ stt: boolean
+ tts: boolean
+ stt_model?: string
+ tts_backend?: string
+}
+
+export interface VoiceEntry {
+ id: string
+ label: string
+}
+
+export const getVoiceStatus = () => apiGet('/api/voice/status')
+
+export const getVoiceList = () =>
+ apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
+
+export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> {
+ const form = new FormData()
+ form.append('audio', blob, 'audio.webm')
+ const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
+ throw new ApiError(res.status, err)
+ }
+ return res.json()
+}
+
+export async function synthesiseSpeech(
+ text: string,
+ voice?: string,
+ speed?: number
+): Promise {
+ const res = await fetch('/api/voice/synthesise', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, voice: voice ?? 'af_heart', speed: speed ?? 1.0 }),
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
+ throw new ApiError(res.status, err)
+ }
+ return res.blob()
+}
diff --git a/frontend/src/components/VoiceOverlay.vue b/frontend/src/components/VoiceOverlay.vue
new file mode 100644
index 0000000..7445a30
--- /dev/null
+++ b/frontend/src/components/VoiceOverlay.vue
@@ -0,0 +1,536 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ msg.content }}
+
+
+ {{ streamContent }}▌
+
+
+
+
+
+
+
+
+
+ Recording…
+ Transcribing…
+ Thinking…
+ Speaking…
+ {{ errorMsg || 'Error' }}
+
+
+ Hold Space or tap
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useVoiceAudio.ts b/frontend/src/composables/useVoiceAudio.ts
new file mode 100644
index 0000000..6645f65
--- /dev/null
+++ b/frontend/src/composables/useVoiceAudio.ts
@@ -0,0 +1,60 @@
+import { ref, readonly } from 'vue'
+
+/**
+ * Audio playback composable wrapping the Web Audio API.
+ *
+ * Usage:
+ * const { playing, isSupported, play, stop } = useVoiceAudio()
+ * await play(wavBlob)
+ */
+export function useVoiceAudio() {
+ const playing = ref(false)
+ const isSupported = typeof AudioContext !== 'undefined' || typeof (window as unknown as Record).webkitAudioContext !== 'undefined'
+
+ let audioCtx: AudioContext | null = null
+ let currentSource: AudioBufferSourceNode | null = null
+
+ function _getCtx(): AudioContext {
+ if (!audioCtx || audioCtx.state === 'closed') {
+ const Ctx = window.AudioContext ?? (window as unknown as Record).webkitAudioContext
+ audioCtx = new Ctx()
+ }
+ return audioCtx
+ }
+
+ async function play(blob: Blob): Promise {
+ stop()
+ const ctx = _getCtx()
+ if (ctx.state === 'suspended') await ctx.resume()
+
+ const arrayBuffer = await blob.arrayBuffer()
+ const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
+
+ const source = ctx.createBufferSource()
+ source.buffer = audioBuffer
+ source.connect(ctx.destination)
+ currentSource = source
+ playing.value = true
+
+ source.onended = () => {
+ playing.value = false
+ currentSource = null
+ }
+ source.start(0)
+ }
+
+ function stop(): void {
+ if (currentSource) {
+ try { currentSource.stop() } catch { /* already stopped */ }
+ currentSource = null
+ }
+ playing.value = false
+ }
+
+ return {
+ playing: readonly(playing),
+ isSupported,
+ play,
+ stop,
+ }
+}
diff --git a/frontend/src/composables/useVoiceRecorder.ts b/frontend/src/composables/useVoiceRecorder.ts
new file mode 100644
index 0000000..2b55a57
--- /dev/null
+++ b/frontend/src/composables/useVoiceRecorder.ts
@@ -0,0 +1,92 @@
+import { ref, readonly } from 'vue'
+
+/**
+ * Push-to-talk recorder wrapping the browser MediaRecorder API.
+ *
+ * Usage:
+ * const { recording, error, isSupported, startRecording, stopRecording } = useVoiceRecorder()
+ * await startRecording()
+ * const blob = await stopRecording() // resolves with the recorded audio Blob
+ */
+export function useVoiceRecorder() {
+ const recording = ref(false)
+ const error = ref(null)
+ const isSupported = typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
+
+ let mediaRecorder: MediaRecorder | null = null
+ let chunks: Blob[] = []
+ let stream: MediaStream | null = null
+ let resolveStop: ((blob: Blob) => void) | null = null
+ let rejectStop: ((err: Error) => void) | null = null
+
+ async function startRecording(): Promise {
+ error.value = null
+ if (!isSupported) {
+ error.value = 'Audio recording is not supported in this browser'
+ return
+ }
+ if (recording.value) return
+
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ } catch (e) {
+ error.value = 'Microphone access denied'
+ return
+ }
+
+ chunks = []
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
+ ? 'audio/webm;codecs=opus'
+ : MediaRecorder.isTypeSupported('audio/webm')
+ ? 'audio/webm'
+ : ''
+
+ mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
+
+ mediaRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunks.push(e.data)
+ }
+
+ mediaRecorder.onstop = () => {
+ const blob = new Blob(chunks, { type: mediaRecorder?.mimeType ?? 'audio/webm' })
+ chunks = []
+ stream?.getTracks().forEach((t) => t.stop())
+ stream = null
+ recording.value = false
+ resolveStop?.(blob)
+ resolveStop = null
+ rejectStop = null
+ }
+
+ mediaRecorder.onerror = () => {
+ recording.value = false
+ error.value = 'Recording error'
+ rejectStop?.(new Error('MediaRecorder error'))
+ resolveStop = null
+ rejectStop = null
+ }
+
+ mediaRecorder.start(100) // collect in 100ms chunks
+ recording.value = true
+ }
+
+ function stopRecording(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!mediaRecorder || !recording.value) {
+ reject(new Error('Not recording'))
+ return
+ }
+ resolveStop = resolve
+ rejectStop = reject
+ mediaRecorder.stop()
+ })
+ }
+
+ return {
+ recording: readonly(recording),
+ error: readonly(error),
+ isSupported,
+ startRecording,
+ stopRecording,
+ }
+}
diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue
index ff05bc9..97f83cb 100644
--- a/frontend/src/views/BriefingView.vue
+++ b/frontend/src/views/BriefingView.vue
@@ -1,6 +1,8 @@
@@ -281,6 +368,29 @@ onMounted(async () => {
+
+
+