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>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<data
android:scheme="gigawear"
android:host="chat" />
</intent-filter>
</activity>
@@ -1,10 +1,6 @@
package ru.shadowsparky.gigawear.presentation.chat
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.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -17,9 +13,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.platform.LocalContext
@@ -41,64 +35,23 @@ import kotlinx.coroutines.flow.collectLatest
import ru.shadowsparky.gigawear.R
import ru.shadowsparky.gigawear.presentation.WearErrorContent
import ru.shadowsparky.updater.presentation.UpdateDialog
import java.util.Locale
@Composable
fun ChatScreen(component: ChatComponent) {
val context = LocalContext.current
val state by component.state.collectAsState()
var tts by remember {
mutableStateOf<TextToSpeech?>(null)
val speechManager = remember { SpeechManager(context) }
val isSpeaking by speechManager.isSpeaking.collectAsState()
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(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) {
return@rememberLauncherForActivityResult
}
val spokenText = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
?.firstOrNull()
.orEmpty()
val spokenText = speechManager.extractRecognizedText(result.data)
if (spokenText.isNotEmpty()) {
component.onSpeechRecognized(spokenText)
}
@@ -107,30 +60,12 @@ fun ChatScreen(component: ChatComponent) {
LaunchedEffect(component) {
component.events.collectLatest { event ->
when (event) {
is ChatComponent.ComponentEvent.RequestSpeechRecognition -> {
val intent = Intent(
RecognizerIntent.ACTION_RECOGNIZE_SPEECH
).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(
RecognizerIntent.EXTRA_LANGUAGE,
Locale.getDefault()
)
}
speechLauncher.launch(intent)
ChatComponent.ComponentEvent.RequestSpeechRecognition -> {
speechLauncher.launch(speechManager.createRecognitionIntent())
}
is ChatComponent.ComponentEvent.RequestSpeak -> {
tts?.speak(
event.text,
TextToSpeech.QUEUE_FLUSH,
null,
null
)
speechManager.speak(event.text)
}
}
}
@@ -150,8 +85,7 @@ fun ChatScreen(component: ChatComponent) {
EdgeButton(
onClick = {
if (isSpeaking) {
tts?.stop()
isSpeaking = false
speechManager.stop()
} else {
component.onMicClicked()
}
@@ -191,10 +125,10 @@ fun ChatScreen(component: ChatComponent) {
)
}
state.assistantText?.let {
state.assistantText?.let { text ->
item {
Text(
text = it,
text = text,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Start,
modifier = Modifier.padding(horizontal = 20.dp)
@@ -203,14 +137,12 @@ fun ChatScreen(component: ChatComponent) {
}
if (state.isLoading) {
item {
CircularProgressIndicator()
}
item { CircularProgressIndicator() }
}
state.error?.let {
state.error?.let { error ->
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.essenty.lifecycle.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
@@ -28,6 +29,7 @@ class RoutingComponent(
healthCheck.healthCheck()
navigateTo(Route.ChatScreen())
}.onFailure {
ensureActive()
tokenStorage.clear()
navigateTo(Route.AuthScreen())
}
@@ -423,6 +423,131 @@
</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 -->
<Group