Compare commits

..

No commits in common. "eb633ac9d64ce2f173bf30a444659bb821ca8a9c" and "f5d7d9a9ab5e70ea85f25b5b3af33a9680ce4d22" have entirely different histories.

3 changed files with 50 additions and 28 deletions

View file

@ -42,9 +42,6 @@ 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
@ -443,25 +440,13 @@ 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`
- [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` - [ ] **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)
- [ ] **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 for PTT. Live Call uses a completely different architecture (WebSocket relay). 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.
**v2 — Gemini Live API (Weg B, DONE):** **v2 — Gemini Live API (Weg B, target architecture):**
- [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. - [ ] **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.
**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 (500ms8s, 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

View file

@ -5,6 +5,7 @@ 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
@ -20,14 +21,15 @@ import kotlin.math.sqrt
/** /**
* Bidirectional audio pipeline for Live Call. * Bidirectional audio pipeline for Live Call.
* *
* Capture: AudioRecord (16kHz PCM16 mono, VOICE_COMMUNICATION + AEC) * Capture: AudioRecord (16kHz PCM16 mono, VOICE_COMMUNICATION for hardware AEC)
* 50ms chunks Base64 [onChunk] callback. * 100ms 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.
* *
* Echo prevention: AcousticEchoCanceler (hardware) + hard ducking * Mic-ducking: gain reduced to 10% while the KI speaks (prevents echo feedback
* (zero mic chunks while model speaks). * loop on devices with weak AEC). Not a digital gain we skip sending chunks
* whose RMS is below the ducking threshold.
*/ */
class AudioPipeline(private val context: Context) { class AudioPipeline(private val context: Context) {
@ -40,14 +42,18 @@ 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 = 50 private const val CHUNK_MS = 100
private const val CAPTURE_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE * CHUNK_MS / 1000 // 800 private const val CAPTURE_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE * CHUNK_MS / 1000 // 1600
private const val CAPTURE_CHUNK_BYTES = CAPTURE_CHUNK_SAMPLES * 2 // 1600 private const val CAPTURE_CHUNK_BYTES = CAPTURE_CHUNK_SAMPLES * 2 // 3200
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
@ -56,6 +62,35 @@ 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 {
@ -150,7 +185,7 @@ class AudioPipeline(private val context: Context) {
val t = AudioTrack.Builder() val t = AudioTrack.Builder()
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build() .build()
) )
@ -161,7 +196,7 @@ class AudioPipeline(private val context: Context) {
.setChannelMask(CHANNEL_OUT) .setChannelMask(CHANNEL_OUT)
.build() .build()
) )
.setBufferSizeInBytes(maxOf(minBuf * 2, 24_000)) .setBufferSizeInBytes(maxOf(minBuf * 4, 24_000 * 2))
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.setTransferMode(AudioTrack.MODE_STREAM) .setTransferMode(AudioTrack.MODE_STREAM)
.build() .build()
@ -208,6 +243,7 @@ class AudioPipeline(private val context: Context) {
fun release() { fun release() {
stopCapture() stopCapture()
stopPlayback() stopPlayback()
exitCallMode()
} }
// ─── Helpers ────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────

View file

@ -168,6 +168,7 @@ 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")
} }