add speechmanager
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
+14
-82
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user