fix(android-agent): Sprachausgabe meldet erst Erfolg, wenn wirklich Ton laeuft

Der Agent meldete "gesprochen", sobald die TTS-Anfrage im Socket lag. Ob sie
ueberhaupt rausging, ob der Server antwortete oder ob ein einziger Ton kam,
hat nie jemand geprueft. Auf dem Fire TV blieb es damit still, waehrend ARIA
"erledigt" sagte — ein Fehler, der sich nicht melden kann.

Drei Ursachen:
- OkHttps send() WIRFT NICHT, sondern gibt false zurueck, wenn die Verbindung
  zu ist. Der Rueckgabewert wurde verworfen: Senden ins Leere sah aus wie
  Erfolg. send() gibt das jetzt durch, requestTts wirft bei abgelehntem Senden.
- Niemand wartete auf Audio. Neu: awaitSpeechStart() wartet, bis fuer diese
  messageId wirklich Ton laeuft, sonst Fehler mit Grund.
- Warten darf NICHT im WS-Lese-Thread passieren: handle() laeuft dort, und das
  erwartete audio_pcm kommt ueber genau diese Verbindung — wir haetten uns
  selbst ausgehungert. speak/notify laufen jetzt in einem eigenen Thread und
  schicken ihr host_result von dort.

Brain-seitig haben speak/notify 40s statt 20s Zeit, damit die ehrliche Antwort
(TTS-Render + Uebertragung) nicht in den Timeout laeuft.

APK baut durch (Gradle, nur die zwei bekannten Deprecation-Warnungen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:17:48 +02:00
co-authored by Claude Opus 5
parent 12b8362d26
commit 443b36bb9d
2 changed files with 75 additions and 10 deletions
@@ -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 {
@@ -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")