Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aae5af6a9 | ||
|
|
a8ff73f93d | ||
|
|
0e9adeee5c | ||
|
|
6addb2f8fe | ||
|
|
9bdfb7193e | ||
|
|
9e78d75149 | ||
|
|
517c993ac8 | ||
|
|
c1bd13687d | ||
|
|
d9bb7239c6 | ||
|
|
0265aabb5e | ||
|
|
17bc50b847 | ||
|
|
1f2be4299d |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20306
|
||||
versionName "0.2.3.6"
|
||||
versionCode 20309
|
||||
versionName "0.2.3.9"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.3.6",
|
||||
"version": "0.2.3.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -1401,13 +1401,30 @@ const ChatScreen: React.FC = () => {
|
||||
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
|
||||
// stoppen — onPlaybackFinished liest converseRef. Default true.
|
||||
converseRef.current = (message.payload as any).converse !== false;
|
||||
// Passiv-Lauschen (30s) NUR wenn das Brain explizit converse:true schickt.
|
||||
// Vorher default true → jeder Befehl (auch "Spiele Spotify" mit gesproche-
|
||||
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
|
||||
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
|
||||
converseRef.current = (message.payload as any).converse === true;
|
||||
const _isSilent = (message.payload as any).speak === false;
|
||||
if (_isSilent && wakeWordService.isConversing()) {
|
||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
||||
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
||||
// kein 30s-Fenster (skipPassive=true).
|
||||
wakeWordService.endConversation(true).catch(() => {});
|
||||
if (_isSilent) {
|
||||
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
|
||||
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
|
||||
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
|
||||
if (converseRef.current) {
|
||||
// Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen,
|
||||
// sondern das passive Lausch-Fenster oeffnen (endConversation(false)),
|
||||
// damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA
|
||||
// haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt.
|
||||
if (wakeWordService.isConversing()) {
|
||||
wakeWordService.endConversation(false).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene
|
||||
// Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand
|
||||
// (conversing, passives Lauschen ODER offene Streaming-Aufnahme).
|
||||
ariaStopRecording('silent-command').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2294,11 +2311,44 @@ const ChatScreen: React.FC = () => {
|
||||
return true;
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
|
||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||
// Befehl ist ausgefuehrt, ARIA hat die Rueckinfo (Skill-Ergebnis) und antwortet
|
||||
// NICHT vorgelesen. Weil ohne TTS kein onPlaybackFinished kommt, muss der
|
||||
// Aufnahme-/Konversations-Zustand hier aktiv aufgeraeumt werden, sonst bleibt
|
||||
// das Ohr haengen bzw. das Aufnahme-Fenster laeuft leer weiter (Stefans
|
||||
// Reproduktion: "spotify play" und das Mikro wartet trotzdem 30s).
|
||||
// Unterschied zum manuellen Stop: der verwirft NICHT, sondern finalisiert die
|
||||
// Aufnahme (User will seinen Satz verarbeitet haben) — hier ist der Befehl
|
||||
// schon durch, ein evtl. offenes Folge-Fenster wird verworfen.
|
||||
const ariaStopRecording = useCallback(async (reason: string): Promise<void> => {
|
||||
converseRef.current = false;
|
||||
// 1) Passiv-Lauschen: sauber beenden (cancelt den Stream selbst, startet
|
||||
// KEINE neue passive Aufnahme).
|
||||
if (wakeWordService.getState() === 'listening') {
|
||||
await wakeWordService.exitPassiveListening('manual').catch(() => {});
|
||||
return;
|
||||
}
|
||||
// 2) Noch offene Streaming-Aufnahme (aktiv / Barge-In) verwerfen.
|
||||
if (audioService.isStreamingRecording()) {
|
||||
await audioService.cancelStreamingRecording(reason).catch(() => {});
|
||||
}
|
||||
// 3) Konversation beenden → zurueck aufs Wake-Word (skipPassive: kein 30s-Fenster).
|
||||
if (wakeWordService.isConversing()) {
|
||||
await wakeWordService.endConversation(true).catch(() => {});
|
||||
} else if (!wakeWordService.isActive()) {
|
||||
setWakeWordActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
|
||||
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
|
||||
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
||||
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
||||
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
||||
// Manueller Stop = endgueltig: auch die NACH der Antwort kommende
|
||||
// onPlaybackFinished darf kein 30s-Passiv-Fenster mehr oeffnen.
|
||||
converseRef.current = false;
|
||||
// Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen +
|
||||
// laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz").
|
||||
if (audioService.isPlayingAudio()) {
|
||||
|
||||
+97
-5
@@ -1347,6 +1347,77 @@ def _extract_await_marker(text: str) -> tuple:
|
||||
return text, False
|
||||
|
||||
|
||||
# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ──
|
||||
#
|
||||
# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun)
|
||||
# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette
|
||||
# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau
|
||||
# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in
|
||||
# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag,
|
||||
# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur
|
||||
# ARIA weiss aus dem Kontext, was gerade gemeint ist.
|
||||
#
|
||||
# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein =
|
||||
# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false).
|
||||
# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten
|
||||
# (converse=true) — kein erneutes "Computer" noetig.
|
||||
# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false).
|
||||
_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE)
|
||||
_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE)
|
||||
_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_flow_markers(text: str) -> tuple:
|
||||
"""Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text.
|
||||
Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist
|
||||
None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag).
|
||||
Regeln: [[STUMM]] alleine = Einzelbefehl → auch converse=false (Mikro zu),
|
||||
ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]]."""
|
||||
if not text:
|
||||
return text, None, None
|
||||
speak_ov = None
|
||||
conv_ov = None
|
||||
if _SILENT_MARKER_RE.search(text):
|
||||
speak_ov = False
|
||||
text = _SILENT_MARKER_RE.sub("", text)
|
||||
if _END_MARKER_RE.search(text):
|
||||
conv_ov = False
|
||||
text = _END_MARKER_RE.sub("", text)
|
||||
if _CONT_MARKER_RE.search(text):
|
||||
# [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden.
|
||||
if conv_ov is None:
|
||||
conv_ov = True
|
||||
text = _CONT_MARKER_RE.sub("", text)
|
||||
# Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu.
|
||||
if speak_ov is False and conv_ov is None:
|
||||
conv_ov = False
|
||||
return text.strip(), speak_ov, conv_ov
|
||||
|
||||
|
||||
# Explizite "Konversation beenden"-Phrasen vom USER — deterministisch, NICHT auf
|
||||
# ARIAs [[ENDE]]-Marker angewiesen. Stefan will "Konversation Ende" o.ae. als
|
||||
# festen Trigger: danach zurueck aufs Wake-Word, egal was ARIA sonst tut. Eine in
|
||||
# derselben Nachricht enthaltene Frage beantwortet sie normal (wird vorgelesen),
|
||||
# aber converse wird auf false gezwungen. Nomen + Ende-Wort in EINEM Satzteil
|
||||
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
|
||||
# o.ae. nicht faelschlich matcht.
|
||||
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
|
||||
_CONV_END_VERB = r"(?:ende|beenden|beende|aus|stop|stopp|schluss)"
|
||||
_END_CONVERSATION_RE = re.compile(
|
||||
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
|
||||
rf"|\b(?:beende|schlie(?:ß|ss)e?)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _user_wants_conversation_end(text: str) -> bool:
|
||||
"""True, wenn der User in dieser Nachricht explizit die Konversation/Kette
|
||||
beenden will (deterministisch, unabhaengig vom LLM-Marker)."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_END_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||
|
||||
|
||||
def _normalize_for_fast_match(text: str) -> str:
|
||||
norm = _strip_leading_hint_blocks(text).lower()
|
||||
norm = _fold_umlauts(norm)
|
||||
@@ -1755,6 +1826,11 @@ class Agent:
|
||||
if not user_message:
|
||||
raise ValueError("Leere Nachricht")
|
||||
|
||||
# Expliziter "Konversation/Kette beenden"-Wunsch (deterministisch). Wird an
|
||||
# JEDEM Return auf converse=false angewendet — unabhaengig davon, ob ARIA
|
||||
# den [[ENDE]]-Marker setzt. Stefans fester Trigger "Konversation Ende".
|
||||
_wants_end = _user_wants_conversation_end(user_message)
|
||||
|
||||
# Events vom letzten Turn weglassen
|
||||
self._pending_events = []
|
||||
|
||||
@@ -1782,6 +1858,8 @@ class Agent:
|
||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||
if _wants_end:
|
||||
converse = False
|
||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||
return fast_reply, "fast-path", speak, converse, False
|
||||
|
||||
@@ -1801,6 +1879,8 @@ class Agent:
|
||||
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||
speak = getattr(self, "_local_turn_speak", True)
|
||||
converse = getattr(self, "_local_turn_converse", True)
|
||||
if _wants_end:
|
||||
converse = False
|
||||
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
||||
return local_reply, "local", speak, converse, False
|
||||
|
||||
@@ -2033,16 +2113,28 @@ class Agent:
|
||||
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
|
||||
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
|
||||
final_reply, awaiting_reply = _extract_await_marker(final_reply)
|
||||
# ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History,
|
||||
# damit sie nicht angezeigt/vorgelesen/gespeichert werden.
|
||||
final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply)
|
||||
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=active_project_id)
|
||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
|
||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
|
||||
# ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter-
|
||||
# schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt.
|
||||
speak = bool(getattr(self, "_claude_turn_speak", True))
|
||||
converse = bool(getattr(self, "_claude_turn_converse", True))
|
||||
if _speak_ov is not None:
|
||||
speak = _speak_ov
|
||||
if _conv_ov is not None:
|
||||
converse = _conv_ov
|
||||
# Expliziter User-Wunsch "Konversation beenden" gewinnt IMMER — Frage wird
|
||||
# noch beantwortet (speak bleibt), aber danach zurueck aufs Wake-Word.
|
||||
if _wants_end:
|
||||
converse = False
|
||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||
return (final_reply, "claude",
|
||||
bool(getattr(self, "_claude_turn_speak", True)),
|
||||
bool(getattr(self, "_claude_turn_converse", True)),
|
||||
awaiting_reply)
|
||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
|
||||
+42
-1
@@ -162,6 +162,46 @@ def build_time_section() -> str:
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_voice_flow_section() -> str:
|
||||
"""Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs.
|
||||
Frage, Kette vs. Ende) und deklariert sie per Marker — wie [[AWAIT]]. Die
|
||||
Marker werden im Brain entfernt (nie angezeigt/vorgelesen)."""
|
||||
return "\n".join([
|
||||
"## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)",
|
||||
"Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase "
|
||||
"ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf "
|
||||
"zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, "
|
||||
"nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.",
|
||||
"",
|
||||
"- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas "
|
||||
"GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird "
|
||||
"NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. "
|
||||
"Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — "
|
||||
"AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim "
|
||||
"Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs "
|
||||
"Wake-Word.",
|
||||
"- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das "
|
||||
"Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes "
|
||||
"\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb "
|
||||
"dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') "
|
||||
"oder das Gespraech klar weitergeht.",
|
||||
"- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz "
|
||||
"das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette "
|
||||
"Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, "
|
||||
"beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng "
|
||||
"`[[ENDE]]` an.",
|
||||
"",
|
||||
"Regeln:",
|
||||
"- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, "
|
||||
"ohne Marker (wird vorgelesen).",
|
||||
"- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro "
|
||||
"offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.",
|
||||
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
|
||||
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
|
||||
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
|
||||
])
|
||||
|
||||
|
||||
TYPE_HEADINGS = {
|
||||
"identity": "## Wer du bist",
|
||||
"rule": "## Sicherheitsregeln & Prinzipien",
|
||||
@@ -463,7 +503,8 @@ def build_system_prompt(
|
||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
|
||||
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()]
|
||||
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
|
||||
"", build_voice_flow_section()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
parts.append(build_skills_section(skills))
|
||||
|
||||
+19
-1
@@ -788,6 +788,18 @@
|
||||
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
||||
Status wird geladen...
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Nur meine Stimme:</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;">
|
||||
<input type="checkbox" id="diag-voice-id-enabled" onchange="sendVoiceConfig()">
|
||||
<span style="color:#E0E0F0;font-size:12px;">Speaker-ID-Prüfung aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
|
||||
AUS (Default) = alle Stimmen kommen durch (fail-open). AN = nur der enrollte
|
||||
Sprecher wird ans Brain geleitet, fremde Stimmen werden verworfen. Erst
|
||||
einschalten wenn ein Fingerprint eingelernt ist — sonst hört ARIA niemanden.
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<label style="color:#8888AA;font-size:12px;min-width:130px;">Match-Threshold:</label>
|
||||
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
||||
@@ -1899,6 +1911,11 @@
|
||||
if (slider) slider.value = msg.voiceIdThreshold;
|
||||
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
||||
}
|
||||
// Speaker-ID Gating-Schalter wiederherstellen (Default aus)
|
||||
{
|
||||
const cb = document.getElementById('diag-voice-id-enabled');
|
||||
if (cb) cb.checked = !!msg.voiceIdEnabled;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3600,13 +3617,14 @@
|
||||
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
||||
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
||||
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
|
||||
send({
|
||||
action: 'send_voice_config',
|
||||
ttsEnabled, xttsVoice, whisperModel,
|
||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||
f5ttsCfgStrength, f5ttsNfeStep,
|
||||
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
||||
voiceIdThreshold,
|
||||
voiceIdThreshold, voiceIdEnabled,
|
||||
});
|
||||
const statusEl = document.getElementById('voice-status');
|
||||
if (statusEl && xttsVoice) {
|
||||
|
||||
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
|
||||
const t = parseFloat(msg.voiceIdThreshold);
|
||||
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
||||
}
|
||||
// Speaker-ID Gating an/aus ("nur meine Stimme"). Default aus (fail-open) —
|
||||
// bewusster Schalter. voxtral/whisper-bridge lesen voiceIdEnabled aus dem
|
||||
// config-Broadcast; aus = gar keine Pruefung.
|
||||
if (msg.voiceIdEnabled !== undefined) {
|
||||
voiceConfig.voiceIdEnabled = !!msg.voiceIdEnabled;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
|
||||
@@ -69,6 +69,22 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
||||
STREAM_VOICE_FACTOR = 2.5
|
||||
STREAM_VOICE_RMS_MIN = 0.005
|
||||
STREAM_VOICE_RMS_MAX = 0.020
|
||||
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
|
||||
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
|
||||
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
|
||||
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
|
||||
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
|
||||
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
|
||||
# Broadcast (voiceIdEnabled, aus dem Diagnostic) zur Laufzeit gesetzt. Kann per ENV
|
||||
# vorbelegt werden.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
|
||||
|
||||
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
||||
@@ -168,6 +184,14 @@ class StreamSession:
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
endpoint_sent: bool = False
|
||||
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
|
||||
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
|
||||
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
|
||||
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
||||
speech_signaled: bool = False
|
||||
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
|
||||
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
|
||||
voiced_frames: int = 0
|
||||
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||
speaker_checked: bool = False
|
||||
speaker_match: Optional[bool] = None
|
||||
@@ -270,6 +294,10 @@ class SessionManager:
|
||||
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
||||
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
return
|
||||
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
sess.speaker_match = True
|
||||
@@ -361,6 +389,23 @@ class SessionManager:
|
||||
rms = self._tail_rms(sess)
|
||||
if rms >= self._voice_threshold(sess):
|
||||
sess.last_voice_at = now
|
||||
sess.voiced_frames += 1
|
||||
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
|
||||
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
|
||||
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
|
||||
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
|
||||
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
|
||||
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
|
||||
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
|
||||
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
|
||||
if (not sess.speech_signaled and self._ws is not None
|
||||
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
|
||||
sess.speech_signaled = True
|
||||
await _send(self._ws, "stt_partial", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "",
|
||||
})
|
||||
else:
|
||||
self._update_noise_floor(sess, rms)
|
||||
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
||||
@@ -371,6 +416,28 @@ class SessionManager:
|
||||
if sess.endpoint_sent:
|
||||
return
|
||||
sess.endpoint_sent = True
|
||||
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
|
||||
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
|
||||
# aus Fast-Nichts gern einen Fuellsatz ("keine Ahnung" o.ae.), der dann als
|
||||
# PHANTOM-Nachricht ans Brain geht und das Gespraech entgleisen laesst
|
||||
# (Stefans Repro: "kam Nachricht von mir, obwohl ich nichts sagte"). Leeres
|
||||
# Endpoint = no-speech → App re-armt still. Der manuelle Stop (stream_end)
|
||||
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
|
||||
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
|
||||
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
|
||||
sess.request_id[:8], sess.voiced_frames, STREAM_MIN_VOICED_FRAMES, reason)
|
||||
if self._ws is not None:
|
||||
nospeech = {"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}",
|
||||
"durationS": 0.0, "sttMs": 0}
|
||||
await _send(self._ws, "stt_endpoint", nospeech)
|
||||
await _send(self._ws, "stt_stream_done", {
|
||||
"requestId": sess.request_id,
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "", "reason": f"no_speech:{reason}"})
|
||||
self.drop(sess.request_id)
|
||||
return
|
||||
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -484,6 +551,10 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
except Exception as e:
|
||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
|
||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
||||
Ergebnis: rohes PCM."""
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
if len(raw) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(raw)
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(raw) / 2 / 16000.0)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
|
||||
@@ -86,6 +86,16 @@ STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
|
||||
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
|
||||
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
||||
|
||||
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — bewusster Schalter
|
||||
# ("nur meine Stimme"), kein Automatismus: ein schlechter Enroll darf nie die STT
|
||||
# lahmlegen. Wird per config-Broadcast (voiceIdEnabled) zur Laufzeit gesetzt.
|
||||
SPEAKER_ID_ENABLED = os.getenv("VOICE_ID_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _set_speaker_id_enabled(val: bool) -> None:
|
||||
global SPEAKER_ID_ENABLED
|
||||
SPEAKER_ID_ENABLED = bool(val)
|
||||
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
||||
@@ -467,6 +477,11 @@ class SessionManager:
|
||||
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||
sess.speaker_checked = True
|
||||
# Schalter aus (Default) → gar keine Pruefung, alles durchlassen.
|
||||
if not SPEAKER_ID_ENABLED:
|
||||
sess.speaker_match = True
|
||||
sess.speaker_similarity = 0.0
|
||||
return
|
||||
# Erste ~1.5s aus dem Buffer entnehmen (16kHz * 2 byte/sample = 32 bytes/ms)
|
||||
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
||||
@@ -975,6 +990,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "voiceIdEnabled" in payload:
|
||||
_set_speaker_id_enabled(payload.get("voiceIdEnabled"))
|
||||
logger.info("[speaker-id] Gating %s (voiceIdEnabled)",
|
||||
"AN" if SPEAKER_ID_ENABLED else "AUS")
|
||||
if "whisperDebugLog" in payload:
|
||||
global _DEBUG_LOG_TO_BRIDGE
|
||||
old = _DEBUG_LOG_TO_BRIDGE
|
||||
|
||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
||||
return _model
|
||||
|
||||
|
||||
def _decode_compressed_to_pcm(audio_bytes: bytes) -> bytes:
|
||||
"""Dekodiert komprimiertes Audio (MP4/M4A/AAC vom Android-Recorder) via ffmpeg
|
||||
(im Container vorhanden) auf rohes 16kHz mono int16 LE PCM. Input geht ueber
|
||||
eine Temp-Datei (nicht Pipe): Androids MediaRecorder legt das moov-Atom ans
|
||||
ENDE, das braucht seekbaren Input, sonst 'moov atom not found'."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tf:
|
||||
tf.write(audio_bytes)
|
||||
tmp = tf.name
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", tmp,
|
||||
"-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise ValueError(
|
||||
f"ffmpeg decode failed: {proc.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return proc.stdout
|
||||
finally:
|
||||
if tmp:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
||||
Ergebnis: rohes PCM."""
|
||||
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
# MP4/M4A/AAC-Container: Android-AAC-Recorder legt 'ftyp' bei Offset 4 an.
|
||||
if len(audio_bytes) >= 12 and audio_bytes[4:8] == b"ftyp":
|
||||
return _decode_compressed_to_pcm(audio_bytes)
|
||||
return audio_bytes
|
||||
|
||||
|
||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||
continue
|
||||
if len(raw) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
||||
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||
# Android-Recorder liefert komprimiertes MP4, dessen Byte-Laenge nichts
|
||||
# ueber die Dauer sagt (4s AAC < 32KB → faelschlich "zu kurz").
|
||||
try:
|
||||
pcm = _normalize_audio_bytes(raw)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"decode: {exc}"})
|
||||
continue
|
||||
if len(pcm) < MIN_SAMPLE_BYTES:
|
||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(pcm)} bytes PCM)"})
|
||||
continue
|
||||
try:
|
||||
emb = embed(raw)
|
||||
emb = embed(pcm)
|
||||
embeddings.append(emb)
|
||||
durations.append(len(raw) / 2 / 16000.0)
|
||||
durations.append(len(pcm) / 2 / 16000.0)
|
||||
except Exception as exc:
|
||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||
if not embeddings:
|
||||
|
||||
Reference in New Issue
Block a user