fix: resolve VoiceModeManager race conditions and add server heartbeat

Fix five issues in VoiceModeManager: replace CoroutineScope extension
with coroutineScope{} (#5), move initialIds snapshot into collectorJob
to eliminate race window (#3), add select+onTimeout(30s) to prevent
permanent blocking on server death (#1/#2), propagate null statusText
(#4), and fix break-in-lambda compile error.

Add 10s heartbeat goroutine in runAgentLoop to keep voice mode alive
during long tool executions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-19 20:50:16 +09:00
parent f6da055421
commit d89c153afa
30 changed files with 1494 additions and 48 deletions

View file

@ -70,6 +70,7 @@ dependencies {
implementation(libs.activity.compose)
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.navigation.compose)
implementation(libs.koin.android)
implementation(libs.koin.compose)

View file

@ -3,6 +3,13 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application
android:name=".PicoClawApp"

View file

@ -4,8 +4,13 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import io.picoclaw.android.core.ui.theme.PicoClawTheme
import io.picoclaw.android.feature.chat.screen.ChatScreen
import io.picoclaw.android.feature.chat.screen.SettingsScreen
import io.picoclaw.android.navigation.NavRoutes
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -13,7 +18,19 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
PicoClawTheme {
ChatScreen()
val navController = rememberNavController()
NavHost(navController = navController, startDestination = NavRoutes.CHAT) {
composable(NavRoutes.CHAT) {
ChatScreen(
onNavigateToSettings = { navController.navigate(NavRoutes.SETTINGS) }
)
}
composable(NavRoutes.SETTINGS) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}
}

View file

@ -8,7 +8,11 @@ import io.picoclaw.android.core.data.local.AppDatabase
import io.picoclaw.android.core.data.local.ImageFileStorage
import io.picoclaw.android.core.data.remote.WebSocketClient
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
import io.picoclaw.android.core.data.repository.TtsCatalogRepositoryImpl
import io.picoclaw.android.core.data.repository.TtsSettingsRepositoryImpl
import io.picoclaw.android.core.domain.repository.ChatRepository
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
@ -17,6 +21,10 @@ import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
import io.picoclaw.android.feature.chat.ChatViewModel
import io.picoclaw.android.feature.chat.SettingsViewModel
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -67,6 +75,10 @@ val appModule = module {
// Repository
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
single<TtsSettingsRepository> { TtsSettingsRepositoryImpl(androidContext()) }
single<TtsCatalogRepository> {
TtsCatalogRepositoryImpl(androidContext(), get<TtsSettingsRepository>().ttsConfig, get())
}
// UseCases
factory { SendMessageUseCase(get()) }
@ -77,6 +89,12 @@ val appModule = module {
factory { ConnectChatUseCase(get()) }
factory { DisconnectChatUseCase(get()) }
// Voice
factory { SpeechRecognizerWrapper(androidContext()) }
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }
single { VoiceModeManager(get(), get(), get(), get(), get()) }
// ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get()) }
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
}

View file

@ -0,0 +1,6 @@
package io.picoclaw.android.navigation
object NavRoutes {
const val CHAT = "chat"
const val SETTINGS = "settings"
}

View file

@ -32,4 +32,5 @@ dependencies {
ksp(libs.room.compiler)
implementation(libs.coroutines.android)
implementation(libs.datastore.preferences)
}

View file

@ -0,0 +1,96 @@
package io.picoclaw.android.core.data.repository
import android.content.Context
import android.speech.tts.TextToSpeech
import io.picoclaw.android.core.domain.model.TtsConfig
import io.picoclaw.android.core.domain.model.TtsEngineInfo
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class TtsCatalogRepositoryImpl(
private val context: Context,
private val ttsConfigFlow: Flow<TtsConfig>,
private val scope: CoroutineScope
) : TtsCatalogRepository {
private val mutex = Mutex()
private var tts: TextToSpeech? = null
private var initialized = false
private var currentEnginePackage: String? = null
private val _availableEngines = MutableStateFlow<List<TtsEngineInfo>>(emptyList())
override val availableEngines: StateFlow<List<TtsEngineInfo>> = _availableEngines.asStateFlow()
private val _availableVoices = MutableStateFlow<List<TtsVoiceInfo>>(emptyList())
override val availableVoices: StateFlow<List<TtsVoiceInfo>> = _availableVoices.asStateFlow()
init {
initTtsEngine(null)
scope.launch {
ttsConfigFlow
.distinctUntilChangedBy { it.enginePackageName }
.collect { config ->
mutex.withLock {
if (config.enginePackageName != currentEnginePackage) {
initTtsEngine(config.enginePackageName)
}
}
}
}
}
private fun initTtsEngine(enginePackageName: String?) {
tts?.shutdown()
initialized = false
currentEnginePackage = enginePackageName
val listener = TextToSpeech.OnInitListener { status ->
if (status == TextToSpeech.SUCCESS) {
initialized = true
loadAvailableEngines()
loadAvailableVoices()
}
}
tts = if (enginePackageName != null) {
TextToSpeech(context, listener, enginePackageName)
} else {
TextToSpeech(context, listener)
}
}
private fun loadAvailableEngines() {
val engine = tts ?: return
_availableEngines.value = engine.engines.map { info ->
TtsEngineInfo(
packageName = info.name,
label = info.label
)
}
}
private fun loadAvailableVoices() {
val engine = tts ?: return
val voices = engine.voices ?: return
_availableVoices.value = voices
.filter { !it.isNetworkConnectionRequired }
.sortedBy { it.locale.displayName }
.map { voice ->
TtsVoiceInfo(
name = voice.name,
displayLabel = "${voice.locale.displayName} - ${voice.name}",
locale = voice.locale.toString()
)
}
}
}

View file

@ -0,0 +1,60 @@
package io.picoclaw.android.core.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import io.picoclaw.android.core.domain.model.TtsConfig
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.ttsDataStore: DataStore<Preferences> by preferencesDataStore(name = "tts_settings")
class TtsSettingsRepositoryImpl(
private val context: Context
) : TtsSettingsRepository {
private object Keys {
val ENGINE = stringPreferencesKey("engine_package")
val VOICE_NAME = stringPreferencesKey("voice_name")
val SPEECH_RATE = floatPreferencesKey("speech_rate")
val PITCH = floatPreferencesKey("pitch")
}
override val ttsConfig: Flow<TtsConfig> = context.ttsDataStore.data.map { prefs ->
TtsConfig(
enginePackageName = prefs[Keys.ENGINE],
voiceName = prefs[Keys.VOICE_NAME],
speechRate = prefs[Keys.SPEECH_RATE] ?: 1.0f,
pitch = prefs[Keys.PITCH] ?: 1.0f
)
}
override suspend fun updateEngine(packageName: String?) {
context.ttsDataStore.edit { prefs ->
if (packageName != null) prefs[Keys.ENGINE] = packageName
else prefs.remove(Keys.ENGINE)
// エンジン変更時は音声選択をリセット
prefs.remove(Keys.VOICE_NAME)
}
}
override suspend fun updateVoiceName(voiceName: String?) {
context.ttsDataStore.edit { prefs ->
if (voiceName != null) prefs[Keys.VOICE_NAME] = voiceName
else prefs.remove(Keys.VOICE_NAME)
}
}
override suspend fun updateSpeechRate(rate: Float) {
context.ttsDataStore.edit { it[Keys.SPEECH_RATE] = rate.coerceIn(0.5f, 2.0f) }
}
override suspend fun updatePitch(pitch: Float) {
context.ttsDataStore.edit { it[Keys.PITCH] = pitch.coerceIn(0.5f, 2.0f) }
}
}

View file

@ -0,0 +1,8 @@
package io.picoclaw.android.core.domain.model
data class TtsConfig(
val enginePackageName: String? = null,
val voiceName: String? = null,
val speechRate: Float = 1.0f,
val pitch: Float = 1.0f
)

View file

@ -0,0 +1,6 @@
package io.picoclaw.android.core.domain.model
data class TtsEngineInfo(
val packageName: String,
val label: String
)

View file

@ -0,0 +1,7 @@
package io.picoclaw.android.core.domain.model
data class TtsVoiceInfo(
val name: String,
val displayLabel: String,
val locale: String
)

View file

@ -0,0 +1,5 @@
package io.picoclaw.android.core.domain.model
enum class VoicePhase {
IDLE, LISTENING, SENDING, THINKING, SPEAKING, ERROR
}

View file

@ -0,0 +1,10 @@
package io.picoclaw.android.core.domain.repository
import io.picoclaw.android.core.domain.model.TtsEngineInfo
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
import kotlinx.coroutines.flow.StateFlow
interface TtsCatalogRepository {
val availableEngines: StateFlow<List<TtsEngineInfo>>
val availableVoices: StateFlow<List<TtsVoiceInfo>>
}

View file

@ -0,0 +1,12 @@
package io.picoclaw.android.core.domain.repository
import io.picoclaw.android.core.domain.model.TtsConfig
import kotlinx.coroutines.flow.Flow
interface TtsSettingsRepository {
val ttsConfig: Flow<TtsConfig>
suspend fun updateEngine(packageName: String?)
suspend fun updateVoiceName(voiceName: String?)
suspend fun updateSpeechRate(rate: Float)
suspend fun updatePitch(pitch: Float)
}

View file

@ -10,4 +10,6 @@ sealed interface ChatEvent {
data object OnLoadMore : ChatEvent
data class OnError(val message: String) : ChatEvent
data object OnErrorDismissed : ChatEvent
data object OnVoiceModeStart : ChatEvent
data object OnVoiceModeStop : ChatEvent
}

View file

@ -3,6 +3,7 @@ package io.picoclaw.android.feature.chat
import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ConnectionState
import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.feature.chat.voice.VoiceModeState
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
@ -12,5 +13,6 @@ data class ChatUiState(
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true,
val error: String? = null,
val statusLabel: String? = null
val statusLabel: String? = null,
val voiceModeState: VoiceModeState = VoiceModeState()
)

View file

@ -9,6 +9,7 @@ import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -24,7 +25,8 @@ class ChatViewModel(
private val observeStatus: ObserveStatusUseCase,
private val loadMoreMessages: LoadMoreMessagesUseCase,
private val connectChat: ConnectChatUseCase,
private val disconnectChat: DisconnectChatUseCase
private val disconnectChat: DisconnectChatUseCase,
private val voiceModeManager: VoiceModeManager
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
@ -53,6 +55,12 @@ class ChatViewModel(
_uiState.update { it.copy(statusLabel = label) }
}
}
viewModelScope.launch {
voiceModeManager.state.collect { voiceState ->
_uiState.update { it.copy(voiceModeState = voiceState) }
}
}
}
fun onEvent(event: ChatEvent) {
@ -91,11 +99,18 @@ class ChatViewModel(
is ChatEvent.OnErrorDismissed -> {
_uiState.update { it.copy(error = null) }
}
is ChatEvent.OnVoiceModeStart -> {
voiceModeManager.start(viewModelScope)
}
is ChatEvent.OnVoiceModeStop -> {
voiceModeManager.stop()
}
}
}
override fun onCleared() {
super.onCleared()
voiceModeManager.destroy()
disconnectChat()
}
}

View file

@ -0,0 +1,12 @@
package io.picoclaw.android.feature.chat
import io.picoclaw.android.core.domain.model.TtsConfig
import io.picoclaw.android.core.domain.model.TtsEngineInfo
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
data class SettingsUiState(
val ttsConfig: TtsConfig = TtsConfig(),
val availableEngines: List<TtsEngineInfo> = emptyList(),
val availableVoices: List<TtsVoiceInfo> = emptyList(),
val isTesting: Boolean = false
)

View file

@ -0,0 +1,64 @@
package io.picoclaw.android.feature.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SettingsViewModel(
private val ttsSettingsRepository: TtsSettingsRepository,
private val ttsCatalogRepository: TtsCatalogRepository,
private val ttsWrapper: TextToSpeechWrapper
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
ttsSettingsRepository.ttsConfig.collect { config ->
_uiState.update { it.copy(ttsConfig = config) }
}
}
viewModelScope.launch {
ttsCatalogRepository.availableEngines.collect { engines ->
_uiState.update { it.copy(availableEngines = engines) }
}
}
viewModelScope.launch {
ttsCatalogRepository.availableVoices.collect { voices ->
_uiState.update { it.copy(availableVoices = voices) }
}
}
}
fun onEngineSelected(packageName: String?) {
viewModelScope.launch { ttsSettingsRepository.updateEngine(packageName) }
}
fun onVoiceSelected(voiceName: String?) {
viewModelScope.launch { ttsSettingsRepository.updateVoiceName(voiceName) }
}
fun onSpeechRateChanged(rate: Float) {
viewModelScope.launch { ttsSettingsRepository.updateSpeechRate(rate) }
}
fun onPitchChanged(pitch: Float) {
viewModelScope.launch { ttsSettingsRepository.updatePitch(pitch) }
}
fun onTestSpeak() {
viewModelScope.launch {
_uiState.update { it.copy(isTesting = true) }
ttsWrapper.speak("これはテスト音声です。This is a test.")
_uiState.update { it.copy(isTesting = false) }
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -28,6 +29,7 @@ fun MessageInput(
onSendClick: () -> Unit,
onCameraClick: () -> Unit,
onGalleryClick: () -> Unit,
onMicClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
@ -42,6 +44,9 @@ fun MessageInput(
IconButton(onClick = onGalleryClick) {
Icon(Icons.Default.Image, contentDescription = "Gallery")
}
IconButton(onClick = onMicClick) {
Icon(Icons.Default.Mic, contentDescription = "Voice")
}
OutlinedTextField(
value = text,
onValueChange = onTextChanged,

View file

@ -3,14 +3,20 @@ package io.picoclaw.android.feature.chat.screen
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -33,12 +39,14 @@ import io.picoclaw.android.feature.chat.component.ImagePreviewRow
import io.picoclaw.android.feature.chat.component.MessageInput
import io.picoclaw.android.feature.chat.component.MessageList
import io.picoclaw.android.feature.chat.component.StatusIndicator
import io.picoclaw.android.feature.chat.voice.VoiceModeOverlay
import org.koin.androidx.compose.koinViewModel
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onNavigateToSettings: () -> Unit = {},
viewModel: ChatViewModel = koinViewModel()
) {
val context = LocalContext.current
@ -88,6 +96,34 @@ fun ChatScreen(
}
}
// RECORD_AUDIO permission
var pendingVoiceStart by remember { mutableStateOf(false) }
val micPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
viewModel.onEvent(ChatEvent.OnVoiceModeStart)
}
pendingVoiceStart = false
}
val onMicClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
viewModel.onEvent(ChatEvent.OnVoiceModeStart)
} else {
pendingVoiceStart = true
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
// Back handler for voice mode
BackHandler(enabled = uiState.voiceModeState.isActive) {
viewModel.onEvent(ChatEvent.OnVoiceModeStop)
}
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
@ -116,9 +152,17 @@ fun ChatScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(title = { Text("PicoClaw") })
TopAppBar(
title = { Text("PicoClaw") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
}
) { padding ->
Column(
@ -157,8 +201,15 @@ fun ChatScreen(
},
onGalleryClick = {
galleryLauncher.launch("image/*")
}
},
onMicClick = onMicClick
)
}
}
VoiceModeOverlay(
state = uiState.voiceModeState,
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) }
)
}
}

