diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index ebd5130..57e10be 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -3407,11 +3407,18 @@ class ARIABridge: # weiter auseinander (man hoert den Wechsel erst). _norm_txt = " ".join(text.lower().split()) _now_t = asyncio.get_event_loop().time() + # Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den + # zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s + # fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich + # nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter", + # "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die + # Idempotenz ueber audioRequestId weiter oben. + _DUPE_WINDOW_SEC = 1.5 if (_norm_txt and _norm_txt == getattr(self, "_last_endpoint_norm", "") - and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0): - logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r", - text[:60]) + and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC): + logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r", + _DUPE_WINDOW_SEC, text[:60]) return self._last_endpoint_norm = _norm_txt self._last_endpoint_at = _now_t @@ -3777,8 +3784,12 @@ class ARIABridge: r")\s*[.!?]?\s*$", re.IGNORECASE, ) + # Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern + # Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..." + # als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im + # Hauptchat unsichtbar ("wird gar nicht verarbeitet"). _VOICE_META_PROJECT_PREFIX = re.compile( - r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$", + r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$", re.IGNORECASE | re.DOTALL, ) @@ -3833,7 +3844,10 @@ class ARIABridge: s = SequenceMatcher(None, q, pname).ratio() if s > best_score: best, best_score = p, s - if best and best_score >= 0.6: + # Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage + # Aehnlichkeit, und die Nachricht verschwand still in einem + # fremden Projekt. Lieber im Hauptchat lassen als falsch routen. + if best and best_score >= 0.82: pid = best["id"] self._voice_sticky_project_id = pid self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC @@ -3845,8 +3859,10 @@ class ARIABridge: # Kein Match → als normale Nachricht weiter (Sticky wenn aktiv) # 3) Kein Meta / Prefix → Sticky oder Default if self._voice_sticky_project_id and now < self._voice_sticky_expires_at: - # Sticky refreshen - self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC + # Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist + # erneuert — ein einziger Fehl-Match und danach landete dauerhaft + # alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen + # Zeit aus und der Hauptchat uebernimmt wieder. return (True, stripped, self._voice_sticky_project_id, "sticky") # Sticky abgelaufen — zurücksetzen self._voice_sticky_project_id = "" diff --git a/rvs/server.js b/rvs/server.js index 5ab0e75..063f7b4 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([ // ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft // der RVS sie still an der Allow-List und die Historie bleibt leer. "agent_history", "agent_history_query", "agent_history_list", + // STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen + // soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die + // Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste + // Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session + // an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne + // ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie + // laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung. + "stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result", + "chat_ack", // Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query), // RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je // Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log). diff --git a/xtts/whisper/bridge.py b/xtts/whisper/bridge.py index 91261a9..1db3d6c 100644 --- a/xtts/whisper/bridge.py +++ b/xtts/whisper/bridge.py @@ -584,6 +584,12 @@ class SessionManager: if ws is None: return # disconnected — Endpointer pausiert bis Reconnect + # Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT + # transkribieren/finalisieren — sonst kaeme leerer Text heraus und die + # Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter. + if self.runner.model is None: + return + audio_ms = self._buffer_duration_ms(sess) # Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text) @@ -962,19 +968,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None: # transcribe-Call mit Leerstring weil Model None. target_model = payload.get("model") or runner.model_size or WHISPER_MODEL needs_load = (runner.model is None) or (target_model != runner.model_size) + # WICHTIG: Session SOFORT anlegen — auch wenn das Modell + # noch laedt. Sonst laufen alle stt_audio_chunk der ersten + # Aufnahme ins Leere (feed_chunk findet keine Session) und + # der Take ist verloren; erst die zweite Aufnahme klappt, + # weil das Modell dann warm ist. Der Buffer fuellt sich + # jetzt waehrend des Ladens, das Transkribieren wartet. + sessions.start_session(payload) if needs_load: - async def _load_then_start(p, target): + async def _load_only(target): await _broadcast_status(ws, "loading", model=target) try: await runner.ensure_loaded(target) await _broadcast_status(ws, "ready", model=runner.model_size) except Exception as e: await _broadcast_status(ws, "error", error=str(e)[:200]) - return - sessions.start_session(p) - asyncio.create_task(_load_then_start(payload, target_model)) - else: - sessions.start_session(payload) + asyncio.create_task(_load_only(target_model)) elif mtype == "stt_audio_chunk": ok = sessions.feed_chunk(payload)