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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user