Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fa574a85 | ||
|
|
a3d7933949 | ||
|
|
626281dead | ||
|
|
49ea172617 |
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20406
|
||||
versionName "0.2.4.6"
|
||||
versionCode 20408
|
||||
versionName "0.2.4.8"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
|
||||
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
|
||||
private const val STARTUP_SUPPRESSION_MS = 600L
|
||||
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
|
||||
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
|
||||
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
|
||||
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
|
||||
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
|
||||
}
|
||||
|
||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||
private var consecutiveAboveThreshold: Int = 0
|
||||
private var lastDetectionMs: Long = 0L
|
||||
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
|
||||
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
|
||||
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
|
||||
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
|
||||
private var pcmRingPos = 0
|
||||
private var pcmRingFilled = false
|
||||
private val pcmRingLock = Any()
|
||||
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
||||
private var recordingStartedMs: Long = 0L
|
||||
|
||||
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
embBuffer.clear()
|
||||
consecutiveAboveThreshold = 0
|
||||
lastDetectionMs = 0L
|
||||
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
|
||||
// in den Bestaetigungs-Schnipsel bluten.
|
||||
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
|
||||
}
|
||||
|
||||
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
|
||||
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
|
||||
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
|
||||
private fun snapshotPreTrigger(): String? {
|
||||
val out: ByteArray
|
||||
synchronized(pcmRingLock) {
|
||||
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
|
||||
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
|
||||
if (n <= 0) return null
|
||||
out = ByteArray(n * 2)
|
||||
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
|
||||
for (i in 0 until n) {
|
||||
val s = pcmRing[idx].toInt()
|
||||
out[i * 2] = (s and 0xFF).toByte()
|
||||
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
|
||||
idx += 1
|
||||
if (idx >= PCM_RING_SAMPLES) idx = 0
|
||||
}
|
||||
}
|
||||
return try {
|
||||
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitDetected() {
|
||||
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
|
||||
return
|
||||
}
|
||||
val preTriggerB64 = snapshotPreTrigger()
|
||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||
putString("model", modelName)
|
||||
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
|
||||
}
|
||||
try {
|
||||
reactApplicationContext
|
||||
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
read += n
|
||||
}
|
||||
if (!running.get()) break
|
||||
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
|
||||
synchronized(pcmRingLock) {
|
||||
for (i in 0 until CHUNK_SAMPLES) {
|
||||
pcmRing[pcmRingPos] = buf[i]
|
||||
pcmRingPos += 1
|
||||
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
|
||||
}
|
||||
}
|
||||
try {
|
||||
processChunk(buf)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.4.6",
|
||||
"version": "0.2.4.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -114,6 +114,8 @@ import wakeWordService, {
|
||||
saveWakeThreshold,
|
||||
loadBgWakeEnabled,
|
||||
saveBgWakeEnabled,
|
||||
loadWakeConfirmEnabled,
|
||||
saveWakeConfirmEnabled,
|
||||
} from '../services/wakeword';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
import QRScanner from '../components/QRScanner';
|
||||
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
|
||||
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
|
||||
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
|
||||
const [bgWake, setBgWake] = useState<boolean>(false);
|
||||
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
|
||||
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [xttsVoice, setXttsVoice] = useState('');
|
||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
|
||||
isWakeReadySoundEnabled().then(setWakeReadySound);
|
||||
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
|
||||
loadBgWakeEnabled().then(setBgWake).catch(() => {});
|
||||
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
|
||||
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
|
||||
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
|
||||
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
|
||||
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Gegen Musik-Fehltrigger: nach „{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
|
||||
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
|
||||
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,5–1 s Extra vor dem
|
||||
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={wakeConfirm}
|
||||
onValueChange={(val) => {
|
||||
setWakeConfirm(val);
|
||||
saveWakeConfirmEnabled(val).catch(() => {});
|
||||
wakeWordService.setWakeConfirmEnabled(val);
|
||||
}}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
|
||||
|
||||
@@ -171,6 +171,39 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
|
||||
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
|
||||
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
|
||||
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
|
||||
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
|
||||
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
|
||||
if (!pcmBase64) return null;
|
||||
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let done = false;
|
||||
let unsub: (() => void) | null = null;
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
function finish(val: string | null) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { unsub && unsub(); } catch {}
|
||||
clearTimeout(timer);
|
||||
resolve(val);
|
||||
}
|
||||
try {
|
||||
unsub = rvs.onMessage((msg: any) => {
|
||||
if (msg?.type !== 'stt_transcribe_result') return;
|
||||
const p = (msg as any).payload || {};
|
||||
if (String(p.requestId || '') !== requestId) return;
|
||||
finish(typeof p.text === 'string' ? p.text : '');
|
||||
});
|
||||
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadSttEndpointMs(): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
|
||||
|
||||
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
|
||||
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
|
||||
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
|
||||
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
|
||||
// Event (neueres APK) — ohne das macht die App normal weiter.
|
||||
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
|
||||
|
||||
export async function loadWakeConfirmEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||
@@ -159,6 +180,9 @@ class WakeWordService {
|
||||
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
|
||||
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
|
||||
private bgWakeEnabled: boolean = false;
|
||||
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
|
||||
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
|
||||
private wakeConfirmEnabled: boolean = false;
|
||||
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
||||
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
||||
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
||||
@@ -241,15 +265,19 @@ class WakeWordService {
|
||||
try {
|
||||
const threshold = await loadWakeThreshold();
|
||||
this.bgWakeEnabled = await loadBgWakeEnabled();
|
||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
|
||||
this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
|
||||
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
|
||||
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
|
||||
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
||||
this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
|
||||
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||
this.onWakeDetected().catch(err =>
|
||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||
// payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
|
||||
// nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
|
||||
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
|
||||
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||
});
|
||||
}
|
||||
this.nativeReady = true;
|
||||
@@ -348,6 +376,12 @@ class WakeWordService {
|
||||
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
|
||||
}
|
||||
|
||||
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
|
||||
setWakeConfirmEnabled(enabled: boolean): void {
|
||||
this.wakeConfirmEnabled = enabled;
|
||||
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
|
||||
}
|
||||
|
||||
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
|
||||
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
|
||||
isBgWakeEnabled(): boolean {
|
||||
@@ -364,8 +398,10 @@ class WakeWordService {
|
||||
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
|
||||
* preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
|
||||
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
|
||||
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
|
||||
if (this.inBackground && !this.bgWakeEnabled) {
|
||||
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
|
||||
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
|
||||
@@ -411,6 +447,22 @@ class WakeWordService {
|
||||
// Kein erneutes setState — wir bleiben in 'conversing'.
|
||||
return;
|
||||
}
|
||||
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
|
||||
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
|
||||
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
|
||||
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
|
||||
if (this.wakeConfirmEnabled && preTriggerPcm) {
|
||||
const confirmed = await this.confirmWake(preTriggerPcm);
|
||||
if (!confirmed) {
|
||||
this.detectionInProgress = false;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.start(); } catch (e) {
|
||||
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.setState('conversing');
|
||||
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
||||
@@ -424,6 +476,34 @@ class WakeWordService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
|
||||
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
|
||||
private async confirmWake(pcm: string): Promise<boolean> {
|
||||
try {
|
||||
const audio = await import('./audio');
|
||||
const text = await audio.transcribeBlob(pcm);
|
||||
if (text === null) {
|
||||
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
|
||||
return true;
|
||||
}
|
||||
const norm = text.toLowerCase();
|
||||
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
|
||||
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
|
||||
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
|
||||
.split(/\s+/).filter(w => w.length >= 4);
|
||||
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
|
||||
const ok = kwWords.some(w => norm.includes(w));
|
||||
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
|
||||
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
|
||||
import('./logger').then(m => m.reportAppDebug('wake.confirm',
|
||||
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')} → ${ok ? 'ok' : 'reject'}`)).catch(() => {});
|
||||
return ok;
|
||||
} catch (e) {
|
||||
console.warn('[WakeWord] confirmWake err → fail-open:', e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
||||
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
||||
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
||||
|
||||
@@ -126,6 +126,11 @@ SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "t
|
||||
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
|
||||
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
|
||||
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
|
||||
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
|
||||
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
|
||||
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
|
||||
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
|
||||
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
|
||||
|
||||
_vad_state = {"model": None, "get_ts": None, "failed": False}
|
||||
|
||||
@@ -281,6 +286,7 @@ class StreamSession:
|
||||
last_growth_at: float = 0.0
|
||||
last_transcribe_at: float = 0.0
|
||||
last_voice_at: float = 0.0
|
||||
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
|
||||
noise_floor: float = 0.0
|
||||
closed: bool = False
|
||||
endpoint_sent: bool = False
|
||||
@@ -506,6 +512,26 @@ class SessionManager:
|
||||
"audioRequestId": sess.audio_request_id,
|
||||
"text": "",
|
||||
})
|
||||
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
|
||||
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
|
||||
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
|
||||
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
|
||||
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
|
||||
# Turns haben Sprache im Fenster → laufen weiter.
|
||||
if (self._buffer_ms(sess) >= sess.endpoint_ms
|
||||
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
|
||||
sess.last_speech_check_at = now
|
||||
try:
|
||||
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
|
||||
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
|
||||
segs = _speech_segments(tail)
|
||||
if segs is not None and len(segs) == 0:
|
||||
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
|
||||
sess.request_id[:8], sess.endpoint_ms)
|
||||
await self._finalize(sess, "endpoint")
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
|
||||
else:
|
||||
self._update_noise_floor(sess, rms)
|
||||
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
|
||||
@@ -700,6 +726,30 @@ async def run_loop(sessions: SessionManager) -> None:
|
||||
sessions.feed_chunk(payload)
|
||||
elif mtype == "stt_stream_end":
|
||||
sessions.end_session(payload.get("requestId", ""))
|
||||
elif mtype == "stt_transcribe_blob":
|
||||
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
|
||||
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
|
||||
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
|
||||
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
|
||||
# Musik/Rauschen → leerer Text (nicht bestaetigt).
|
||||
req_id = payload.get("requestId", "")
|
||||
try:
|
||||
pcm = base64.b64decode(payload.get("pcm", ""))
|
||||
audio = pcm_s16le_to_float32(pcm)
|
||||
segs = _speech_segments(audio)
|
||||
if segs is not None and len(segs) == 0:
|
||||
text = ""
|
||||
else:
|
||||
text = (await sessions.runner.transcribe(
|
||||
audio, payload.get("language", "de"))).strip()
|
||||
logger.info("stt_transcribe_blob (%.1fs) → %r",
|
||||
audio.size / 16000.0, text[:60])
|
||||
await _send(ws, "stt_transcribe_result",
|
||||
{"requestId": req_id, "text": text})
|
||||
except Exception as exc:
|
||||
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
|
||||
await _send(ws, "stt_transcribe_result",
|
||||
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
|
||||
elif mtype == "voice_id_status_request":
|
||||
req_id = payload.get("requestId", "")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user