feat: voice S2S — faster-whisper STT, Kokoro TTS, PTT overlay

Implements full speech-to-speech pipeline (all 4 phases):

Backend (Phase 1):
- services/stt.py: lazy WhisperModel singleton, run_in_executor transcription
- services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit
- routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise
- config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars
- app.py: load STT/TTS models at startup when VOICE_ENABLED=true
- llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix
- generation_task.py: voice_mode passed through from chat route
- chat.py: "voice" conversation type allowed + excluded from retention cleanup
- pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies

Frontend (Phases 2–4):
- composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper
- composables/useVoiceAudio.ts: AudioContext WAV playback wrapper
- BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT
- VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv;
  full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue
- SettingsView.vue: Voice tab (status badge, speech style, voice/speed)
- App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle
- api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:03:38 -04:00
parent 3581cc1582
commit 6f84d90dff
18 changed files with 1524 additions and 6 deletions
+29
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import VoiceOverlay from "@/components/VoiceOverlay.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
@@ -47,9 +48,21 @@ function clearPrefix() {
prefixTimeout = null;
}
let spaceHeld = false;
function onGlobalKeydown(e: KeyboardEvent) {
if (!authStore.isAuthenticated) return;
// Space — PTT start (only when not typing, no modifiers, no repeat)
if (e.key === " " && !isInputActive() && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat) {
e.preventDefault();
if (!spaceHeld) {
spaceHeld = true;
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
}
return;
}
// ? — toggle shortcuts overlay (only when not typing)
if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) {
toggleShortcuts();
@@ -122,8 +135,16 @@ function onGlobalKeydown(e: KeyboardEvent) {
}
}
function onGlobalKeyup(e: KeyboardEvent) {
if (e.key === " " && spaceHeld) {
spaceHeld = false;
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
}
}
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("keyup", onGlobalKeyup);
await authStore.checkAuth();
if (authStore.isAuthenticated) {
startAppServices();
@@ -149,6 +170,7 @@ watch(
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("keyup", onGlobalKeyup);
stopAppServices();
});
</script>
@@ -164,6 +186,9 @@ onUnmounted(() => {
<footer class="app-footer">v{{ appVersion }}</footer>
</div>
<!-- Global voice PTT overlay -->
<VoiceOverlay />
<!-- Keyboard shortcuts overlay -->
<Transition name="shortcuts-fade">
<div v-if="showShortcuts" class="shortcuts-overlay" @click.self="closeShortcuts">
@@ -268,6 +293,10 @@ onUnmounted(() => {
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">New line</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Space</kbd>
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
</div>
</div>
</div>
</div>