Files
clippy-auto/app/src/main/java/farm/hobbs/clippyauto/VoiceLoop.kt
T

254 lines
8.3 KiB
Kotlin

package farm.hobbs.clippyauto
import android.content.Context
import farm.hobbs.clippyauto.connectivity.MessageQueue
import farm.hobbs.clippyauto.connectivity.NetworkMonitor
import farm.hobbs.clippyauto.connectivity.NetworkState
import farm.hobbs.clippyauto.matrix.MatrixRestClient
import farm.hobbs.clippyauto.push.ClippyResponseBus
import farm.hobbs.clippyauto.push.ResponseDedup
import farm.hobbs.clippyauto.voice.SpeechManager
import farm.hobbs.clippyauto.voice.TTSManager
import farm.hobbs.clippyauto.voice.VoiceState
import farm.hobbs.clippyauto.voice.VoiceStateMachine
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import java.security.MessageDigest
import java.time.Instant
/**
* Core voice loop — wires STT → Matrix → sync/FCM → TTS end-to-end.
*
* This is the heart of ClippyAuto. It coordinates:
* - SpeechManager (STT input)
* - MatrixRestClient (send/receive)
* - TTSManager (audio output)
* - VoiceStateMachine (state transitions)
* - NetworkMonitor (connectivity)
* - FCM fast path (via ClippyResponseBus)
*
* Flow:
* User taps talk → LISTENING → STT captures text → PROCESSING
* → send to Matrix → wait for Clippy response (FCM or sync)
* → RECEIVING → SPEAKING (TTS) → IDLE
*/
class VoiceLoop(
private val context: Context,
private val matrixClient: MatrixRestClient,
private val roomId: String,
private val clippyUserId: String = "@clippy:hobbs.farm",
private val onStateChange: (VoiceState, String) -> Unit
) {
private val stateMachine = VoiceStateMachine()
private val messageQueue = MessageQueue()
private val networkMonitor = NetworkMonitor(context)
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var speechManager: SpeechManager
private lateinit var ttsManager: TTSManager
private var syncJob: Job? = null
private var sessionId: String = Instant.now().toString()
fun initialize() {
// Initialize TTS
ttsManager = TTSManager(
context = context,
onSpeakingStart = {
stateMachine.transition(VoiceState.SPEAKING)
onStateChange(VoiceState.SPEAKING, "Clippy is speaking")
},
onSpeakingDone = {
stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, "Tap to talk to Clippy")
}
)
ttsManager.initialize()
// Initialize STT
speechManager = SpeechManager(
context = context,
onResult = { text -> onSpeechResult(text) },
onListening = {
onStateChange(VoiceState.LISTENING, "Listening...")
},
onError = { error ->
stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, error)
}
)
// Subscribe to FCM fast path
ClippyResponseBus.subscribe { response, _ ->
scope.launch { handleClippyResponse(response) }
}
// Start network monitoring
networkMonitor.start()
// Monitor network state for offline handling
scope.launch {
networkMonitor.state.collectLatest { state ->
when (state) {
NetworkState.OFFLINE -> {
ttsManager.speak("Signal dropped. I'll catch up when you're back.")
}
NetworkState.ONLINE -> {
if (!messageQueue.isEmpty) {
ttsManager.speak("I'm back online.")
flushQueuedMessages()
}
}
NetworkState.WEAK -> { /* Continue, just slower */ }
}
}
}
// Start Matrix sync (fallback path, always running)
startSyncLoop()
}
/**
* Called when user taps the talk button.
*/
fun onTalkPressed() {
when (stateMachine.state.value) {
VoiceState.IDLE -> {
stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening()
onStateChange(VoiceState.LISTENING, "Listening...")
}
VoiceState.SPEAKING -> {
// Interruption — stop TTS, start listening
ttsManager.interrupt()
stateMachine.transition(VoiceState.LISTENING)
speechManager.startListening()
onStateChange(VoiceState.LISTENING, "Listening...")
}
else -> { /* Ignore taps during PROCESSING/RECEIVING */ }
}
}
/**
* Called when STT produces a result.
*/
private fun onSpeechResult(text: String) {
if (text.isBlank()) {
stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, "Tap to talk to Clippy")
return
}
stateMachine.transition(VoiceState.PROCESSING)
onStateChange(VoiceState.PROCESSING, "Thinking...")
scope.launch {
val sent = sendToMatrix(text)
if (!sent) {
// Offline — queue the message
messageQueue.enqueue(text)
ttsManager.speak("Hang on, lost signal for a sec.")
stateMachine.transition(VoiceState.IDLE)
onStateChange(VoiceState.IDLE, "Offline — message queued")
}
// If sent, we wait for response via FCM or sync
}
}
/**
* Send transcribed text to Matrix #clippy-voice room.
*/
private suspend fun sendToMatrix(text: String): Boolean {
return withContext(Dispatchers.IO) {
matrixClient.sendMessage(
roomId = roomId,
body = text,
source = "voice",
sessionId = sessionId
)
}
}
/**
* Handle Clippy's response — from either FCM or Matrix sync.
*/
private fun handleClippyResponse(text: String) {
// Dedup — mark this response so the other path skips it
val hash = sha256(text)
if (ResponseDedup.seen(hash)) return
ResponseDedup.mark(hash)
stateMachine.transition(VoiceState.RECEIVING)
onStateChange(VoiceState.RECEIVING, "")
// Send to TTS — TTSManager handles queuing if multiple chunks arrive
ttsManager.speak(text)
}
/**
* Background sync loop — polls Matrix for messages.
* This is the fallback path when FCM doesn't deliver first.
*/
private fun startSyncLoop() {
syncJob = scope.launch(Dispatchers.IO) {
while (isActive) {
try {
val messages = matrixClient.sync(roomId, timeoutMs = 30000)
messages
.filter { it.sender == clippyUserId }
.filter { it.signal == null } // Skip session signals
.forEach { msg ->
withContext(Dispatchers.Main) {
handleClippyResponse(msg.body)
}
}
} catch (e: Exception) {
delay(5000) // Back off on error
}
}
}
}
/**
* Flush messages queued during offline period.
*/
private suspend fun flushQueuedMessages() {
val queued = messageQueue.flush()
for (msg in queued) {
sendToMatrix(msg.text)
}
}
/**
* Send session start signal.
*/
fun startSession() {
sessionId = Instant.now().toString()
scope.launch(Dispatchers.IO) {
matrixClient.sendSessionSignal(roomId, "session_start", sessionId)
}
}
/**
* Send session end signal.
*/
fun endSession() {
scope.launch(Dispatchers.IO) {
matrixClient.sendSessionSignal(roomId, "session_end", sessionId)
}
}
fun destroy() {
syncJob?.cancel()
speechManager.destroy()
ttsManager.shutdown()
networkMonitor.stop()
scope.cancel()
}
private fun sha256(text: String): String {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(text.toByteArray()).joinToString("") { "%02x".format(it) }
}
}