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