Compare commits
4 commits
f5d7d9a9ab
...
eb633ac9d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb633ac9d6 | ||
|
|
70b91cc7bb | ||
|
|
965f1876bf | ||
|
|
b254862ef1 |
3 changed files with 28 additions and 50 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -42,6 +42,9 @@ chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels,
|
||||||
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
db/ Room offline cache: KaizenDatabase, Entities, DAOs,
|
||||||
Repositories, Converters, Mappers
|
Repositories, Converters, Mappers
|
||||||
haptics/ Phase-aware haptics
|
haptics/ Phase-aware haptics
|
||||||
|
live/ LiveCallService (foreground service), LiveClient (WS),
|
||||||
|
AudioPipeline (capture+playback+AEC), LiveProtocol,
|
||||||
|
LiveCallOverlay (fullscreen call UI)
|
||||||
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
|
net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
|
||||||
SessionViewModel, ServerConfig, StreamConsumer
|
SessionViewModel, ServerConfig, StreamConsumer
|
||||||
settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection
|
settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection
|
||||||
|
|
@ -440,13 +443,25 @@ All done:
|
||||||
- [x] **TTS cache** — `ttsUrl` + `ttsVoice` fields on `MessageEntity` (Room v3). First play generates + caches; subsequent plays with same voice skip the API call. Voice change auto-invalidates
|
- [x] **TTS cache** — `ttsUrl` + `ttsVoice` fields on `MessageEntity` (Room v3). First play generates + caches; subsequent plays with same voice skip the API call. Voice change auto-invalidates
|
||||||
- [x] **Server-synced voice** — `tts_voice` column on `users` table (migration 0030). Read from `GET /api/v1/me`, written via `PATCH /api/v1/me`. Web's `setCallVoice()` syncs to server. App reads on login/resume. Default "Kore"
|
- [x] **Server-synced voice** — `tts_voice` column on `users` table (migration 0030). Read from `GET /api/v1/me`, written via `PATCH /api/v1/me`. Web's `setCallVoice()` syncs to server. App reads on login/resume. Default "Kore"
|
||||||
- [x] **Backend: Bearer-auth audio-gen** — `/api/audio-gen` switched to `requireActiveUserOrToken()`, v1 re-export at `/api/v1/audio-gen`
|
- [x] **Backend: Bearer-auth audio-gen** — `/api/audio-gen` switched to `requireActiveUserOrToken()`, v1 re-export at `/api/v1/audio-gen`
|
||||||
- [ ] **Call UI** — fullscreen overlay, KaizenOrb with amplitude-reactive animation, push-to-talk mic button, status machine (idle → recording → uploading → thinking → speaking), voice picker (30 Gemini-TTS voices)
|
- [x] **Call UI** — fullscreen `LiveCallOverlay` (`live/LiveCallOverlay.kt`): opaque `MeshBackground` (frozen blobs), `KaizenOrb` with amplitude-reactive animation, status text, subtitle (transcript), EndCall button. Status machine: `IDLE → CONNECTING → LISTENING → SPEAKING`
|
||||||
- [ ] **In-app voice picker** — settings UI to choose from 30 Gemini-TTS voices (synced to server via `PATCH /api/v1/me`)
|
- [ ] **In-app voice picker** — settings UI to choose from 30 Gemini-TTS voices (synced to server via `PATCH /api/v1/me`)
|
||||||
|
|
||||||
The web's call mode (`use-call-mode.ts`, `call-view.tsx`) is the reference implementation. Same pipeline: Record → Upload → Chat (with audio attachment) → Text stream → TTS → Playback. No WebSocket needed.
|
The web's call mode (`use-call-mode.ts`, `call-view.tsx`) is the reference implementation for PTT. Live Call uses a completely different architecture (WebSocket relay).
|
||||||
|
|
||||||
**v2 — Gemini Live API (Weg B, target architecture):**
|
**v2 — Gemini Live API (Weg B, DONE):**
|
||||||
- [ ] **Bidirectional WebSocket audio streaming** via Vertex AI Gemini Live API (`BidiGenerateContent`). Available on Vertex AI / enterprise Gemini (GCP), not just the consumer API. True real-time conversation: sub-second latency, server-side VAD, user can interrupt the model mid-response. Requires either proxying through the backend (WebSocket relay) or direct app→Vertex connection with credential delegation. History sync and cost tracking need a separate solution since the conversation bypasses the normal chat persistence pipeline.
|
- [x] **Bidirectional WebSocket audio streaming** via Vertex AI Gemini Live API (`BidiGenerateContent`). Proxied through the backend WS relay (`ws-server.ts`). App connects to `wss://{baseUrl}/ws/live`, server relays to Gemini.
|
||||||
|
|
||||||
|
**Live Call Architecture (App-side, `live/` package):**
|
||||||
|
|
||||||
|
- **`LiveClient`** (`live/LiveClient.kt`) — OkHttp WebSocket to `wss://{baseUrl}/ws/live` with Bearer auth. Reuses `KaizenApi.baseClient` connection pool (readTimeout=0, pingInterval=15s). Auto-reconnect with exponential backoff (500ms–8s, max 5 attempts). Speaks the provider-agnostic JSON protocol (`LiveProtocol.kt`). **Critical:** `dispose()` must NOT call `client.dispatcher.executorService.shutdown()` — the dispatcher is shared with `KaizenApi.baseClient`, shutting it down kills all HTTP.
|
||||||
|
- **`LiveProtocol`** (`live/LiveProtocol.kt`) — JSON serialization via `kotlinx.serialization`. **Critical:** `encodeDefaults = true` required, otherwise the `type` field (which has a default value like `"setup"`) is omitted and the server rejects the message as malformed. `explicitNulls = false` to keep null optionals out of the wire format.
|
||||||
|
- **`AudioPipeline`** (`live/AudioPipeline.kt`) — Bidirectional audio: Capture (16kHz PCM16 mono, `VOICE_COMMUNICATION` source for built-in AEC/AGC/NS, 50ms chunks) + Playback (24kHz PCM16 mono, `USAGE_ASSISTANT` for full quality, `LOW_LATENCY` mode, `MODE_STREAM`). Echo prevention: `AcousticEchoCanceler` on the AudioRecord session (hardware AEC on Samsung) + hard ducking (zero mic chunks while model speaks). **No `MODE_IN_COMMUNICATION`** — it activates telephony DSP that degrades audio quality; not needed since AEC + ducking handle echo. Buffer: `minBuf * 2` for playback (lower latency).
|
||||||
|
- **`LiveCallService`** (`live/LiveCallService.kt`) — Foreground Service (`FOREGROUND_SERVICE_TYPE_MICROPHONE`). Keeps mic + WS alive during screen rotation / backgrounding. 20s connect timeout (ends call if server doesn't respond). Error events shown in overlay subtitle + loadError banner. Terminal errors: `auth_failed`, `restricted_model`, `restricted_feature`, `budget_exceeded`, `concurrent_limit`, `provider_error`, `reconnect_failed`.
|
||||||
|
- **`LiveCallOverlay`** (`live/LiveCallOverlay.kt`) — Fullscreen Composable with opaque `MeshBackground` (not transparent scrim — chat content must NOT bleed through).
|
||||||
|
|
||||||
|
**Model selection:** App sends user's current model (e.g. `vertex:gemini-3.5-flash`) — the backend WS server automatically maps non-live models to the best available live model (`gemini-live-2.5-flash-native-audio`). No manual live model selection needed.
|
||||||
|
|
||||||
|
**Conversation persistence:** `ChatScreenViewModel.startLiveCallInner` creates a conversation if needed before starting the service. Turn text + audio persisted server-side by `ws-server.ts` on each `turn_end` event. Messages cached in Room for offline access.
|
||||||
|
|
||||||
### 6. Image & media generation
|
### 6. Image & media generation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
import android.media.AudioTrack
|
import android.media.AudioTrack
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
|
|
@ -21,15 +20,14 @@ import kotlin.math.sqrt
|
||||||
/**
|
/**
|
||||||
* Bidirectional audio pipeline for Live Call.
|
* Bidirectional audio pipeline for Live Call.
|
||||||
*
|
*
|
||||||
* Capture: AudioRecord (16kHz PCM16 mono, VOICE_COMMUNICATION for hardware AEC)
|
* Capture: AudioRecord (16kHz PCM16 mono, VOICE_COMMUNICATION + AEC)
|
||||||
* → 100ms chunks → Base64 → [onChunk] callback.
|
* → 50ms chunks → Base64 → [onChunk] callback.
|
||||||
*
|
*
|
||||||
* Playback: AudioTrack (24kHz PCM16 mono, LOW_LATENCY, MODE_STREAM)
|
* Playback: AudioTrack (24kHz PCM16 mono, LOW_LATENCY, MODE_STREAM)
|
||||||
* ← PCM16 chunks from server.
|
* ← PCM16 chunks from server.
|
||||||
*
|
*
|
||||||
* Mic-ducking: gain reduced to 10% while the KI speaks (prevents echo feedback
|
* Echo prevention: AcousticEchoCanceler (hardware) + hard ducking
|
||||||
* loop on devices with weak AEC). Not a digital gain — we skip sending chunks
|
* (zero mic chunks while model speaks).
|
||||||
* whose RMS is below the ducking threshold.
|
|
||||||
*/
|
*/
|
||||||
class AudioPipeline(private val context: Context) {
|
class AudioPipeline(private val context: Context) {
|
||||||
|
|
||||||
|
|
@ -42,18 +40,14 @@ class AudioPipeline(private val context: Context) {
|
||||||
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
|
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
|
||||||
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
|
||||||
private const val CHUNK_MS = 100
|
private const val CHUNK_MS = 50
|
||||||
private const val CAPTURE_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE * CHUNK_MS / 1000 // 1600
|
private const val CAPTURE_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE * CHUNK_MS / 1000 // 800
|
||||||
private const val CAPTURE_CHUNK_BYTES = CAPTURE_CHUNK_SAMPLES * 2 // 3200
|
private const val CAPTURE_CHUNK_BYTES = CAPTURE_CHUNK_SAMPLES * 2 // 1600
|
||||||
|
|
||||||
private const val DUCKING_GAIN = 0.10f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var onChunk: ((base64Pcm: String) -> Unit)? = null
|
var onChunk: ((base64Pcm: String) -> Unit)? = null
|
||||||
var onAmplitude: ((Float) -> Unit)? = null
|
var onAmplitude: ((Float) -> Unit)? = null
|
||||||
|
|
||||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
private var previousAudioMode = AudioManager.MODE_NORMAL
|
|
||||||
private var recorder: AudioRecord? = null
|
private var recorder: AudioRecord? = null
|
||||||
private var track: AudioTrack? = null
|
private var track: AudioTrack? = null
|
||||||
private var aec: AcousticEchoCanceler? = null
|
private var aec: AcousticEchoCanceler? = null
|
||||||
|
|
@ -62,35 +56,6 @@ class AudioPipeline(private val context: Context) {
|
||||||
@Volatile private var ducking = false
|
@Volatile private var ducking = false
|
||||||
@Volatile private var capturing = false
|
@Volatile private var capturing = false
|
||||||
|
|
||||||
// ─── Audio Mode ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun enterCallMode() {
|
|
||||||
previousAudioMode = audioManager.mode
|
|
||||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
||||||
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 31) {
|
|
||||||
val speaker = audioManager.availableCommunicationDevices
|
|
||||||
.firstOrNull { it.type == android.media.AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
|
|
||||||
if (speaker != null) {
|
|
||||||
audioManager.setCommunicationDevice(speaker)
|
|
||||||
Log.i(TAG, "Communication device → speaker")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
audioManager.isSpeakerphoneOn = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exitCallMode() {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 31) {
|
|
||||||
audioManager.clearCommunicationDevice()
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
audioManager.isSpeakerphoneOn = false
|
|
||||||
}
|
|
||||||
audioManager.mode = previousAudioMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Capture ──────────────────────────────────────────────────────────
|
// ─── Capture ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun startCapture(scope: CoroutineScope): Boolean {
|
fun startCapture(scope: CoroutineScope): Boolean {
|
||||||
|
|
@ -185,7 +150,7 @@ class AudioPipeline(private val context: Context) {
|
||||||
val t = AudioTrack.Builder()
|
val t = AudioTrack.Builder()
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
@ -196,7 +161,7 @@ class AudioPipeline(private val context: Context) {
|
||||||
.setChannelMask(CHANNEL_OUT)
|
.setChannelMask(CHANNEL_OUT)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setBufferSizeInBytes(maxOf(minBuf * 4, 24_000 * 2))
|
.setBufferSizeInBytes(maxOf(minBuf * 2, 24_000))
|
||||||
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -243,7 +208,6 @@ class AudioPipeline(private val context: Context) {
|
||||||
fun release() {
|
fun release() {
|
||||||
stopCapture()
|
stopCapture()
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
exitCallMode()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,6 @@ class LiveCallService : Service() {
|
||||||
connectTimeoutJob?.cancel()
|
connectTimeoutJob?.cancel()
|
||||||
_status.value = CallStatus.LISTENING
|
_status.value = CallStatus.LISTENING
|
||||||
|
|
||||||
pipeline.enterCallMode()
|
|
||||||
if (!pipeline.initPlayback()) {
|
if (!pipeline.initPlayback()) {
|
||||||
Log.e(TAG, "Playback init failed")
|
Log.e(TAG, "Playback init failed")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue