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