Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.4 KiB
Android Voice I/O Design
Goal
Add voice input (STT) and output (TTS) to the Android app. All audio processing runs server-side via existing backend endpoints — no on-device STT or TTS APIs. Voice input is available in three locations: the Chat screen, the Quick Capture bar, and the Briefing follow-up bar. TTS auto-plays when voice mode is active.
Architecture
New files
| File | Responsibility |
|---|---|
lib/data/api/voice_api.dart |
Dio wrapper: checkStatus(), transcribe(Uint8List), synthesise(String) |
lib/data/repositories/voice_repository.dart |
Thin wrapper around VoiceApi |
lib/providers/voice_provider.dart |
VoiceState + VoiceNotifier extends Notifier<VoiceState> — owns full recording/TTS lifecycle |
lib/widgets/voice_mic_button.dart |
Shared mic button widget used by all three screens; animates across all states |
Modified files
| File | Change |
|---|---|
pubspec.yaml |
Add record: ^6.x, just_audio: ^0.9.x |
android/app/src/main/AndroidManifest.xml |
Add RECORD_AUDIO permission |
lib/providers/api_client_provider.dart |
Add voiceApiProvider, voiceRepositoryProvider |
lib/screens/chat/chat_screen.dart |
Add VoiceMicButton to input bar; wire streaming TTS watcher |
lib/app.dart (_QuickCaptureBar) |
Add VoiceMicButton next to capture send button |
lib/screens/briefing/briefing_screen.dart |
Add VoiceMicButton to follow-up input row; wire streaming TTS watcher |
Packages
record^6.x — records audio on Android, outputs WebM/Opus (matches what the backend Whisper STT expects)just_audio^0.9.x — queue-based audio player; plays WAV bytes returned by/api/voice/synthesise
State model
enum VoiceMode { idle, recording, transcribing, playing }
class VoiceState {
final VoiceMode mode;
final bool voiceModeActive; // whether the voice loop is running
final bool available; // from /api/voice/status check
}
VoiceNotifier is a Notifier<VoiceState> (Riverpod 3). It owns the AudioRecorder and AudioPlayer instances.
Backend endpoints (no changes needed)
All existing, no backend work required:
GET /api/voice/status→{enabled, stt, tts}— checked before entering voice modePOST /api/voice/transcribe—multipart/form-data, fieldaudio(WebM/Opus bytes) →{transcript, duration_ms}POST /api/voice/synthesise—{"text": "..."}→audio/wavbytes
Data flow
Chat / Briefing — continuous voice loop
- User taps mic →
VoiceNotifier.enterVoiceMode() - Call
GET /api/voice/status; if unavailable → show snackbar, abort - Request
RECORD_AUDIOpermission (viapermission_handler); if denied → snackbar, abort - Set
voiceModeActive = true; input field disabled, send button dimmed, red banner shown - Start recording via
recordpackage; poll amplitude every 200ms - Silence detected (amplitude < −40 dB for 1500ms) → stop recording
POST /api/voice/transcribewith WebM bytes → transcript- If transcript is empty → restart listening from step 5 silently
- Call
sendMessage(transcript)onmessagesProvider(identical path to typed text) - As the SSE response streams in,
VoiceNotifierwatches the streaming content:- Buffer incoming text; extract completed sentences at
.,!,?boundaries - Strip markdown (code fences, headers, bold/italic markers) before synthesising
- For each sentence:
POST /api/voice/synthesise→ enqueue WAV blob injust_audioplayer
- Buffer incoming text; extract completed sentences at
- After all audio plays → restart listening from step 5
- User taps mic again →
VoiceNotifier.exitVoiceMode()→ stop recording, cancel pending TTS, reset state
Quick Capture — one-shot, no TTS loop
Steps 1–8 identical. Then instead of sendMessage, the transcript is passed to captureWorkQueueProvider.enqueue(transcript) — identical to typing in the capture bar. Mic returns to idle. No TTS playback, no loop.
Silence detection
The record package emits onAmplitudeChanged events. VoiceNotifier tracks consecutive below-threshold samples:
- Threshold: −40 dB
- Required duration: 1500ms of continuous silence
- Minimum recording length: 300ms (ignore silence before user has spoken)
Streaming TTS (mirrors web app)
Matches the behaviour of useStreamingTts in the web frontend:
- Watch
messagesProviderfor streaming assistant content - Accumulate new characters into a sentence buffer
- On each sentence boundary → strip markdown →
POST /api/voice/synthesise→ enqueue WAV just_audioplays queued WAVs sequentially- On new message start → cancel in-flight synthesis, clear queue, stop playback
- On stream end → flush remaining buffer fragment if ≥ 3 characters
Markdown stripping removes: code blocks (```), inline code, # headers, **bold**, *italic*, [link](url) → link text only, leading list markers.
UX
VoiceMicButton states
| State | Visual |
|---|---|
| Idle (voice mode off) | Plain mic icon, muted background |
| Recording | Red filled circle, pulsing shadow ring |
| Transcribing | Indigo filled circle, spinner overlay |
| Playing TTS | Indigo filled circle, speaker wave icon |
Voice mode banner (Chat + Briefing only)
A thin red banner appears above the input row while voice mode is active:
"🎤 Listening… tap mic to exit voice mode"
Input field shows "Listening…" hint in italic. Send button dimmed but still tappable as an override.
Quick Capture bar
No banner. The capture bar's background shifts to a subtle red tint while recording. Returns to normal after capture.
Error handling
| Scenario | Behaviour |
|---|---|
| Voice unavailable on server | Snackbar "Voice not available on this server", mic stays idle |
| Mic permission denied | Snackbar "Microphone permission required", mic stays idle |
| Empty transcript | Stay in voice mode, restart listening silently |
| Transcription API error | Stay in voice mode, restart listening; log warning |
| TTS synthesis failure for a sentence | Skip that sentence, continue playback queue |
| Network error during voice loop | Exit voice mode, show snackbar "Voice error — check connection" |
| User navigates away while in voice mode | VoiceNotifier disposes recording + playback cleanly |
Permissions
Add to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
Runtime permission requested via permission_handler at first mic tap. If permanently denied, open app settings.
Testing
- Unit —
VoiceNotifier: MockVoiceRepository; verify state transitions: idle → recording → transcribing → idle (on empty transcript), idle → recording → transcribing → playing → recording (happy path) - Unit — silence detection: Feed synthetic amplitude stream; assert stop fires after 1500ms below threshold, not before
- Unit — streaming TTS sentence extraction: Feed streaming content strings; assert correct sentences extracted, markdown stripped
- Widget —
VoiceMicButton: Verify correct icon/colour/animation for eachVoiceModevalue - Integration — permission flow: Mock
permission_handler; assert denied path shows snackbar and stays idle
Out of scope
- Wake word / always-on listening
- On-device STT or TTS
- Voice settings UI in the Android app (voice and TTS settings managed via the web Settings page)
- iOS support (Android only per project constraints)