add speechmanager

This commit is contained in:
2026-09-15 11:15:30 +03:00
parent 4c6735746e
commit eeaca6c1bd
5 changed files with 237 additions and 82 deletions
@@ -36,6 +36,9 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<data
android:scheme="gigawear"
android:host="chat" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -1,10 +1,6 @@
package ru.shadowsparky.gigawear.presentation.chat package ru.shadowsparky.gigawear.presentation.chat
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.speech.RecognizerIntent
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -17,9 +13,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -41,64 +35,23 @@ import kotlinx.coroutines.flow.collectLatest
import ru.shadowsparky.gigawear.R import ru.shadowsparky.gigawear.R
import ru.shadowsparky.gigawear.presentation.WearErrorContent import ru.shadowsparky.gigawear.presentation.WearErrorContent
import ru.shadowsparky.updater.presentation.UpdateDialog import ru.shadowsparky.updater.presentation.UpdateDialog
import java.util.Locale
@Composable @Composable
fun ChatScreen(component: ChatComponent) { fun ChatScreen(component: ChatComponent) {
val context = LocalContext.current val context = LocalContext.current
val state by component.state.collectAsState() val state by component.state.collectAsState()
val speechManager = remember { SpeechManager(context) }
var tts by remember { val isSpeaking by speechManager.isSpeaking.collectAsState()
mutableStateOf<TextToSpeech?>(null) DisposableEffect(speechManager) {
onDispose { speechManager.release() }
} }
var isSpeaking by remember {
mutableStateOf(false)
}
DisposableEffect(context) {
val ttsInstance = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.getDefault()
tts?.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) { isSpeaking = true }
override fun onDone(utteranceId: String?) { isSpeaking = false }
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) { isSpeaking = false }
override fun onError(utteranceId: String?, errorCode: Int) {
super.onError(utteranceId, errorCode)
isSpeaking = false
}
override fun onStop(
utteranceId: String?,
interrupted: Boolean
) { isSpeaking = false }
}
)
}
}
tts = ttsInstance
onDispose {
ttsInstance.stop()
ttsInstance.shutdown()
tts = null
isSpeaking = false
}
}
val speechLauncher = rememberLauncherForActivityResult( val speechLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode != Activity.RESULT_OK) { if (result.resultCode != Activity.RESULT_OK) {
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
val spokenText = speechManager.extractRecognizedText(result.data)
val spokenText = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
if (spokenText.isNotEmpty()) { if (spokenText.isNotEmpty()) {
component.onSpeechRecognized(spokenText) component.onSpeechRecognized(spokenText)
} }
@@ -107,30 +60,12 @@ fun ChatScreen(component: ChatComponent) {
LaunchedEffect(component) { LaunchedEffect(component) {
component.events.collectLatest { event -> component.events.collectLatest { event ->
when (event) { when (event) {
is ChatComponent.ComponentEvent.RequestSpeechRecognition -> { ChatComponent.ComponentEvent.RequestSpeechRecognition -> {
val intent = Intent( speechLauncher.launch(speechManager.createRecognitionIntent())
RecognizerIntent.ACTION_RECOGNIZE_SPEECH
).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(
RecognizerIntent.EXTRA_LANGUAGE,
Locale.getDefault()
)
}
speechLauncher.launch(intent)
} }
is ChatComponent.ComponentEvent.RequestSpeak -> { is ChatComponent.ComponentEvent.RequestSpeak -> {
tts?.speak( speechManager.speak(event.text)
event.text,
TextToSpeech.QUEUE_FLUSH,
null,
null
)
} }
} }
} }
@@ -150,8 +85,7 @@ fun ChatScreen(component: ChatComponent) {
EdgeButton( EdgeButton(
onClick = { onClick = {
if (isSpeaking) { if (isSpeaking) {
tts?.stop() speechManager.stop()
isSpeaking = false
} else { } else {
component.onMicClicked() component.onMicClicked()
} }
@@ -191,10 +125,10 @@ fun ChatScreen(component: ChatComponent) {
) )
} }
state.assistantText?.let { state.assistantText?.let { text ->
item { item {
Text( Text(
text = it, text = text,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
modifier = Modifier.padding(horizontal = 20.dp) modifier = Modifier.padding(horizontal = 20.dp)
@@ -203,14 +137,12 @@ fun ChatScreen(component: ChatComponent) {
} }
if (state.isLoading) { if (state.isLoading) {
item { item { CircularProgressIndicator() }
CircularProgressIndicator()
}
} }
state.error?.let { state.error?.let { error ->
item { item {
WearErrorContent(it.message ?: it.toString()) WearErrorContent(error.message ?: error.toString())
} }
} }
} }
@@ -0,0 +1,93 @@
package ru.shadowsparky.gigawear.presentation.chat
import android.content.Context
import android.content.Intent
import android.speech.RecognizerIntent
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
class SpeechManager(context: Context) {
private val _isSpeaking = MutableStateFlow(false)
val isSpeaking = _isSpeaking.asStateFlow()
private var tts: TextToSpeech? = null
init {
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) { configureTts() }
}
}
fun createRecognitionIntent(): Intent {
return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(
RecognizerIntent.EXTRA_LANGUAGE,
Locale.getDefault()
)
}
}
fun extractRecognizedText(intent: Intent?): String {
return intent
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
}
fun speak(text: String) {
tts?.speak(
text,
TextToSpeech.QUEUE_FLUSH,
null,
UTTERANCE_ID
)
}
fun stop() {
tts?.stop()
resetSpeaking()
}
fun release() {
stop()
tts?.shutdown()
tts = null
}
private fun configureTts() {
tts?.apply {
language = Locale.getDefault()
setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = setSpeaking(true)
override fun onDone(utteranceId: String?) = resetSpeaking()
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) = resetSpeaking()
override fun onError(utteranceId: String?, errorCode: Int) = resetSpeaking()
override fun onStop(utteranceId: String?, interrupted: Boolean) = resetSpeaking()
}
)
}
}
private fun resetSpeaking() = setSpeaking(false)
private fun setSpeaking(value: Boolean) {
_isSpeaking.value = value
Log.d(TAG, "setSpeaking $value")
}
private companion object {
const val TAG = "SpeechManager"
const val UTTERANCE_ID = "chat_speech"
}
}
@@ -2,6 +2,7 @@ package ru.shadowsparky.gigawear.presentation.routing
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
@@ -28,6 +29,7 @@ class RoutingComponent(
healthCheck.healthCheck() healthCheck.healthCheck()
navigateTo(Route.ChatScreen()) navigateTo(Route.ChatScreen())
}.onFailure { }.onFailure {
ensureActive()
tokenStorage.clear() tokenStorage.clear()
navigateTo(Route.AuthScreen()) navigateTo(Route.AuthScreen())
} }
@@ -423,6 +423,131 @@
</Group> </Group>
<!-- CHAT / VOICE -->
<Group
name="chat"
x="195"
y="284"
width="60"
height="60">
<Variant
mode="AMBIENT"
target="alpha"
value="0"/>
<PartDraw
x="0"
y="0"
width="60"
height="60">
<Launch
target="ru.shadowsparky.gigawear/ru.shadowsparky.gigawear.presentation.MainActivity" />
<!-- shadow -->
<Ellipse
x="2"
y="4"
width="56"
height="56">
<Fill color="#40000000"/>
</Ellipse>
<!-- background -->
<Ellipse
x="2"
y="2"
width="56"
height="56">
<Fill color="#0000004D"/>
</Ellipse>
<!-- border -->
<Ellipse
x="3"
y="3"
width="54"
height="54">
<Stroke
color="#35FFFFFF"
thickness="1"/>
</Ellipse>
<!-- microphone body -->
<RoundRectangle
x="24"
y="14"
width="12"
height="23"
cornerRadiusX="6"
cornerRadiusY="6">
<Stroke
color="#FFFFFFFF"
thickness="3"
cap="ROUND"/>
</RoundRectangle>
<!-- microphone left support -->
<RoundRectangle
x="19"
y="27"
width="3"
height="8"
cornerRadiusX="2"
cornerRadiusY="2">
<Fill color="#FFFFFFFF"/>
</RoundRectangle>
<!-- microphone right support -->
<RoundRectangle
x="38"
y="27"
width="3"
height="8"
cornerRadiusX="2"
cornerRadiusY="2">
<Fill color="#FFFFFFFF"/>
</RoundRectangle>
<!-- microphone bottom -->
<RoundRectangle
x="19"
y="33"
width="22"
height="3"
cornerRadiusX="2"
cornerRadiusY="2">
<Fill color="#FFFFFFFF"/>
</RoundRectangle>
<!-- microphone stem -->
<RoundRectangle
x="28.5"
y="36"
width="3"
height="7"
cornerRadiusX="2"
cornerRadiusY="2">
<Fill color="#FFFFFFFF"/>
</RoundRectangle>
<!-- microphone base -->
<RoundRectangle
x="23"
y="42"
width="14"
height="3"
cornerRadiusX="2"
cornerRadiusY="2">
<Fill color="#FFFFFFFF"/>
</RoundRectangle>
</PartDraw>
</Group>
<!-- STEPS --> <!-- STEPS -->
<Group <Group