Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f09e2bca3 | ||
|
|
ebe0e8065f | ||
|
|
9d2c07d8d1 | ||
|
|
9aae5af6a9 | ||
|
|
a8ff73f93d | ||
|
|
0e9adeee5c | ||
|
|
6addb2f8fe | ||
|
|
9bdfb7193e | ||
|
|
9e78d75149 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20308
|
versionCode 20400
|
||||||
versionName "0.2.3.8"
|
versionName "0.2.4.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.3.8",
|
"version": "0.2.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -1679,7 +1679,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
const wasInterrupted = interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const windowMs = await loadConvWindowMs();
|
// EIN Wert regiert: die Stille-Toleranz. Sie gilt sowohl als Pause WÄHREND
|
||||||
|
// des Redens (endpointMs) ALS AUCH als "wenn du nicht anfängst zu reden,
|
||||||
|
// ist Schluss" (noSpeechTimeoutMs). Kein separates 30s-Konversationsfenster
|
||||||
|
// mehr — Stefans Modell: sagst du nichts, greift der Stille-Wert.
|
||||||
|
const sttEndpointMs = await loadSttEndpointMs();
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -1697,8 +1701,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: wasInterrupted,
|
interrupted: wasInterrupted,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
|
// Notbremse 5 min (nicht 1 min) — der Stille-Endpoint beendet normale
|
||||||
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
|
// Turns eh sofort; der Cap darf lange Diktate nicht mitten drin kappen.
|
||||||
hardCapMs: await loadMaxRecordingMs(),
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
@@ -1756,12 +1760,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
!(m.audioRequestId === ev.audioRequestId
|
!(m.audioRequestId === ev.audioRequestId
|
||||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (=
|
||||||
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word.
|
||||||
// Sonst endConversation wie bisher.
|
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.)
|
||||||
if (wakeWordService.getState() === 'listening') {
|
if (wakeWordService.getState() === 'listening') {
|
||||||
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
|
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word');
|
||||||
startPassiveStreamingRecording();
|
wakeWordService.exitPassiveListening('timeout').catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
wakeWordService.endConversation();
|
wakeWordService.endConversation();
|
||||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||||
@@ -1791,7 +1795,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const windowMs = await loadConvWindowMs();
|
const sttEndpointMs = await loadSttEndpointMs(); // ein Wert für Pause + No-Speech
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -1809,8 +1813,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: windowMs,
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
|
// Notbremse 5 min (s.o.) — lange Diktate nicht bei 1 min abschneiden.
|
||||||
hardCapMs: await loadMaxRecordingMs(),
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
@@ -1871,16 +1875,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const passiveMs = await loadPassiveListenMs();
|
// Kein 30s-Passiv-Fenster mehr: nach ARIAs Antwort geht das Mikro auf, und
|
||||||
|
// fängst du nicht innerhalb der Stille-Toleranz an zu reden, ist Schluss →
|
||||||
|
// zurück aufs Wake-Word. Derselbe Wert wie die Pause-Toleranz beim Reden.
|
||||||
|
const sttEndpointMs = await loadSttEndpointMs();
|
||||||
const { ok } = await audioService.startStreamingRecording({
|
const { ok } = await audioService.startStreamingRecording({
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
noSpeechTimeoutMs: sttEndpointMs,
|
||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: sttEndpointMs,
|
||||||
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
// Lange Antworten nicht kappen (früher 35s → schnitt langes Reden ab).
|
||||||
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -1394,6 +1394,54 @@ def _extract_flow_markers(text: str) -> tuple:
|
|||||||
return text.strip(), speak_ov, conv_ov
|
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)))
|
||||||
|
|
||||||
|
|
||||||
|
# Gegenstueck zu _END: expliziter "Konversation OFFEN halten / fortfuehren"-Wunsch.
|
||||||
|
# Wichtig fuer BEFEHLE die den Fast-Path treffen: "spiel Spotify ab ABER Konversation
|
||||||
|
# fortfuehren" — der Fast-Path (Regex) versteht den Satz-Rest nicht und wuerde mit
|
||||||
|
# converse=false schliessen. Dieser Detektor erzwingt converse=true, auch am
|
||||||
|
# Fast-Path, egal was das Skill-Manifest sagt. Nomen+Verb in einem Satzteil, plus
|
||||||
|
# "weiter reden/sprechen" ohne Nomen.
|
||||||
|
_CONT_VERB = (r"(?:fortf[uü]hr\w*|fortsetz\w*|weiterf[uü]hr\w*|weiter\s*mach\w*|"
|
||||||
|
r"weiter\b|fort\b|offen\s+(?:halten|lassen)|nicht\s+beenden|weiterlauf\w*)")
|
||||||
|
_CONTINUE_CONVERSATION_RE = re.compile(
|
||||||
|
rf"\b{_CONV_NOUN}\b[^.!?]{{0,20}}\b{_CONT_VERB}"
|
||||||
|
rf"|\b{_CONT_VERB}[^.!?]{{0,20}}\b{_CONV_NOUN}\b"
|
||||||
|
rf"|\bweiter\s*(?:reden|sprechen|quatschen|plaudern|labern)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_wants_conversation_continue(text: str) -> bool:
|
||||||
|
"""True, wenn der User explizit weiter im Gespraech bleiben will (converse=true
|
||||||
|
erzwingen — auch bei einem Fast-Path-Befehl). [[ENDE]]/_wants_end hat Vorrang."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_fast_match(text: str) -> str:
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
norm = _strip_leading_hint_blocks(text).lower()
|
norm = _strip_leading_hint_blocks(text).lower()
|
||||||
norm = _fold_umlauts(norm)
|
norm = _fold_umlauts(norm)
|
||||||
@@ -1802,6 +1850,14 @@ class Agent:
|
|||||||
if not user_message:
|
if not user_message:
|
||||||
raise ValueError("Leere Nachricht")
|
raise ValueError("Leere Nachricht")
|
||||||
|
|
||||||
|
# Explizite Gespraechs-Steuerung vom USER (deterministisch, an JEDEM Return
|
||||||
|
# angewendet — auch am Fast-Path, den die LLM-Marker nicht erreichen):
|
||||||
|
# _wants_end → converse=false ("Konversation Ende")
|
||||||
|
# _wants_continue → converse=true ("... aber Konversation fortfuehren")
|
||||||
|
# End hat Vorrang bei Widerspruch.
|
||||||
|
_wants_end = _user_wants_conversation_end(user_message)
|
||||||
|
_wants_continue = (not _wants_end) and _user_wants_conversation_continue(user_message)
|
||||||
|
|
||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
@@ -1829,6 +1885,10 @@ class Agent:
|
|||||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||||
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
|
||||||
converse = bool(getattr(self, "_fast_path_converse", False))
|
converse = bool(getattr(self, "_fast_path_converse", False))
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||||
return fast_reply, "fast-path", speak, converse, False
|
return fast_reply, "fast-path", speak, converse, False
|
||||||
|
|
||||||
@@ -1848,6 +1908,10 @@ class Agent:
|
|||||||
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||||
speak = getattr(self, "_local_turn_speak", True)
|
speak = getattr(self, "_local_turn_speak", True)
|
||||||
converse = getattr(self, "_local_turn_converse", True)
|
converse = getattr(self, "_local_turn_converse", True)
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
|
||||||
return local_reply, "local", speak, converse, False
|
return local_reply, "local", speak, converse, False
|
||||||
|
|
||||||
@@ -2096,6 +2160,12 @@ class Agent:
|
|||||||
speak = _speak_ov
|
speak = _speak_ov
|
||||||
if _conv_ov is not None:
|
if _conv_ov is not None:
|
||||||
converse = _conv_ov
|
converse = _conv_ov
|
||||||
|
# Explizite User-Woerter gewinnen ueber Marker/Manifest: "Konversation
|
||||||
|
# beenden" → zu; "... fortfuehren" → offen halten. End hat Vorrang.
|
||||||
|
if _wants_end:
|
||||||
|
converse = False
|
||||||
|
elif _wants_continue:
|
||||||
|
converse = True
|
||||||
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
|
||||||
return (final_reply, "claude", speak, converse, awaiting_reply)
|
return (final_reply, "claude", speak, converse, awaiting_reply)
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -788,6 +788,18 @@
|
|||||||
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
<div id="voice-id-status" style="font-size:13px;color:#E0E0F0;margin-bottom:10px;">
|
||||||
Status wird geladen...
|
Status wird geladen...
|
||||||
</div>
|
</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;">
|
<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>
|
<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"
|
<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 (slider) slider.value = msg.voiceIdThreshold;
|
||||||
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3600,13 +3617,14 @@
|
|||||||
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
const huggingfaceToken = document.getElementById('diag-flux-hf-token')?.value;
|
||||||
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
const voiceIdThresholdRaw = document.getElementById('diag-voice-id-threshold')?.value;
|
||||||
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
const voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||||
|
const voiceIdEnabled = document.getElementById('diag-voice-id-enabled')?.checked;
|
||||||
send({
|
send({
|
||||||
action: 'send_voice_config',
|
action: 'send_voice_config',
|
||||||
ttsEnabled, xttsVoice, whisperModel,
|
ttsEnabled, xttsVoice, whisperModel,
|
||||||
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
f5ttsModel, f5ttsCkptFile, f5ttsVocabFile,
|
||||||
f5ttsCfgStrength, f5ttsNfeStep,
|
f5ttsCfgStrength, f5ttsNfeStep,
|
||||||
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
fluxDefaultModel, fluxKeywordRaw, fluxKeywordSwitch, huggingfaceToken,
|
||||||
voiceIdThreshold,
|
voiceIdThreshold, voiceIdEnabled,
|
||||||
});
|
});
|
||||||
const statusEl = document.getElementById('voice-status');
|
const statusEl = document.getElementById('voice-status');
|
||||||
if (statusEl && xttsVoice) {
|
if (statusEl && xttsVoice) {
|
||||||
|
|||||||
@@ -2681,6 +2681,12 @@ wss.on("connection", (ws) => {
|
|||||||
const t = parseFloat(msg.voiceIdThreshold);
|
const t = parseFloat(msg.voiceIdThreshold);
|
||||||
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
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 {
|
try {
|
||||||
fs.mkdirSync("/shared/config", { recursive: true });
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
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_FACTOR = 2.5
|
||||||
STREAM_VOICE_RMS_MIN = 0.005
|
STREAM_VOICE_RMS_MIN = 0.005
|
||||||
STREAM_VOICE_RMS_MAX = 0.020
|
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:
|
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
|
||||||
@@ -168,6 +184,14 @@ class StreamSession:
|
|||||||
noise_floor: float = 0.0
|
noise_floor: float = 0.0
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
endpoint_sent: 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-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||||
speaker_checked: bool = False
|
speaker_checked: bool = False
|
||||||
speaker_match: Optional[bool] = None
|
speaker_match: Optional[bool] = None
|
||||||
@@ -270,6 +294,10 @@ class SessionManager:
|
|||||||
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
"""Einmalig: erste ~1.5s → Embedding → Vergleich mit Fingerprint.
|
||||||
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
Ohne Fingerprint fail-open (match=True). Bei Mismatch: Session beenden."""
|
||||||
sess.speaker_checked = True
|
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])
|
head = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||||
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
if len(head) < speaker_id.MIN_SAMPLE_BYTES:
|
||||||
sess.speaker_match = True
|
sess.speaker_match = True
|
||||||
@@ -361,6 +389,23 @@ class SessionManager:
|
|||||||
rms = self._tail_rms(sess)
|
rms = self._tail_rms(sess)
|
||||||
if rms >= self._voice_threshold(sess):
|
if rms >= self._voice_threshold(sess):
|
||||||
sess.last_voice_at = now
|
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:
|
else:
|
||||||
self._update_noise_floor(sess, rms)
|
self._update_noise_floor(sess, rms)
|
||||||
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
# Endpoint: hat der User schon gesprochen UND ist es seit endpoint_ms still?
|
||||||
@@ -371,6 +416,28 @@ class SessionManager:
|
|||||||
if sess.endpoint_sent:
|
if sess.endpoint_sent:
|
||||||
return
|
return
|
||||||
sess.endpoint_sent = True
|
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))
|
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -484,6 +551,10 @@ async def run_loop(sessions: SessionManager) -> None:
|
|||||||
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
|
|||||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
|||||||
return _model
|
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:
|
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||||
Ergebnis: rohes PCM."""
|
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||||
if (len(audio_bytes) >= 44
|
if (len(audio_bytes) >= 44
|
||||||
and audio_bytes[:4] == b"RIFF"
|
and audio_bytes[:4] == b"RIFF"
|
||||||
and audio_bytes[8:12] == b"WAVE"):
|
and audio_bytes[8:12] == b"WAVE"):
|
||||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
|||||||
if sw != 2:
|
if sw != 2:
|
||||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||||
return wav.readframes(wav.getnframes())
|
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
|
return audio_bytes
|
||||||
|
|
||||||
|
|
||||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||||
continue
|
continue
|
||||||
if len(raw) < MIN_SAMPLE_BYTES:
|
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
# 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
|
continue
|
||||||
try:
|
try:
|
||||||
emb = embed(raw)
|
emb = embed(pcm)
|
||||||
embeddings.append(emb)
|
embeddings.append(emb)
|
||||||
durations.append(len(raw) / 2 / 16000.0)
|
durations.append(len(pcm) / 2 / 16000.0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||||
if not embeddings:
|
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_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_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
|
||||||
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
|
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,
|
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
|
||||||
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
|
||||||
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
|
# 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
|
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||||
Session sofort beendet mit synthetischem stt_endpoint."""
|
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||||
sess.speaker_checked = True
|
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)
|
# 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])
|
head_bytes = bytes(sess.pcm_buffer[: STREAM_SPEAKER_CHECK_MS * 32])
|
||||||
if len(head_bytes) < speaker_id.MIN_SAMPLE_BYTES:
|
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)
|
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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:
|
if "whisperDebugLog" in payload:
|
||||||
global _DEBUG_LOG_TO_BRIDGE
|
global _DEBUG_LOG_TO_BRIDGE
|
||||||
old = _DEBUG_LOG_TO_BRIDGE
|
old = _DEBUG_LOG_TO_BRIDGE
|
||||||
|
|||||||
@@ -61,10 +61,40 @@ def _ensure_loaded():
|
|||||||
return _model
|
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:
|
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
"""Akzeptiert rohes 16kHz int16 LE PCM, eine WAV-Datei (RIFF/WAVE) ODER einen
|
||||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
komprimierten MP4/M4A/AAC-Container (Android-Recorder). WAV → Header strippen +
|
||||||
Ergebnis: rohes PCM."""
|
Format validieren; MP4/AAC → via ffmpeg dekodieren. Ergebnis: rohes PCM."""
|
||||||
if (len(audio_bytes) >= 44
|
if (len(audio_bytes) >= 44
|
||||||
and audio_bytes[:4] == b"RIFF"
|
and audio_bytes[:4] == b"RIFF"
|
||||||
and audio_bytes[8:12] == b"WAVE"):
|
and audio_bytes[8:12] == b"WAVE"):
|
||||||
@@ -81,6 +111,9 @@ def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
|||||||
if sw != 2:
|
if sw != 2:
|
||||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||||
return wav.readframes(wav.getnframes())
|
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
|
return audio_bytes
|
||||||
|
|
||||||
|
|
||||||
@@ -212,13 +245,21 @@ def enroll_from_samples(samples_b64: list[str]) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
rejected.append({"index": idx, "reason": f"base64: {exc}"})
|
||||||
continue
|
continue
|
||||||
if len(raw) < MIN_SAMPLE_BYTES:
|
# Erst dekodieren (WAV/MP4/AAC → rohes PCM), DANN Laenge pruefen: der
|
||||||
rejected.append({"index": idx, "reason": f"zu kurz ({len(raw)} bytes)"})
|
# 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
|
continue
|
||||||
try:
|
try:
|
||||||
emb = embed(raw)
|
emb = embed(pcm)
|
||||||
embeddings.append(emb)
|
embeddings.append(emb)
|
||||||
durations.append(len(raw) / 2 / 16000.0)
|
durations.append(len(pcm) / 2 / 16000.0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||||
if not embeddings:
|
if not embeddings:
|
||||||
|
|||||||
Reference in New Issue
Block a user