docs: add Android Voice I/O design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,3 +49,4 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```dart
|
||||||
|
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 mode
|
||||||
|
- `POST /api/voice/transcribe` — `multipart/form-data`, field `audio` (WebM/Opus bytes) → `{transcript, duration_ms}`
|
||||||
|
- `POST /api/voice/synthesise` — `{"text": "..."}` → `audio/wav` bytes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Chat / Briefing — continuous voice loop
|
||||||
|
|
||||||
|
1. User taps mic → `VoiceNotifier.enterVoiceMode()`
|
||||||
|
2. Call `GET /api/voice/status`; if unavailable → show snackbar, abort
|
||||||
|
3. Request `RECORD_AUDIO` permission (via `permission_handler`); if denied → snackbar, abort
|
||||||
|
4. Set `voiceModeActive = true`; input field disabled, send button dimmed, red banner shown
|
||||||
|
5. Start recording via `record` package; poll amplitude every 200ms
|
||||||
|
6. Silence detected (amplitude < −40 dB for 1500ms) → stop recording
|
||||||
|
7. `POST /api/voice/transcribe` with WebM bytes → transcript
|
||||||
|
8. If transcript is empty → restart listening from step 5 silently
|
||||||
|
9. Call `sendMessage(transcript)` on `messagesProvider` (identical path to typed text)
|
||||||
|
10. As the SSE response streams in, `VoiceNotifier` watches 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 in `just_audio` player
|
||||||
|
11. After all audio plays → restart listening from step 5
|
||||||
|
12. 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:
|
||||||
|
|
||||||
|
1. Watch `messagesProvider` for streaming assistant content
|
||||||
|
2. Accumulate new characters into a sentence buffer
|
||||||
|
3. On each sentence boundary → strip markdown → `POST /api/voice/synthesise` → enqueue WAV
|
||||||
|
4. `just_audio` plays queued WAVs sequentially
|
||||||
|
5. On new message start → cancel in-flight synthesis, clear queue, stop playback
|
||||||
|
6. 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`:
|
||||||
|
|
||||||
|
```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`:** Mock `VoiceRepository`; 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 each `VoiceMode` value
|
||||||
|
- **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)
|
||||||
Reference in New Issue
Block a user