View file

@ -0,0 +1,242 @@
// TTS settings are currently used only within the chat feature, so this screen
// is placed under feature/chat. If TTS settings become shared across multiple
// features in the future, consider extracting them into a dedicated feature/settings module.
package io.picoclaw.android.feature.chat.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.TtsEngineInfo
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
import io.picoclaw.android.feature.chat.SettingsViewModel
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = koinViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text("Text-to-Speech", style = MaterialTheme.typography.titleMedium)
EngineSelector(
selectedEngine = uiState.ttsConfig.enginePackageName,
engines = uiState.availableEngines,
onEngineSelected = viewModel::onEngineSelected
)
VoiceSelector(
selectedVoiceName = uiState.ttsConfig.voiceName,
voices = uiState.availableVoices,
onVoiceSelected = viewModel::onVoiceSelected
)
SliderSetting(
label = "Speed",
value = uiState.ttsConfig.speechRate,
valueRange = 0.5f..2.0f,
onValueChangeFinished = viewModel::onSpeechRateChanged
)
SliderSetting(
label = "Pitch",
value = uiState.ttsConfig.pitch,
valueRange = 0.5f..2.0f,
onValueChangeFinished = viewModel::onPitchChanged
)
Button(
onClick = viewModel::onTestSpeak,
enabled = !uiState.isTesting
) {
Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EngineSelector(
selectedEngine: String?,
engines: List<TtsEngineInfo>,
onEngineSelected: (String?) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val displayText = if (selectedEngine == null) {
"System Default"
} else {
engines.find { it.packageName == selectedEngine }?.label ?: selectedEngine
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = displayText,
onValueChange = {},
readOnly = true,
label = { Text("Engine") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("System Default") },
onClick = {
onEngineSelected(null)
expanded = false
}
)
engines.forEach { engine ->
DropdownMenuItem(
text = { Text(engine.label) },
onClick = {
onEngineSelected(engine.packageName)
expanded = false
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun VoiceSelector(
selectedVoiceName: String?,
voices: List<TtsVoiceInfo>,
onVoiceSelected: (String?) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val displayText = if (selectedVoiceName == null) {
"System Default"
} else {
voices.find { it.name == selectedVoiceName }?.displayLabel ?: selectedVoiceName
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = displayText,
onValueChange = {},
readOnly = true,
label = { Text("Voice") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("System Default") },
onClick = {
onVoiceSelected(null)
expanded = false
}
)
voices.forEach { voice ->
DropdownMenuItem(
text = { Text(voice.displayLabel) },
onClick = {
onVoiceSelected(voice.name)
expanded = false
}
)
}
}
}
}
@Composable
private fun SliderSetting(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
onValueChangeFinished: (Float) -> Unit
) {
var localValue by remember(value) { mutableFloatStateOf(value) }
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Text(
"%.1f".format(localValue),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
Slider(
value = localValue,
onValueChange = { localValue = it },
onValueChangeFinished = { onValueChangeFinished(localValue) },
valueRange = valueRange,
steps = 14
)
}
}

View file

@ -0,0 +1,76 @@
package io.picoclaw.android.feature.chat.voice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
sealed interface SttResult {
data class Partial(val text: String) : SttResult
data class Final(val text: String) : SttResult
data class Error(val code: Int) : SttResult
data class RmsChanged(val rms: Float) : SttResult
}
class SpeechRecognizerWrapper(private val context: Context) {
fun startListening(): Flow<SttResult> = callbackFlow {
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
val listener = object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {}
override fun onBeginningOfSpeech() {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onEvent(eventType: Int, params: Bundle?) {}
override fun onRmsChanged(rmsdB: Float) {
trySend(SttResult.RmsChanged(rmsdB))
}
override fun onPartialResults(partialResults: Bundle?) {
val texts = partialResults
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val text = texts?.firstOrNull() ?: return
trySend(SttResult.Partial(text))
}
override fun onResults(results: Bundle?) {
val texts = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
val text = texts?.firstOrNull().orEmpty()
trySend(SttResult.Final(text))
channel.close()
}
override fun onError(error: Int) {
trySend(SttResult.Error(error))
channel.close()
}
}
recognizer.setRecognitionListener(listener)
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
}
recognizer.startListening(intent)
awaitClose {
recognizer.cancel()
recognizer.destroy()
}
}.flowOn(Dispatchers.Main)
}

View file

@ -0,0 +1,137 @@
package io.picoclaw.android.feature.chat.voice
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import io.picoclaw.android.core.domain.model.TtsConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
class TextToSpeechWrapper(
private val context: Context,
ttsConfigFlow: Flow<TtsConfig>
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val mutex = Mutex()
private var tts: TextToSpeech? = null
private var initialized = false
private var currentConfig = TtsConfig()
private var currentEnginePackage: String? = null
init {
initTtsEngine(null)
scope.launch {
ttsConfigFlow.collect { config ->
mutex.withLock { currentConfig = config }
}
}
scope.launch {
ttsConfigFlow
.distinctUntilChangedBy { it.enginePackageName }
.collect { config ->
mutex.withLock {
if (config.enginePackageName != currentEnginePackage) {
switchEngine(config.enginePackageName)
}
}
}
}
}
private fun initTtsEngine(enginePackageName: String?) {
tts?.stop()
tts?.shutdown()
initialized = false
currentEnginePackage = enginePackageName
val listener = TextToSpeech.OnInitListener { status ->
if (status == TextToSpeech.SUCCESS) {
initialized = true
}
}
tts = if (enginePackageName != null) {
TextToSpeech(context, listener, enginePackageName)
} else {
TextToSpeech(context, listener)
}
}
private fun switchEngine(enginePackageName: String?) {
initTtsEngine(enginePackageName)
}
private fun applyConfig(engine: TextToSpeech) {
engine.setSpeechRate(currentConfig.speechRate)
engine.setPitch(currentConfig.pitch)
currentConfig.voiceName?.let { name ->
engine.voices?.firstOrNull { it.name == name }?.let { engine.voice = it }
}
}
suspend fun speak(text: String): Boolean = suspendCancellableCoroutine { cont ->
val engine = tts
if (engine == null || !initialized) {
cont.resume(false)
return@suspendCancellableCoroutine
}
applyConfig(engine)
val utteranceId = UUID.randomUUID().toString()
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) {
if (id == utteranceId && cont.isActive) {
cont.resume(true)
}
}
@Deprecated("Deprecated in Java")
override fun onError(id: String?) {
if (id == utteranceId && cont.isActive) {
cont.resume(false)
}
}
override fun onError(id: String?, errorCode: Int) {
if (id == utteranceId && cont.isActive) {
cont.resume(false)
}
}
})
cont.invokeOnCancellation {
engine.stop()
}
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
}
fun stop() {
tts?.stop()
}
fun destroy() {
scope.cancel()
tts?.stop()
tts?.shutdown()
tts = null
}
}

