fix(android-agent): Sprachausgabe — ARIAs echte Stimme statt (fehlender) Geraete-TTS

Der Agent meldete 'gesprochen', es kam aber kein Ton: Fire TV Sticks haben in
der Regel keine (deutsche) TTS-Engine, und wir haben weder setLanguage noch den
Rueckgabewert von speak() geprueft -> stiller Fehlschlag.

- speak/notify fordern jetzt ARIAs ECHTE Stimme ueber den bestehenden Serverpfad
  an: tts_request -> Bridge -> F5/XTTS -> audio_pcm, das der Agent abspielt.
  Keine Server-Aenderung noetig (beide Typen stehen in der RVS-Allow-List).
  VoiceSession merkt sich die angeforderte messageId und spielt genau dieses
  Audio ab — die Turn-Ownership bleibt sonst unveraendert (Handy redet nicht mit).
- Geraete-TTS nur noch auf ausdrueckliche Anforderung (engine='device') und nun
  ehrlich: fehlende Engine, fehlende deutsche Sprachdaten und ein abgelehntes
  speak() werden als Fehler gemeldet statt als Erfolg.
Android-Version 0.0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:47:22 +02:00
co-authored by Claude Opus 4.8
parent 8f9e3e5c79
commit 9b11c16479
3 changed files with 70 additions and 12 deletions
@@ -331,7 +331,7 @@ class RvsClient(
if (mode == "speech" || mode == "both") { if (mode == "speech" || mode == "both") {
val r = speakOut(p, text) val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" } r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen" } ?: run { done += "gesprochen (ARIAs Stimme)" }
} }
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" }) if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})") val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
@@ -342,21 +342,33 @@ class RvsClient(
private fun doSpeak(p: JSONObject): JSONObject { private fun doSpeak(p: JSONObject): JSONObject {
val text = p.optString("text") val text = p.optString("text")
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.") if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
val r = speakOut(p, text) val r = speakOut(p, text)
return r.fold( return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) }, { JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") }, { err("Sprachausgabe fehlgeschlagen: ${it.message}") },
) )
} }
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS. /**
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */ * Sprachausgabe. Reihenfolge:
* 1. mitgeliefertes Audio abspielen,
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
* Sticks haben oft gar keine (deutsche) TTS-Engine,
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
* (engine='device') oder wenn kein Server-Audio moeglich ist.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
*/
private fun speakOut(p: JSONObject, text: String): Result<Unit> { private fun speakOut(p: JSONObject, text: String): Result<Unit> {
val audio = p.optString("audio_base64") val audio = p.optString("audio_base64")
val focus = p.optString("focus").ifBlank { "duck" } val focus = p.optString("focus").ifBlank { "duck" }
return if (audio.isNotBlank()) if (audio.isNotBlank())
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus) return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
else Speaker.speak(appCtx, text, focus) if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
} }
private fun doVolume(p: JSONObject): JSONObject { private fun doVolume(p: JSONObject): JSONObject {
@@ -65,7 +65,14 @@ object Speaker {
var waited = 0 var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 } while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
} }
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)") if (!ttsReady) throw IllegalStateException(
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
throw IllegalStateException(
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
AudioFocusHelper.request(ctx, focus, "speak") AudioFocusHelper.request(ctx, focus, "speak")
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() { tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(id: String?) {} override fun onStart(id: String?) {}
@@ -73,7 +80,11 @@ object Speaker {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) } override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
}) })
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria") val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
if (rc != TextToSpeech.SUCCESS) {
AudioFocusHelper.abandon(ctx)
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
}
Unit Unit
} }
@@ -61,6 +61,10 @@ class VoiceSession(
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */ /** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = "" @Volatile private var lastReplyText: String = ""
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
val isRecording: Boolean get() = recording val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ──────────────────────────────────────── // ── Aufnehmen + streamen ────────────────────────────────────────
@@ -170,6 +174,32 @@ class VoiceSession(
.put("result", JSONObject().put("message", "Aufnahme beendet")) .put("result", JSONObject().put("message", "Aufnahme beendet"))
} }
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
return mid
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */ /** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject { fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text") if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
@@ -206,10 +236,15 @@ class VoiceSession(
true true
} }
"audio_pcm" -> { "audio_pcm" -> {
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und // Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit). // (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
val mid = p.optString("messageId") val mid = p.optString("messageId")
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p) if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true true
} }
else -> false else -> false