diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 0c5b53bef..64a171062 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 95a13792b..0399f91cc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,13 @@
+
+
+
+
+
+
+
{ ChatRepositoryImpl(get(), get(), get(), get()) }
+ single { TtsSettingsRepositoryImpl(androidContext()) }
+ single {
+ TtsCatalogRepositoryImpl(androidContext(), get().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().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()) }
}
diff --git a/android/app/src/main/java/io/picoclaw/android/navigation/NavRoutes.kt b/android/app/src/main/java/io/picoclaw/android/navigation/NavRoutes.kt
new file mode 100644
index 000000000..734ce76dc
--- /dev/null
+++ b/android/app/src/main/java/io/picoclaw/android/navigation/NavRoutes.kt
@@ -0,0 +1,6 @@
+package io.picoclaw.android.navigation
+
+object NavRoutes {
+ const val CHAT = "chat"
+ const val SETTINGS = "settings"
+}
diff --git a/android/core/data/build.gradle.kts b/android/core/data/build.gradle.kts
index 95c369752..06515b26b 100644
--- a/android/core/data/build.gradle.kts
+++ b/android/core/data/build.gradle.kts
@@ -32,4 +32,5 @@ dependencies {
ksp(libs.room.compiler)
implementation(libs.coroutines.android)
+ implementation(libs.datastore.preferences)
}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsCatalogRepositoryImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsCatalogRepositoryImpl.kt
new file mode 100644
index 000000000..7968d9460
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsCatalogRepositoryImpl.kt
@@ -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,
+ 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>(emptyList())
+ override val availableEngines: StateFlow> = _availableEngines.asStateFlow()
+
+ private val _availableVoices = MutableStateFlow>(emptyList())
+ override val availableVoices: StateFlow> = _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()
+ )
+ }
+ }
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsSettingsRepositoryImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsSettingsRepositoryImpl.kt
new file mode 100644
index 000000000..787643b67
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/TtsSettingsRepositoryImpl.kt
@@ -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 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 = 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) }
+ }
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsConfig.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsConfig.kt
new file mode 100644
index 000000000..498b11abd
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsConfig.kt
@@ -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
+)
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsEngineInfo.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsEngineInfo.kt
new file mode 100644
index 000000000..60d6ae66d
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsEngineInfo.kt
@@ -0,0 +1,6 @@
+package io.picoclaw.android.core.domain.model
+
+data class TtsEngineInfo(
+ val packageName: String,
+ val label: String
+)
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsVoiceInfo.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsVoiceInfo.kt
new file mode 100644
index 000000000..916ffd9a5
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/TtsVoiceInfo.kt
@@ -0,0 +1,7 @@
+package io.picoclaw.android.core.domain.model
+
+data class TtsVoiceInfo(
+ val name: String,
+ val displayLabel: String,
+ val locale: String
+)
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/VoicePhase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/VoicePhase.kt
new file mode 100644
index 000000000..787993396
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/VoicePhase.kt
@@ -0,0 +1,5 @@
+package io.picoclaw.android.core.domain.model
+
+enum class VoicePhase {
+ IDLE, LISTENING, SENDING, THINKING, SPEAKING, ERROR
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsCatalogRepository.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsCatalogRepository.kt
new file mode 100644
index 000000000..fe7c67f9d
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsCatalogRepository.kt
@@ -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>
+ val availableVoices: StateFlow>
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsSettingsRepository.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsSettingsRepository.kt
new file mode 100644
index 000000000..7f86b97eb
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/TtsSettingsRepository.kt
@@ -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
+ suspend fun updateEngine(packageName: String?)
+ suspend fun updateVoiceName(voiceName: String?)
+ suspend fun updateSpeechRate(rate: Float)
+ suspend fun updatePitch(pitch: Float)
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt
index 1909e39ab..de855cf1d 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt
@@ -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
}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt
index d0ac21a63..1ff4b957e 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt
@@ -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 = 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()
)
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt
index a682444ad..a310ea466 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt
@@ -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()
}
}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsUiState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsUiState.kt
new file mode 100644
index 000000000..a5f20700f
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsUiState.kt
@@ -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 = emptyList(),
+ val availableVoices: List = emptyList(),
+ val isTesting: Boolean = false
+)
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsViewModel.kt
new file mode 100644
index 000000000..02ade5cab
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/SettingsViewModel.kt
@@ -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 = _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) }
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt
index e522687b9..acb400588 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt
@@ -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,
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt
index 6de475b54..6999f513a 100644
--- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt
@@ -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,49 +152,64 @@ fun ChatScreen(
}
}
- Scaffold(
- topBar = {
- TopAppBar(title = { Text("PicoClaw") })
- }
- ) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- ) {
- ConnectionBanner(connectionState = uiState.connectionState)
-
- MessageList(
- messages = uiState.messages,
- listState = listState,
- isLoadingMore = uiState.isLoadingMore,
- modifier = Modifier.weight(1f)
- )
-
- StatusIndicator(label = uiState.statusLabel)
-
- ImagePreviewRow(
- images = uiState.pendingImages,
- onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
- )
-
- MessageInput(
- text = uiState.inputText,
- onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
- onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
- onCameraClick = {
- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
- == PackageManager.PERMISSION_GRANTED
- ) {
- launchCamera()
- } else {
- cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("PicoClaw") },
+ actions = {
+ IconButton(onClick = onNavigateToSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
}
- },
- onGalleryClick = {
- galleryLauncher.launch("image/*")
- }
- )
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ ConnectionBanner(connectionState = uiState.connectionState)
+
+ MessageList(
+ messages = uiState.messages,
+ listState = listState,
+ isLoadingMore = uiState.isLoadingMore,
+ modifier = Modifier.weight(1f)
+ )
+
+ StatusIndicator(label = uiState.statusLabel)
+
+ ImagePreviewRow(
+ images = uiState.pendingImages,
+ onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
+ )
+
+ MessageInput(
+ text = uiState.inputText,
+ onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
+ onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
+ onCameraClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
+ == PackageManager.PERMISSION_GRANTED
+ ) {
+ launchCamera()
+ } else {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ },
+ onGalleryClick = {
+ galleryLauncher.launch("image/*")
+ },
+ onMicClick = onMicClick
+ )
+ }
}
+
+ VoiceModeOverlay(
+ state = uiState.voiceModeState,
+ onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) }
+ )
}
}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt
new file mode 100644
index 000000000..c081a1b4b
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/SettingsScreen.kt
@@ -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,
+ 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,
+ 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,
+ 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
+ )
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/SpeechRecognizerWrapper.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/SpeechRecognizerWrapper.kt
new file mode 100644
index 000000000..8bf3e6d78
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/SpeechRecognizerWrapper.kt
@@ -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 = 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)
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/TextToSpeechWrapper.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/TextToSpeechWrapper.kt
new file mode 100644
index 000000000..cf22b5fa1
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/TextToSpeechWrapper.kt
@@ -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
+) {
+
+ 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
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt
new file mode 100644
index 000000000..9dbda160f
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt
@@ -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 = _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()
+ val speechQueue = Channel(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(1)
+ val listenJob = launch {
+ val text = listen()
+ userTextChannel.send(text)
+ }
+
+ select {
+ 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) = coroutineScope {
+ val heartbeat = Channel(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 {
+ 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) {
+ _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) {
+ 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
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt
new file mode 100644
index 000000000..773935878
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt
@@ -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 -> "エラー"
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt
new file mode 100644
index 000000000..20e93d6e2
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeState.kt
@@ -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
+)
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt
new file mode 100644
index 000000000..3ec25fd7c
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceOrb.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index b0d65aeff..e507eb332 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -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" }
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 93cd33c2c..e434d8dae 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -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, ¤tStatus)
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,