View file

@ -0,0 +1,228 @@
package io.picoclaw.android.feature.chat.voice
import android.speech.SpeechRecognizer
import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.domain.model.MessageStatus
import io.picoclaw.android.core.domain.model.VoicePhase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
class VoiceModeManager(
private val sttWrapper: SpeechRecognizerWrapper,
private val ttsWrapper: TextToSpeechWrapper,
private val sendMessage: SendMessageUseCase,
private val observeMessages: ObserveMessagesUseCase,
private val observeStatus: ObserveStatusUseCase
) {
private val _state = MutableStateFlow(VoiceModeState())
val state: StateFlow<VoiceModeState> = _state.asStateFlow()
private var loopJob: Job? = null
fun start(scope: CoroutineScope) {
if (loopJob?.isActive == true) return
_state.update {
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
}
loopJob = scope.launch {
voiceLoop()
}
}
fun stop() {
loopJob?.cancel()
loopJob = null
ttsWrapper.stop()
_state.value = VoiceModeState()
}
fun destroy() {
stop()
}
// Fix #5: coroutineScope {} で自身のスコープを生成CoroutineScope. 拡張関数を廃止)
private suspend fun voiceLoop() = coroutineScope {
val spokenIds = mutableSetOf<String>()
val speechQueue = Channel<String>(Channel.UNLIMITED)
// Fix #3: knownMessageIds のスナップショットを collectorJob 内に移動し、
// スナップショットと collect 開始の間のレースウィンドウを排除
val collectorJob = launch {
val initialIds = observeMessages().value.map { it.id }.toSet()
observeMessages().collect { messages ->
messages.forEach { msg ->
if (msg.sender == MessageSender.AGENT &&
msg.id !in initialIds &&
msg.id !in spokenIds &&
msg.status == MessageStatus.RECEIVED
) {
spokenIds.add(msg.id)
speechQueue.send(msg.content)
}
}
}
}
try {
while (isActive) {
_state.update {
it.copy(
phase = VoicePhase.LISTENING,
recognizedText = "", responseText = "",
statusText = null,
errorMessage = null, amplitudeNormalized = 0f
)
}
val userTextChannel = Channel<String?>(1)
val listenJob = launch {
val text = listen()
userTextChannel.send(text)
}
select<Unit> {
userTextChannel.onReceive { text ->
if (!text.isNullOrBlank()) {
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
try {
sendMessage(text)
} catch (e: Exception) {
_state.update {
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")
}
delay(2000)
return@onReceive
}
awaitAndSpeakResponse(speechQueue)
} else {
drainSpeechQueue(speechQueue)
}
}
speechQueue.onReceive { content ->
listenJob.cancel()
speakAndDrain(content, speechQueue)
}
}
}
} finally {
collectorJob.cancel()
speechQueue.close()
}
}
private sealed interface WaitResult {
data class Message(val content: String) : WaitResult
data object Heartbeat : WaitResult
data object Timeout : WaitResult
}
// Fix #1 + #2 + #4 + #5: select で speechQueue / heartbeat / onTimeout(30s) を待つ
private suspend fun awaitAndSpeakResponse(speechQueue: Channel<String>) = coroutineScope {
val heartbeat = Channel<Unit>(Channel.CONFLATED)
val statusJob = launch {
observeStatus().collect { label ->
_state.update { it.copy(statusText = label) } // Fix #4: null も伝播
if (label != null) heartbeat.trySend(Unit)
}
}
try {
_state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) }
while (true) {
val result = select<WaitResult> {
speechQueue.onReceive { WaitResult.Message(it) }
heartbeat.onReceive { WaitResult.Heartbeat }
onTimeout(30_000) { WaitResult.Timeout }
}
when (result) {
WaitResult.Timeout -> {
_state.update {
it.copy(phase = VoicePhase.ERROR, errorMessage = "応答がタイムアウトしました")
}
delay(2000)
return@coroutineScope
}
WaitResult.Heartbeat -> continue
is WaitResult.Message -> {
speakAndDrain(result.content, speechQueue)
val currentStatus = observeStatus().value
if (currentStatus == null) return@coroutineScope
_state.update {
it.copy(phase = VoicePhase.THINKING, statusText = currentStatus)
}
}
}
}
} finally {
statusJob.cancel()
}
}
private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) {
_state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) }
ttsWrapper.speak(firstContent)
while (true) {
val next = speechQueue.tryReceive().getOrNull() ?: break
_state.update { it.copy(responseText = next) }
ttsWrapper.speak(next)
}
}
private suspend fun drainSpeechQueue(speechQueue: Channel<String>) {
val first = speechQueue.tryReceive().getOrNull() ?: return
speakAndDrain(first, speechQueue)
}
private suspend fun listen(): String? {
var finalText: String? = null
sttWrapper.startListening().collect { result ->
when (result) {
is SttResult.Partial -> {
_state.update { it.copy(recognizedText = result.text) }
}
is SttResult.Final -> {
finalText = result.text
}
is SttResult.RmsChanged -> {
val normalized = ((result.rms + 2f) / 12f).coerceIn(0f, 1f)
_state.update { it.copy(amplitudeNormalized = normalized) }
}
is SttResult.Error -> {
if (result.code == SpeechRecognizer.ERROR_NO_MATCH ||
result.code == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
) {
finalText = ""
} else {
_state.update {
it.copy(
phase = VoicePhase.ERROR,
errorMessage = "音声認識エラー (code=${result.code})"
)
}
delay(2000)
finalText = null
}
}
}
}
return finalText
}
}

