fix: resolve VoiceModeManager race conditions and add server heartbeat
Fix five issues in VoiceModeManager: replace CoroutineScope extension
with coroutineScope{} (#5), move initialIds snapshot into collectorJob
to eliminate race window (#3), add select+onTimeout(30s) to prevent
permanent blocking on server death (#1/#2), propagate null statusText
(#4), and fix break-in-lambda compile error.
Add 10s heartbeat goroutine in runAgentLoop to keep voice mode alive
during long tool executions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f6da055421
commit
d89c153afa
30 changed files with 1494 additions and 48 deletions
|
|
@ -70,6 +70,7 @@ dependencies {
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
implementation(libs.lifecycle.runtime.compose)
|
implementation(libs.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.navigation.compose)
|
||||||
|
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".PicoClawApp"
|
android:name=".PicoClawApp"
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,13 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import io.picoclaw.android.core.ui.theme.PicoClawTheme
|
import io.picoclaw.android.core.ui.theme.PicoClawTheme
|
||||||
import io.picoclaw.android.feature.chat.screen.ChatScreen
|
import io.picoclaw.android.feature.chat.screen.ChatScreen
|
||||||
|
import io.picoclaw.android.feature.chat.screen.SettingsScreen
|
||||||
|
import io.picoclaw.android.navigation.NavRoutes
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -13,7 +18,19 @@ class MainActivity : ComponentActivity() {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PicoClawTheme {
|
PicoClawTheme {
|
||||||
ChatScreen()
|
val navController = rememberNavController()
|
||||||
|
NavHost(navController = navController, startDestination = NavRoutes.CHAT) {
|
||||||
|
composable(NavRoutes.CHAT) {
|
||||||
|
ChatScreen(
|
||||||
|
onNavigateToSettings = { navController.navigate(NavRoutes.SETTINGS) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(NavRoutes.SETTINGS) {
|
||||||
|
SettingsScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import io.picoclaw.android.core.data.local.AppDatabase
|
||||||
import io.picoclaw.android.core.data.local.ImageFileStorage
|
import io.picoclaw.android.core.data.local.ImageFileStorage
|
||||||
import io.picoclaw.android.core.data.remote.WebSocketClient
|
import io.picoclaw.android.core.data.remote.WebSocketClient
|
||||||
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
||||||
|
import io.picoclaw.android.core.data.repository.TtsCatalogRepositoryImpl
|
||||||
|
import io.picoclaw.android.core.data.repository.TtsSettingsRepositoryImpl
|
||||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||||
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
|
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||||
|
|
@ -17,6 +21,10 @@ import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||||
import io.picoclaw.android.feature.chat.ChatViewModel
|
import io.picoclaw.android.feature.chat.ChatViewModel
|
||||||
|
import io.picoclaw.android.feature.chat.SettingsViewModel
|
||||||
|
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
|
||||||
|
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
||||||
|
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -67,6 +75,10 @@ val appModule = module {
|
||||||
|
|
||||||
// Repository
|
// Repository
|
||||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
||||||
|
single<TtsSettingsRepository> { TtsSettingsRepositoryImpl(androidContext()) }
|
||||||
|
single<TtsCatalogRepository> {
|
||||||
|
TtsCatalogRepositoryImpl(androidContext(), get<TtsSettingsRepository>().ttsConfig, get())
|
||||||
|
}
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
factory { SendMessageUseCase(get()) }
|
factory { SendMessageUseCase(get()) }
|
||||||
|
|
@ -77,6 +89,12 @@ val appModule = module {
|
||||||
factory { ConnectChatUseCase(get()) }
|
factory { ConnectChatUseCase(get()) }
|
||||||
factory { DisconnectChatUseCase(get()) }
|
factory { DisconnectChatUseCase(get()) }
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
factory { SpeechRecognizerWrapper(androidContext()) }
|
||||||
|
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }
|
||||||
|
single { VoiceModeManager(get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
// ViewModel
|
// 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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package io.picoclaw.android.navigation
|
||||||
|
|
||||||
|
object NavRoutes {
|
||||||
|
const val CHAT = "chat"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
}
|
||||||
|
|
@ -32,4 +32,5 @@ dependencies {
|
||||||
ksp(libs.room.compiler)
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
implementation(libs.coroutines.android)
|
implementation(libs.coroutines.android)
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package io.picoclaw.android.core.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class TtsCatalogRepositoryImpl(
|
||||||
|
private val context: Context,
|
||||||
|
private val ttsConfigFlow: Flow<TtsConfig>,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) : TtsCatalogRepository {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var initialized = false
|
||||||
|
private var currentEnginePackage: String? = null
|
||||||
|
|
||||||
|
private val _availableEngines = MutableStateFlow<List<TtsEngineInfo>>(emptyList())
|
||||||
|
override val availableEngines: StateFlow<List<TtsEngineInfo>> = _availableEngines.asStateFlow()
|
||||||
|
|
||||||
|
private val _availableVoices = MutableStateFlow<List<TtsVoiceInfo>>(emptyList())
|
||||||
|
override val availableVoices: StateFlow<List<TtsVoiceInfo>> = _availableVoices.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
initTtsEngine(null)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
ttsConfigFlow
|
||||||
|
.distinctUntilChangedBy { it.enginePackageName }
|
||||||
|
.collect { config ->
|
||||||
|
mutex.withLock {
|
||||||
|
if (config.enginePackageName != currentEnginePackage) {
|
||||||
|
initTtsEngine(config.enginePackageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initTtsEngine(enginePackageName: String?) {
|
||||||
|
tts?.shutdown()
|
||||||
|
initialized = false
|
||||||
|
currentEnginePackage = enginePackageName
|
||||||
|
|
||||||
|
val listener = TextToSpeech.OnInitListener { status ->
|
||||||
|
if (status == TextToSpeech.SUCCESS) {
|
||||||
|
initialized = true
|
||||||
|
loadAvailableEngines()
|
||||||
|
loadAvailableVoices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tts = if (enginePackageName != null) {
|
||||||
|
TextToSpeech(context, listener, enginePackageName)
|
||||||
|
} else {
|
||||||
|
TextToSpeech(context, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAvailableEngines() {
|
||||||
|
val engine = tts ?: return
|
||||||
|
_availableEngines.value = engine.engines.map { info ->
|
||||||
|
TtsEngineInfo(
|
||||||
|
packageName = info.name,
|
||||||
|
label = info.label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAvailableVoices() {
|
||||||
|
val engine = tts ?: return
|
||||||
|
val voices = engine.voices ?: return
|
||||||
|
_availableVoices.value = voices
|
||||||
|
.filter { !it.isNetworkConnectionRequired }
|
||||||
|
.sortedBy { it.locale.displayName }
|
||||||
|
.map { voice ->
|
||||||
|
TtsVoiceInfo(
|
||||||
|
name = voice.name,
|
||||||
|
displayLabel = "${voice.locale.displayName} - ${voice.name}",
|
||||||
|
locale = voice.locale.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package io.picoclaw.android.core.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.ttsDataStore: DataStore<Preferences> by preferencesDataStore(name = "tts_settings")
|
||||||
|
|
||||||
|
class TtsSettingsRepositoryImpl(
|
||||||
|
private val context: Context
|
||||||
|
) : TtsSettingsRepository {
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val ENGINE = stringPreferencesKey("engine_package")
|
||||||
|
val VOICE_NAME = stringPreferencesKey("voice_name")
|
||||||
|
val SPEECH_RATE = floatPreferencesKey("speech_rate")
|
||||||
|
val PITCH = floatPreferencesKey("pitch")
|
||||||
|
}
|
||||||
|
|
||||||
|
override val ttsConfig: Flow<TtsConfig> = context.ttsDataStore.data.map { prefs ->
|
||||||
|
TtsConfig(
|
||||||
|
enginePackageName = prefs[Keys.ENGINE],
|
||||||
|
voiceName = prefs[Keys.VOICE_NAME],
|
||||||
|
speechRate = prefs[Keys.SPEECH_RATE] ?: 1.0f,
|
||||||
|
pitch = prefs[Keys.PITCH] ?: 1.0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateEngine(packageName: String?) {
|
||||||
|
context.ttsDataStore.edit { prefs ->
|
||||||
|
if (packageName != null) prefs[Keys.ENGINE] = packageName
|
||||||
|
else prefs.remove(Keys.ENGINE)
|
||||||
|
// エンジン変更時は音声選択をリセット
|
||||||
|
prefs.remove(Keys.VOICE_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateVoiceName(voiceName: String?) {
|
||||||
|
context.ttsDataStore.edit { prefs ->
|
||||||
|
if (voiceName != null) prefs[Keys.VOICE_NAME] = voiceName
|
||||||
|
else prefs.remove(Keys.VOICE_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateSpeechRate(rate: Float) {
|
||||||
|
context.ttsDataStore.edit { it[Keys.SPEECH_RATE] = rate.coerceIn(0.5f, 2.0f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updatePitch(pitch: Float) {
|
||||||
|
context.ttsDataStore.edit { it[Keys.PITCH] = pitch.coerceIn(0.5f, 2.0f) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package io.picoclaw.android.core.domain.model
|
||||||
|
|
||||||
|
data class TtsEngineInfo(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package io.picoclaw.android.core.domain.model
|
||||||
|
|
||||||
|
data class TtsVoiceInfo(
|
||||||
|
val name: String,
|
||||||
|
val displayLabel: String,
|
||||||
|
val locale: String
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package io.picoclaw.android.core.domain.model
|
||||||
|
|
||||||
|
enum class VoicePhase {
|
||||||
|
IDLE, LISTENING, SENDING, THINKING, SPEAKING, ERROR
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package io.picoclaw.android.core.domain.repository
|
||||||
|
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface TtsCatalogRepository {
|
||||||
|
val availableEngines: StateFlow<List<TtsEngineInfo>>
|
||||||
|
val availableVoices: StateFlow<List<TtsVoiceInfo>>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package io.picoclaw.android.core.domain.repository
|
||||||
|
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface TtsSettingsRepository {
|
||||||
|
val ttsConfig: Flow<TtsConfig>
|
||||||
|
suspend fun updateEngine(packageName: String?)
|
||||||
|
suspend fun updateVoiceName(voiceName: String?)
|
||||||
|
suspend fun updateSpeechRate(rate: Float)
|
||||||
|
suspend fun updatePitch(pitch: Float)
|
||||||
|
}
|
||||||
|
|
@ -10,4 +10,6 @@ sealed interface ChatEvent {
|
||||||
data object OnLoadMore : ChatEvent
|
data object OnLoadMore : ChatEvent
|
||||||
data class OnError(val message: String) : ChatEvent
|
data class OnError(val message: String) : ChatEvent
|
||||||
data object OnErrorDismissed : ChatEvent
|
data object OnErrorDismissed : ChatEvent
|
||||||
|
data object OnVoiceModeStart : ChatEvent
|
||||||
|
data object OnVoiceModeStop : ChatEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.ChatMessage
|
||||||
import io.picoclaw.android.core.domain.model.ConnectionState
|
import io.picoclaw.android.core.domain.model.ConnectionState
|
||||||
import io.picoclaw.android.core.domain.model.ImageAttachment
|
import io.picoclaw.android.core.domain.model.ImageAttachment
|
||||||
|
import io.picoclaw.android.feature.chat.voice.VoiceModeState
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
val messages: List<ChatMessage> = emptyList(),
|
val messages: List<ChatMessage> = emptyList(),
|
||||||
|
|
@ -12,5 +13,6 @@ data class ChatUiState(
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val canLoadMore: Boolean = true,
|
val canLoadMore: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val statusLabel: String? = null
|
val statusLabel: String? = null,
|
||||||
|
val voiceModeState: VoiceModeState = VoiceModeState()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.ObserveMessagesUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
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.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -24,7 +25,8 @@ class ChatViewModel(
|
||||||
private val observeStatus: ObserveStatusUseCase,
|
private val observeStatus: ObserveStatusUseCase,
|
||||||
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
||||||
private val connectChat: ConnectChatUseCase,
|
private val connectChat: ConnectChatUseCase,
|
||||||
private val disconnectChat: DisconnectChatUseCase
|
private val disconnectChat: DisconnectChatUseCase,
|
||||||
|
private val voiceModeManager: VoiceModeManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
|
|
@ -53,6 +55,12 @@ class ChatViewModel(
|
||||||
_uiState.update { it.copy(statusLabel = label) }
|
_uiState.update { it.copy(statusLabel = label) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
voiceModeManager.state.collect { voiceState ->
|
||||||
|
_uiState.update { it.copy(voiceModeState = voiceState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onEvent(event: ChatEvent) {
|
fun onEvent(event: ChatEvent) {
|
||||||
|
|
@ -91,11 +99,18 @@ class ChatViewModel(
|
||||||
is ChatEvent.OnErrorDismissed -> {
|
is ChatEvent.OnErrorDismissed -> {
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
|
is ChatEvent.OnVoiceModeStart -> {
|
||||||
|
voiceModeManager.start(viewModelScope)
|
||||||
|
}
|
||||||
|
is ChatEvent.OnVoiceModeStop -> {
|
||||||
|
voiceModeManager.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
voiceModeManager.destroy()
|
||||||
disconnectChat()
|
disconnectChat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package io.picoclaw.android.feature.chat
|
||||||
|
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val ttsConfig: TtsConfig = TtsConfig(),
|
||||||
|
val availableEngines: List<TtsEngineInfo> = emptyList(),
|
||||||
|
val availableVoices: List<TtsVoiceInfo> = emptyList(),
|
||||||
|
val isTesting: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package io.picoclaw.android.feature.chat
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||||
|
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||||
|
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SettingsViewModel(
|
||||||
|
private val ttsSettingsRepository: TtsSettingsRepository,
|
||||||
|
private val ttsCatalogRepository: TtsCatalogRepository,
|
||||||
|
private val ttsWrapper: TextToSpeechWrapper
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
ttsSettingsRepository.ttsConfig.collect { config ->
|
||||||
|
_uiState.update { it.copy(ttsConfig = config) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
ttsCatalogRepository.availableEngines.collect { engines ->
|
||||||
|
_uiState.update { it.copy(availableEngines = engines) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
ttsCatalogRepository.availableVoices.collect { voices ->
|
||||||
|
_uiState.update { it.copy(availableVoices = voices) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEngineSelected(packageName: String?) {
|
||||||
|
viewModelScope.launch { ttsSettingsRepository.updateEngine(packageName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onVoiceSelected(voiceName: String?) {
|
||||||
|
viewModelScope.launch { ttsSettingsRepository.updateVoiceName(voiceName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSpeechRateChanged(rate: Float) {
|
||||||
|
viewModelScope.launch { ttsSettingsRepository.updateSpeechRate(rate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPitchChanged(pitch: Float) {
|
||||||
|
viewModelScope.launch { ttsSettingsRepository.updatePitch(pitch) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTestSpeak() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isTesting = true) }
|
||||||
|
ttsWrapper.speak("これはテスト音声です。This is a test.")
|
||||||
|
_uiState.update { it.copy(isTesting = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
import androidx.compose.material.icons.filled.Image
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -28,6 +29,7 @@ fun MessageInput(
|
||||||
onSendClick: () -> Unit,
|
onSendClick: () -> Unit,
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onGalleryClick: () -> Unit,
|
onGalleryClick: () -> Unit,
|
||||||
|
onMicClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -42,6 +44,9 @@ fun MessageInput(
|
||||||
IconButton(onClick = onGalleryClick) {
|
IconButton(onClick = onGalleryClick) {
|
||||||
Icon(Icons.Default.Image, contentDescription = "Gallery")
|
Icon(Icons.Default.Image, contentDescription = "Gallery")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onMicClick) {
|
||||||
|
Icon(Icons.Default.Mic, contentDescription = "Voice")
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = onTextChanged,
|
onValueChange = onTextChanged,
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@ package io.picoclaw.android.feature.chat.screen
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
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.MessageInput
|
||||||
import io.picoclaw.android.feature.chat.component.MessageList
|
import io.picoclaw.android.feature.chat.component.MessageList
|
||||||
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
||||||
|
import io.picoclaw.android.feature.chat.voice.VoiceModeOverlay
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
|
onNavigateToSettings: () -> Unit = {},
|
||||||
viewModel: ChatViewModel = koinViewModel()
|
viewModel: ChatViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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 {
|
val shouldLoadMore by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
|
|
@ -116,9 +152,17 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(title = { Text("PicoClaw") })
|
TopAppBar(
|
||||||
|
title = { Text("PicoClaw") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -157,8 +201,15 @@ fun ChatScreen(
|
||||||
},
|
},
|
||||||
onGalleryClick = {
|
onGalleryClick = {
|
||||||
galleryLauncher.launch("image/*")
|
galleryLauncher.launch("image/*")
|
||||||
}
|
},
|
||||||
|
onMicClick = onMicClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VoiceModeOverlay(
|
||||||
|
state = uiState.voiceModeState,
|
||||||
|
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
// TTS settings are currently used only within the chat feature, so this screen
|
||||||
|
// is placed under feature/chat. If TTS settings become shared across multiple
|
||||||
|
// features in the future, consider extracting them into a dedicated feature/settings module.
|
||||||
|
package io.picoclaw.android.feature.chat.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||||
|
import io.picoclaw.android.feature.chat.SettingsViewModel
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Settings") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Text("Text-to-Speech", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
EngineSelector(
|
||||||
|
selectedEngine = uiState.ttsConfig.enginePackageName,
|
||||||
|
engines = uiState.availableEngines,
|
||||||
|
onEngineSelected = viewModel::onEngineSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
VoiceSelector(
|
||||||
|
selectedVoiceName = uiState.ttsConfig.voiceName,
|
||||||
|
voices = uiState.availableVoices,
|
||||||
|
onVoiceSelected = viewModel::onVoiceSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
SliderSetting(
|
||||||
|
label = "Speed",
|
||||||
|
value = uiState.ttsConfig.speechRate,
|
||||||
|
valueRange = 0.5f..2.0f,
|
||||||
|
onValueChangeFinished = viewModel::onSpeechRateChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
SliderSetting(
|
||||||
|
label = "Pitch",
|
||||||
|
value = uiState.ttsConfig.pitch,
|
||||||
|
valueRange = 0.5f..2.0f,
|
||||||
|
onValueChangeFinished = viewModel::onPitchChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::onTestSpeak,
|
||||||
|
enabled = !uiState.isTesting
|
||||||
|
) {
|
||||||
|
Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EngineSelector(
|
||||||
|
selectedEngine: String?,
|
||||||
|
engines: List<TtsEngineInfo>,
|
||||||
|
onEngineSelected: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val displayText = if (selectedEngine == null) {
|
||||||
|
"System Default"
|
||||||
|
} else {
|
||||||
|
engines.find { it.packageName == selectedEngine }?.label ?: selectedEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayText,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Engine") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("System Default") },
|
||||||
|
onClick = {
|
||||||
|
onEngineSelected(null)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
engines.forEach { engine ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(engine.label) },
|
||||||
|
onClick = {
|
||||||
|
onEngineSelected(engine.packageName)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun VoiceSelector(
|
||||||
|
selectedVoiceName: String?,
|
||||||
|
voices: List<TtsVoiceInfo>,
|
||||||
|
onVoiceSelected: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val displayText = if (selectedVoiceName == null) {
|
||||||
|
"System Default"
|
||||||
|
} else {
|
||||||
|
voices.find { it.name == selectedVoiceName }?.displayLabel ?: selectedVoiceName
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayText,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Voice") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("System Default") },
|
||||||
|
onClick = {
|
||||||
|
onVoiceSelected(null)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
voices.forEach { voice ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(voice.displayLabel) },
|
||||||
|
onClick = {
|
||||||
|
onVoiceSelected(voice.name)
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SliderSetting(
|
||||||
|
label: String,
|
||||||
|
value: Float,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
onValueChangeFinished: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
var localValue by remember(value) { mutableFloatStateOf(value) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
"%.1f".format(localValue),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = localValue,
|
||||||
|
onValueChange = { localValue = it },
|
||||||
|
onValueChangeFinished = { onValueChangeFinished(localValue) },
|
||||||
|
valueRange = valueRange,
|
||||||
|
steps = 14
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognitionListener
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
|
||||||
|
sealed interface SttResult {
|
||||||
|
data class Partial(val text: String) : SttResult
|
||||||
|
data class Final(val text: String) : SttResult
|
||||||
|
data class Error(val code: Int) : SttResult
|
||||||
|
data class RmsChanged(val rms: Float) : SttResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpeechRecognizerWrapper(private val context: Context) {
|
||||||
|
|
||||||
|
fun startListening(): Flow<SttResult> = callbackFlow {
|
||||||
|
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
|
||||||
|
|
||||||
|
val listener = object : RecognitionListener {
|
||||||
|
override fun onReadyForSpeech(params: Bundle?) {}
|
||||||
|
override fun onBeginningOfSpeech() {}
|
||||||
|
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||||
|
override fun onEndOfSpeech() {}
|
||||||
|
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||||
|
|
||||||
|
override fun onRmsChanged(rmsdB: Float) {
|
||||||
|
trySend(SttResult.RmsChanged(rmsdB))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPartialResults(partialResults: Bundle?) {
|
||||||
|
val texts = partialResults
|
||||||
|
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||||
|
val text = texts?.firstOrNull() ?: return
|
||||||
|
trySend(SttResult.Partial(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResults(results: Bundle?) {
|
||||||
|
val texts = results
|
||||||
|
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||||
|
val text = texts?.firstOrNull().orEmpty()
|
||||||
|
trySend(SttResult.Final(text))
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: Int) {
|
||||||
|
trySend(SttResult.Error(error))
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recognizer.setRecognitionListener(listener)
|
||||||
|
|
||||||
|
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
|
)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||||
|
}
|
||||||
|
recognizer.startListening(intent)
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
recognizer.cancel()
|
||||||
|
recognizer.destroy()
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.Main)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class TextToSpeechWrapper(
|
||||||
|
private val context: Context,
|
||||||
|
ttsConfigFlow: Flow<TtsConfig>
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var initialized = false
|
||||||
|
private var currentConfig = TtsConfig()
|
||||||
|
private var currentEnginePackage: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
initTtsEngine(null)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
ttsConfigFlow.collect { config ->
|
||||||
|
mutex.withLock { currentConfig = config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
ttsConfigFlow
|
||||||
|
.distinctUntilChangedBy { it.enginePackageName }
|
||||||
|
.collect { config ->
|
||||||
|
mutex.withLock {
|
||||||
|
if (config.enginePackageName != currentEnginePackage) {
|
||||||
|
switchEngine(config.enginePackageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initTtsEngine(enginePackageName: String?) {
|
||||||
|
tts?.stop()
|
||||||
|
tts?.shutdown()
|
||||||
|
initialized = false
|
||||||
|
currentEnginePackage = enginePackageName
|
||||||
|
|
||||||
|
val listener = TextToSpeech.OnInitListener { status ->
|
||||||
|
if (status == TextToSpeech.SUCCESS) {
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tts = if (enginePackageName != null) {
|
||||||
|
TextToSpeech(context, listener, enginePackageName)
|
||||||
|
} else {
|
||||||
|
TextToSpeech(context, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchEngine(enginePackageName: String?) {
|
||||||
|
initTtsEngine(enginePackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyConfig(engine: TextToSpeech) {
|
||||||
|
engine.setSpeechRate(currentConfig.speechRate)
|
||||||
|
engine.setPitch(currentConfig.pitch)
|
||||||
|
currentConfig.voiceName?.let { name ->
|
||||||
|
engine.voices?.firstOrNull { it.name == name }?.let { engine.voice = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun speak(text: String): Boolean = suspendCancellableCoroutine { cont ->
|
||||||
|
val engine = tts
|
||||||
|
if (engine == null || !initialized) {
|
||||||
|
cont.resume(false)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
applyConfig(engine)
|
||||||
|
|
||||||
|
val utteranceId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(id: String?) {}
|
||||||
|
|
||||||
|
override fun onDone(id: String?) {
|
||||||
|
if (id == utteranceId && cont.isActive) {
|
||||||
|
cont.resume(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onError(id: String?) {
|
||||||
|
if (id == utteranceId && cont.isActive) {
|
||||||
|
cont.resume(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(id: String?, errorCode: Int) {
|
||||||
|
if (id == utteranceId && cont.isActive) {
|
||||||
|
cont.resume(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
tts?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
scope.cancel()
|
||||||
|
tts?.stop()
|
||||||
|
tts?.shutdown()
|
||||||
|
tts = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import io.picoclaw.android.core.domain.model.MessageSender
|
||||||
|
import io.picoclaw.android.core.domain.model.MessageStatus
|
||||||
|
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||||
|
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||||
|
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||||
|
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.onTimeout
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
|
||||||
|
class VoiceModeManager(
|
||||||
|
private val sttWrapper: SpeechRecognizerWrapper,
|
||||||
|
private val ttsWrapper: TextToSpeechWrapper,
|
||||||
|
private val sendMessage: SendMessageUseCase,
|
||||||
|
private val observeMessages: ObserveMessagesUseCase,
|
||||||
|
private val observeStatus: ObserveStatusUseCase
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(VoiceModeState())
|
||||||
|
val state: StateFlow<VoiceModeState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var loopJob: Job? = null
|
||||||
|
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
if (loopJob?.isActive == true) return
|
||||||
|
_state.update {
|
||||||
|
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
|
||||||
|
}
|
||||||
|
loopJob = scope.launch {
|
||||||
|
voiceLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
loopJob?.cancel()
|
||||||
|
loopJob = null
|
||||||
|
ttsWrapper.stop()
|
||||||
|
_state.value = VoiceModeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix #5: coroutineScope {} で自身のスコープを生成(CoroutineScope. 拡張関数を廃止)
|
||||||
|
private suspend fun voiceLoop() = coroutineScope {
|
||||||
|
val spokenIds = mutableSetOf<String>()
|
||||||
|
val speechQueue = Channel<String>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
// Fix #3: knownMessageIds のスナップショットを collectorJob 内に移動し、
|
||||||
|
// スナップショットと collect 開始の間のレースウィンドウを排除
|
||||||
|
val collectorJob = launch {
|
||||||
|
val initialIds = observeMessages().value.map { it.id }.toSet()
|
||||||
|
observeMessages().collect { messages ->
|
||||||
|
messages.forEach { msg ->
|
||||||
|
if (msg.sender == MessageSender.AGENT &&
|
||||||
|
msg.id !in initialIds &&
|
||||||
|
msg.id !in spokenIds &&
|
||||||
|
msg.status == MessageStatus.RECEIVED
|
||||||
|
) {
|
||||||
|
spokenIds.add(msg.id)
|
||||||
|
speechQueue.send(msg.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
phase = VoicePhase.LISTENING,
|
||||||
|
recognizedText = "", responseText = "",
|
||||||
|
statusText = null,
|
||||||
|
errorMessage = null, amplitudeNormalized = 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userTextChannel = Channel<String?>(1)
|
||||||
|
val listenJob = launch {
|
||||||
|
val text = listen()
|
||||||
|
userTextChannel.send(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
select<Unit> {
|
||||||
|
userTextChannel.onReceive { text ->
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
|
||||||
|
try {
|
||||||
|
sendMessage(text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")
|
||||||
|
}
|
||||||
|
delay(2000)
|
||||||
|
return@onReceive
|
||||||
|
}
|
||||||
|
awaitAndSpeakResponse(speechQueue)
|
||||||
|
} else {
|
||||||
|
drainSpeechQueue(speechQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
speechQueue.onReceive { content ->
|
||||||
|
listenJob.cancel()
|
||||||
|
speakAndDrain(content, speechQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
collectorJob.cancel()
|
||||||
|
speechQueue.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface WaitResult {
|
||||||
|
data class Message(val content: String) : WaitResult
|
||||||
|
data object Heartbeat : WaitResult
|
||||||
|
data object Timeout : WaitResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix #1 + #2 + #4 + #5: select で speechQueue / heartbeat / onTimeout(30s) を待つ
|
||||||
|
private suspend fun awaitAndSpeakResponse(speechQueue: Channel<String>) = coroutineScope {
|
||||||
|
val heartbeat = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
val statusJob = launch {
|
||||||
|
observeStatus().collect { label ->
|
||||||
|
_state.update { it.copy(statusText = label) } // Fix #4: null も伝播
|
||||||
|
if (label != null) heartbeat.trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) }
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val result = select<WaitResult> {
|
||||||
|
speechQueue.onReceive { WaitResult.Message(it) }
|
||||||
|
heartbeat.onReceive { WaitResult.Heartbeat }
|
||||||
|
onTimeout(30_000) { WaitResult.Timeout }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
WaitResult.Timeout -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(phase = VoicePhase.ERROR, errorMessage = "応答がタイムアウトしました")
|
||||||
|
}
|
||||||
|
delay(2000)
|
||||||
|
return@coroutineScope
|
||||||
|
}
|
||||||
|
WaitResult.Heartbeat -> continue
|
||||||
|
is WaitResult.Message -> {
|
||||||
|
speakAndDrain(result.content, speechQueue)
|
||||||
|
val currentStatus = observeStatus().value
|
||||||
|
if (currentStatus == null) return@coroutineScope
|
||||||
|
_state.update {
|
||||||
|
it.copy(phase = VoicePhase.THINKING, statusText = currentStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
statusJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) {
|
||||||
|
_state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) }
|
||||||
|
ttsWrapper.speak(firstContent)
|
||||||
|
while (true) {
|
||||||
|
val next = speechQueue.tryReceive().getOrNull() ?: break
|
||||||
|
_state.update { it.copy(responseText = next) }
|
||||||
|
ttsWrapper.speak(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun drainSpeechQueue(speechQueue: Channel<String>) {
|
||||||
|
val first = speechQueue.tryReceive().getOrNull() ?: return
|
||||||
|
speakAndDrain(first, speechQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun listen(): String? {
|
||||||
|
var finalText: String? = null
|
||||||
|
sttWrapper.startListening().collect { result ->
|
||||||
|
when (result) {
|
||||||
|
is SttResult.Partial -> {
|
||||||
|
_state.update { it.copy(recognizedText = result.text) }
|
||||||
|
}
|
||||||
|
is SttResult.Final -> {
|
||||||
|
finalText = result.text
|
||||||
|
}
|
||||||
|
is SttResult.RmsChanged -> {
|
||||||
|
val normalized = ((result.rms + 2f) / 12f).coerceIn(0f, 1f)
|
||||||
|
_state.update { it.copy(amplitudeNormalized = normalized) }
|
||||||
|
}
|
||||||
|
is SttResult.Error -> {
|
||||||
|
if (result.code == SpeechRecognizer.ERROR_NO_MATCH ||
|
||||||
|
result.code == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
|
||||||
|
) {
|
||||||
|
finalText = ""
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
phase = VoicePhase.ERROR,
|
||||||
|
errorMessage = "音声認識エラー (code=${result.code})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(2000)
|
||||||
|
finalText = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 -> "エラー"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ core-ktx = "1.17.0"
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
coil = "3.1.0"
|
coil = "3.1.0"
|
||||||
markdown-renderer = "0.39.0"
|
markdown-renderer = "0.39.0"
|
||||||
|
navigation = "2.9.0"
|
||||||
|
datastore = "1.1.7"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose
|
# Compose
|
||||||
|
|
@ -53,6 +55,12 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
|
||||||
# Coil
|
# Coil
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "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
|
||||||
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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 {
|
if err != nil {
|
||||||
return "", err
|
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.
|
// runLLMIteration executes the LLM call loop with tool handling.
|
||||||
// Returns the final content, iteration count, and any error.
|
// 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
|
iteration := 0
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
|
|
@ -747,6 +775,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
// Emit tool use status indicator
|
// Emit tool use status indicator
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
if label := statusLabel(tc.Name, tc.Arguments); label != "" {
|
if label := statusLabel(tc.Name, tc.Arguments); label != "" {
|
||||||
|
currentStatus.Store(label)
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue