# Speech-to-Speech (S2S) Design Spec **Branch:** `feature/voice-s2s` **Date:** 2026-03-29 **Status:** Approved for implementation --- ## Decisions - **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var) - **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user) - **Input mode:** Push-to-talk (Phase 1); VAD deferred - **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server - **No Android app** — web only for this implementation --- ## New Env Vars (`config.py`) | Var | Default | Description | |---|---|---| | `VOICE_ENABLED` | `false` | Feature flag — opt-in | | `STT_BACKEND` | `faster-whisper` | Only supported value currently | | `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` | | `TTS_BACKEND` | `kokoro` | Only supported value currently | --- ## Per-User Settings (stored in `settings` table) | Key | Default | Description | |---|---|---| | `voice_tts_voice` | `af_heart` | Kokoro voice ID | | `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 | | `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` | --- ## New Backend Files ### `src/scribe/services/stt.py` Lazy singleton `WhisperModel` loader. Public API: - `load_stt_model()` — called at startup via `asyncio.create_task` - `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text - `stt_available() -> bool` ### `src/scribe/services/tts.py` Lazy singleton `KPipeline` loader. Public API: - `load_tts_model()` — called at startup - `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) - `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels - `tts_available() -> bool` ### `src/scribe/routes/voice.py` Blueprint at `/api/voice`, all routes `@login_required`. | Endpoint | Method | Description | |---|---|---| | `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` | | `/api/voice/voices` | GET | List available Kokoro voices | | `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` | | `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes | --- ## Modified Backend Files ### `src/scribe/app.py` - Register `voice_bp` blueprint - In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` ### `src/scribe/config.py` - Add 4 new env var attributes - Add validation in `validate()` ### `src/scribe/services/llm.py` - Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` - When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* - Append style modifier based on `voice_speech_style` ### `src/scribe/services/generation_task.py` - Add `voice_mode: bool = False` to `run_generation()` - Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` ### `src/scribe/routes/chat.py` - Allow `"voice"` in `conversation_type` whitelist ### `src/scribe/services/chat.py` - Exclude `conversation_type == "voice"` from auto-cleanup retention --- ## New Frontend Files ### `frontend/src/composables/useVoiceRecorder.ts` Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise`. ### `frontend/src/composables/useVoiceAudio.ts` Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`. ### `frontend/src/components/VoiceOverlay.vue` Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`. --- ## Modified Frontend Files ### `frontend/src/api/client.ts` Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()` ### `frontend/src/views/BriefingView.vue` - "Listen" button: reads latest assistant message aloud via TTS - Mic button in input bar: PTT → transcribe → auto-fill input → send - Auto-TTS on assistant response when in listen mode ### `frontend/src/views/SettingsView.vue` - New "Voice" tab: voice dropdown, speed slider, speech style radio - Loads from `/api/settings`, saves via `PUT /api/settings` ### `frontend/src/App.vue` - Mount `` - Space bar → `"voice:ptt-toggle"` custom event --- ## Audio Format | Direction | Format | Rationale | |---|---|---| | Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding | | Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible | --- ## Dependencies to Add (`pyproject.toml`) ```toml [project.optional-dependencies] voice = [ "faster-whisper>=1.0", "kokoro>=0.9", "soundfile>=0.12", ] ``` Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime): ```dockerfile RUN pip install faster-whisper kokoro soundfile ``` --- ## Database Migration No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail. --- ## Implementation Phases ### Phase 1 — Backend services + routes 1. Add env vars to `config.py` 2. Create `services/stt.py` (faster-whisper) 3. Create `services/tts.py` (Kokoro) 4. Create `routes/voice.py` (4 endpoints) 5. Wire model loading into `app.py` startup 6. Add `voice_mode` to `build_context()` + `run_generation()` 7. Allow `"voice"` conversation type in chat route + cleanup exclusion ### Phase 2 — BriefingView listen + voice follow-up 1. Create `useVoiceRecorder.ts` 2. Create `useVoiceAudio.ts` 3. Add voice API functions to `client.ts` 4. Add "Listen" button + mic button to `BriefingView.vue` ### Phase 3 — VoiceOverlay for general voice chat 1. Create `VoiceOverlay.vue` 2. Mount in `App.vue` + Space bar hotkey ### Phase 4 — Settings UI 1. Add "Voice" tab to `SettingsView.vue` --- ## Kokoro Voice Reference | ID | Character | |---|---| | `af_heart` | American female, warm (recommended default) | | `af_bella` | American female, expressive | | `af_nicole` | American female, breathy/intimate | | `af_sarah` | American female, clear | | `af_sky` | American female, bright | | `am_adam` | American male, neutral | | `am_michael` | American male, deeper | | `bf_emma` | British female | | `bf_isabella` | British female, formal | | `bm_george` | British male | | `bm_lewis` | British male, casual |