View file

@ -0,0 +1,128 @@
package io.picoclaw.android.feature.chat.voice
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.VoicePhase
@Composable
fun VoiceModeOverlay(
state: VoiceModeState,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = state.isActive,
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() + slideOutVertically { it / 2 }
) {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
) {
// Close button
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "閉じる",
modifier = Modifier.size(28.dp)
)
}
// Center content
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
VoiceOrb(
phase = state.phase,
amplitudeNormalized = state.amplitudeNormalized
)
Spacer(modifier = Modifier.height(32.dp))
// Phase label
Text(
text = state.statusText.takeIf { state.phase == VoicePhase.THINKING }
?: phaseLabel(state.phase),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(24.dp))
// Recognized text
if (state.recognizedText.isNotEmpty()) {
Text(
text = state.recognizedText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
// Response text
if (state.responseText.isNotEmpty() && state.phase == VoicePhase.SPEAKING) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.responseText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
textAlign = TextAlign.Center
)
}
// Error message
if (state.errorMessage != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
}
}
}
private fun phaseLabel(phase: VoicePhase): String = when (phase) {
VoicePhase.IDLE -> ""
VoicePhase.LISTENING -> "聞き取り中..."
VoicePhase.SENDING -> "送信中..."
VoicePhase.THINKING -> "考え中..."
VoicePhase.SPEAKING -> "話しています..."
VoicePhase.ERROR -> "エラー"
}

