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,