diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt index 8750373..d5608ef 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/RvsClient.kt @@ -36,7 +36,7 @@ class RvsClient( /** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */ val voice: VoiceSession by lazy { VoiceSession(appCtx, config, - send = { t, p -> ws?.let { send(it, t, p) } }, + send = { t, p -> ws?.let { send(it, t, p) } ?: false }, onEvent = { msg -> onStatus(true, msg) }) } @@ -112,12 +112,21 @@ class RvsClient( }.start() } - private fun send(webSocket: WebSocket, type: String, payload: JSONObject) { + /** + * Verschickt eine Nachricht. Gibt zurueck, ob der Socket sie ANGENOMMEN hat. + * + * Wichtig: OkHttps `send()` WIRFT NICHT, sondern gibt `false` zurueck, wenn + * die Verbindung zu ist oder der Ausgangspuffer voll ist. Dieser Rueckgabe- + * wert wurde vorher verworfen — ein Senden ins Leere sah damit aus wie ein + * Erfolg. Genau daran ist die Fire-TV-Sprachausgabe lautlos gescheitert und + * hat trotzdem "gesprochen" gemeldet. + */ + private fun send(webSocket: WebSocket, type: String, payload: JSONObject): Boolean { val o = JSONObject() o.put("type", type) o.put("payload", payload) o.put("timestamp", System.currentTimeMillis()) - try { webSocket.send(o.toString()) } catch (_: Exception) {} + return try { webSocket.send(o.toString()) } catch (_: Exception) { false } } private fun sendHello(webSocket: WebSocket) { @@ -178,6 +187,31 @@ class RvsClient( val action = payload.optString("action") val params = payload.optJSONObject("params") ?: JSONObject() + + fun reply(r: JSONObject) { + r.put("requestId", payload.optString("requestId")) + r.put("hostId", config.hostId()) + r.put("action", action) + send(webSocket, "host_result", r) + } + + // Alles, was SPRECHEN kann, laeuft in einem EIGENEN Thread. Es muss + // abwarten, bis wirklich Ton kommt — und dieses handle() laeuft auf dem + // Lese-Thread des WebSockets. Wuerden wir hier warten, kaeme das + // erwartete audio_pcm nie an (wir blockieren ja selbst das Lesen) und + // jede Sprachausgabe liefe garantiert in den Timeout. + if (action in SPEAKING_ACTIONS && config.controlEnabled) { + Thread { + val r = try { + if (action == "speak") doSpeak(params) else doNotify(params) + } catch (t: Throwable) { + err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}") + } + reply(r) + }.also { it.isDaemon = true }.start() + return + } + // WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception // ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer // Fehlermeldung nur einen Timeout (kein Result kommt zurueck). @@ -210,15 +244,16 @@ class RvsClient( } catch (t: Throwable) { err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}") } - result.put("requestId", payload.optString("requestId")) - result.put("hostId", config.hostId()) - result.put("action", action) - send(webSocket, "host_result", result) + reply(result) } /** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */ private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch") + /** Aktionen, die sprechen koennen — laufen abseits des WS-Lese-Threads, + * weil sie auf das eintreffende audio_pcm warten (siehe handle()). */ + private val SPEAKING_ACTIONS = setOf("speak", "notify") + /** Message-Typen des ARIA-Sprachdialogs (kein host_command). */ private val VOICE_TYPES = setOf( "chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial") @@ -368,7 +403,12 @@ class RvsClient( return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus) if (p.optString("engine") == "device") return Speaker.speak(appCtx, text, focus) - return runCatching { voice.requestTts(text); Unit } + // Nicht nur abschicken, sondern abwarten bis wirklich Ton kommt — + // sonst meldet der Agent Erfolg und der Fernseher bleibt stumm. + return runCatching { + val mid = voice.requestTts(text) + voice.awaitSpeechStart(mid) + } } private fun doVolume(p: JSONObject): JSONObject { diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/VoiceSession.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/VoiceSession.kt index 3f97712..342ac55 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/VoiceSession.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/VoiceSession.kt @@ -33,7 +33,8 @@ import kotlin.random.Random class VoiceSession( private val appCtx: Context, private val config: AgentConfig, - private val send: (type: String, payload: JSONObject) -> Unit, + /** Gibt zurueck, ob der Socket die Nachricht angenommen hat (false = ins Leere). */ + private val send: (type: String, payload: JSONObject) -> Boolean, private val onEvent: (String) -> Unit = {}, ) { companion object { @@ -191,15 +192,39 @@ class VoiceSession( expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } } } } - send("tts_request", JSONObject().apply { + val accepted = send("tts_request", JSONObject().apply { put("text", text) put("messageId", mid) put("voice", "") put("speed", 1.0) }) + if (!accepted) { + expectedAudio.remove(mid) + throw IllegalStateException( + "Keine Verbindung zum Server — TTS-Anfrage ging nicht raus") + } return mid } + /** + * Wartet, bis fuer [mid] wirklich der erste Ton kommt. + * + * Ohne das meldete `host_speak` Erfolg, sobald die Anfrage im Socket lag — + * ARIA sagte "erledigt", und der Fernseher blieb still. Hier wird erst + * geantwortet, wenn tatsaechlich Audio laeuft, sonst mit klarem Grund. + */ + fun awaitSpeechStart(mid: String, timeoutMs: Long = 20000) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (playingMessageId == mid) return // Ton laeuft + if (!expectedAudio.contains(mid)) return // schon fertig gesprochen + try { Thread.sleep(100) } catch (_: InterruptedException) { return } + } + expectedAudio.remove(mid) + throw IllegalStateException( + "Server hat kein Audio geliefert (TTS-Dienst erreichbar? Stimme gesetzt?)") + } + /** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */ fun ask(text: String): JSONObject { if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")