View file

@ -0,0 +1,13 @@
package io.picoclaw.android.feature.chat.voice
import io.picoclaw.android.core.domain.model.VoicePhase
data class VoiceModeState(
val isActive: Boolean = false,
val phase: VoicePhase = VoicePhase.IDLE,
val recognizedText: String = "",
val responseText: String = "",
val statusText: String? = null,
val errorMessage: String? = null,
val amplitudeNormalized: Float = 0f
)

View file

@ -0,0 +1,180 @@
package io.picoclaw.android.feature.chat.voice
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import io.picoclaw.android.core.domain.model.VoicePhase
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private val ListeningColor = Color(0xFF4A9EFF)
private val SendingColor = Color(0xFFFF8C42)
private val ThinkingColor = Color(0xFFA855F7)
private val SpeakingColor = Color(0xFF22C55E)
private val ErrorColor = Color(0xFFEF4444)
private val IdleColor = Color(0xFF6B7280)
@Composable
fun VoiceOrb(
phase: VoicePhase,
amplitudeNormalized: Float,
modifier: Modifier = Modifier
) {
val transition = rememberInfiniteTransition(label = "orb")
val pulse by transition.animateFloat(
initialValue = 0.95f,
targetValue = 1.05f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = when (phase) {
VoicePhase.THINKING -> 800
VoicePhase.SPEAKING -> 600
else -> 1200
},
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
),
label = "pulse"
)
val rotation by transition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
val glowAlpha by transition.animateFloat(
initialValue = 0.3f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1500, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "glow"
)
val primaryColor = when (phase) {
VoicePhase.LISTENING -> ListeningColor
VoicePhase.SENDING -> SendingColor
VoicePhase.THINKING -> ThinkingColor
VoicePhase.SPEAKING -> SpeakingColor
VoicePhase.ERROR -> ErrorColor
VoicePhase.IDLE -> IdleColor
}
Canvas(modifier = modifier.size(200.dp)) {
val center = Offset(size.width / 2f, size.height / 2f)
val baseRadius = size.minDimension / 4f
val scaleMultiplier = if (phase == VoicePhase.LISTENING) {
1f + amplitudeNormalized * 0.3f
} else {
pulse
}
val orbRadius = baseRadius * scaleMultiplier
// Glow
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
primaryColor.copy(alpha = glowAlpha),
primaryColor.copy(alpha = 0f)
),
center = center,
radius = orbRadius * 2f
),
radius = orbRadius * 2f,
center = center
)
// Main orb
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
primaryColor,
primaryColor.copy(alpha = 0.7f)
),
center = Offset(
center.x - orbRadius * 0.2f,
center.y - orbRadius * 0.2f
),
radius = orbRadius * 1.5f
),
radius = orbRadius,
center = center
)
// Wave rings
drawWaveRings(center, orbRadius, primaryColor, rotation, phase, amplitudeNormalized)
}
}
private fun DrawScope.drawWaveRings(
center: Offset,
orbRadius: Float,
color: Color,
rotation: Float,
phase: VoicePhase,
amplitude: Float
) {
val ringCount = 3
for (i in 1..ringCount) {
val ringRadius = orbRadius * (1.2f + i * 0.25f)
val alpha = (0.4f - i * 0.1f).coerceAtLeast(0.05f)
val waveAmplitude = when (phase) {
VoicePhase.LISTENING -> amplitude * 8f
VoicePhase.SPEAKING -> 4f
VoicePhase.THINKING -> 2f
else -> 0f
}
if (waveAmplitude > 0f) {
val path = androidx.compose.ui.graphics.Path()
val steps = 72
for (step in 0..steps) {
val angle = (step.toFloat() / steps) * 2f * PI.toFloat()
val wave = sin(angle * 6f + Math.toRadians(rotation.toDouble()).toFloat() * (i + 1)) * waveAmplitude
val r = ringRadius + wave
val x = center.x + cos(angle) * r
val y = center.y + sin(angle) * r
if (step == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
path.close()
drawPath(
path = path,
color = color.copy(alpha = alpha),
style = Stroke(width = 2f)
)
} else {
drawCircle(
color = color.copy(alpha = alpha),
radius = ringRadius,
center = center,
style = Stroke(width = 1.5f)
)
}
}
}

View file

@ -13,6 +13,8 @@ core-ktx = "1.17.0"
coroutines = "1.10.2"
coil = "3.1.0"
markdown-renderer = "0.39.0"
navigation = "2.9.0"
datastore = "1.1.7"
[libraries]
# Compose
@ -53,6 +55,12 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
# Coil
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Markdown
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }

View file

@ -439,8 +439,36 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
})
}
// Start heartbeat goroutine - resend current status every 10s
var currentStatus atomic.Value
currentStatus.Store("思考中...")
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
defer heartbeatCancel()
if !constants.IsInternalChannel(opts.Channel) {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-heartbeatCtx.Done():
return
case <-ticker.C:
if status, ok := currentStatus.Load().(string); ok && status != "" {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: status,
Type: "status",
})
}
}
}
}()
}
// 5. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, &currentStatus)
if err != nil {
return "", err
}
@ -485,7 +513,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
// runLLMIteration executes the LLM call loop with tool handling.
// Returns the final content, iteration count, and any error.
func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions, currentStatus *atomic.Value) (string, int, error) {
iteration := 0
var finalContent string
@ -747,6 +775,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// Emit tool use status indicator
if !constants.IsInternalChannel(opts.Channel) {
if label := statusLabel(tc.Name, tc.Arguments); label != "" {
currentStatus.Store(label)
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,