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 5e5f690..8750373 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 @@ -331,7 +331,7 @@ class RvsClient( if (mode == "speech" || mode == "both") { val r = speakOut(p, text) 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" }) val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})") @@ -342,21 +342,33 @@ class RvsClient( private fun doSpeak(p: JSONObject): JSONObject { val text = p.optString("text") 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) 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}") }, ) } - /** 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 { val audio = p.optString("audio_base64") val focus = p.optString("focus").ifBlank { "duck" } - return if (audio.isNotBlank()) - Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus) - else Speaker.speak(appCtx, text, focus) + if (audio.isNotBlank()) + 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 } } private fun doVolume(p: JSONObject): JSONObject { diff --git a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/Speaker.kt b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/Speaker.kt index 0b0b546..28ea4ef 100644 --- a/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/Speaker.kt +++ b/host-agent/android/app/src/main/java/de/hackersoft/ariaagent/Speaker.kt @@ -65,7 +65,14 @@ object Speaker { var waited = 0 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") tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() { override fun onStart(id: String?) {} @@ -73,7 +80,11 @@ object Speaker { @Deprecated("Deprecated in Java") 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 } 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 5f50dc7..3f97712 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 @@ -61,6 +61,10 @@ class VoiceSession( /** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */ @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()) + val isRecording: Boolean get() = recording // ── Aufnehmen + streamen ──────────────────────────────────────── @@ -170,6 +174,32 @@ class VoiceSession( .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. */ fun ask(text: String): JSONObject { if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text") @@ -206,10 +236,15 @@ class VoiceSession( true } "audio_pcm" -> { - // Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und - // Sprachausgabe gewuenscht war (sonst redet auch das Handy mit). + // Abspielen, wenn (a) wir dieses Audio selbst angefordert haben + // (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") - 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 } else -> false