Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f714cfc336 | |||
| a0dc0cf20e | |||
| ac53af5c24 | |||
| e3fe27f736 | |||
| 6e19adab87 | |||
| 095a10aaf0 | |||
| e3a224478d | |||
| 61c9183033 | |||
| e04bbef361 | |||
| e82e07e3a2 | |||
| 886b4409d2 | |||
| bcea49365d | |||
| 05eb7ed144 | |||
| ddfc4261e5 | |||
| 20e623dc37 | |||
| 6464dbe28c | |||
| c38e1b197b | |||
| 7a05e8233c | |||
| 73d5bbd7be | |||
| da38cdfefa | |||
| 9c0c13d1f6 | |||
| ba26fa5880 | |||
| 027ba2896d | |||
| 86f20d3b64 | |||
| 78211f09ce | |||
| b2edee9adb | |||
| bb13477ef9 | |||
| 710e7c88d8 | |||
| b6ee5552f0 | |||
| 570eb031e0 | |||
| e9615d987e | |||
| 5e95eacd11 | |||
| ece08f0f2f | |||
| 31fd0d7f7a | |||
| 263835ad74 | |||
| ab7e9801ee | |||
| 3d001a1d03 |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ssh root@172.0.2.33 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 10802
|
versionCode 10903
|
||||||
versionName "0.1.8.2"
|
versionName "0.1.9.3"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
|
import android.media.AudioRecordingConfiguration
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.audiofx.AcousticEchoCanceler
|
import android.media.audiofx.AcousticEchoCanceler
|
||||||
import android.media.audiofx.AutomaticGainControl
|
import android.media.audiofx.AutomaticGainControl
|
||||||
import android.media.audiofx.NoiseSuppressor
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -49,6 +54,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private const val EMBEDDING_DIM = 96
|
private const val EMBEDDING_DIM = 96
|
||||||
private const val MEL_BINS = 32
|
private const val MEL_BINS = 32
|
||||||
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
|
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
|
||||||
|
// Nach record.startRecording() erzeugt das Mikro fuer ~1s einen Spin-up-Spike
|
||||||
|
// (DC-Offset, AGC-Settling) der vom Wake-Word-Klassifikator faelschlich als
|
||||||
|
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
|
||||||
|
// 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 = 1500L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||||
@@ -95,6 +106,22 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||||
private var consecutiveAboveThreshold: Int = 0
|
private var consecutiveAboveThreshold: Int = 0
|
||||||
private var lastDetectionMs: Long = 0L
|
private var lastDetectionMs: Long = 0L
|
||||||
|
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
||||||
|
private var recordingStartedMs: Long = 0L
|
||||||
|
|
||||||
|
// Audio-Sharing mit anderen Apps:
|
||||||
|
// Wenn z.B. WhatsApp eine Sprachnachricht aufnimmt, dann hält ARIAs
|
||||||
|
// VOICE_COMMUNICATION-Lock zwar das System nicht offiziell exklusiv,
|
||||||
|
// aber die Foreground-App bekommt nur Stille — die WhatsApp-Aufnahme
|
||||||
|
// ist tonlos. Loesung: AudioRecordingCallback hoeren, sobald eine andere
|
||||||
|
// App das Mic anfordert → unsere AudioRecord freigeben (externallyPaused=true).
|
||||||
|
// Wenn die andere App fertig ist → reaktivieren. Wakeword pausiert solange.
|
||||||
|
private var recordingCallback: AudioManager.AudioRecordingCallback? = null
|
||||||
|
@Volatile private var externallyPaused: Boolean = false
|
||||||
|
private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
|
||||||
|
private val audioManager: AudioManager by lazy {
|
||||||
|
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
||||||
@@ -159,53 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val minBuf = AudioRecord.getMinBufferSize(
|
acquireAndStartRecording()
|
||||||
SAMPLE_RATE,
|
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
|
||||||
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
|
||||||
|
|
||||||
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
|
|
||||||
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
|
|
||||||
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
|
|
||||||
// zur TTS-Wiedergabe gelauscht wird.
|
|
||||||
val record = AudioRecord(
|
|
||||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
|
||||||
SAMPLE_RATE,
|
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
|
||||||
minBuf,
|
|
||||||
)
|
|
||||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
|
||||||
record.release()
|
|
||||||
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioRecord = record
|
|
||||||
|
|
||||||
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
|
|
||||||
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
|
|
||||||
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
|
|
||||||
try {
|
|
||||||
if (AcousticEchoCanceler.isAvailable()) {
|
|
||||||
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
|
||||||
try {
|
|
||||||
if (NoiseSuppressor.isAvailable()) {
|
|
||||||
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
|
||||||
try {
|
|
||||||
if (AutomaticGainControl.isAvailable()) {
|
|
||||||
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
|
||||||
|
|
||||||
resetInferenceState()
|
|
||||||
running.set(true)
|
|
||||||
record.startRecording()
|
|
||||||
|
|
||||||
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
||||||
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
||||||
@@ -222,10 +203,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
// AudioRecordingCallback registrieren: andere Apps (WhatsApp-
|
||||||
isDaemon = true
|
// Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
|
||||||
start()
|
// es vorruebergehend frei statt sie ins Leere recorden zu lassen.
|
||||||
}
|
registerRecordingCallback()
|
||||||
|
|
||||||
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
@@ -238,6 +219,75 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reine AudioRecord + Effects + Capture-Thread-Acquisition. Wirft bei
|
||||||
|
* Fehler — Caller faengt + reportet. Kein WakeLock, keine Callbacks. */
|
||||||
|
private fun acquireAndStartRecording() {
|
||||||
|
val minBuf = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
||||||
|
|
||||||
|
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
|
||||||
|
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
|
||||||
|
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
|
||||||
|
// zur TTS-Wiedergabe gelauscht wird.
|
||||||
|
val record = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
minBuf,
|
||||||
|
)
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
record.release()
|
||||||
|
throw IllegalStateException("AudioRecord nicht initialisiert (Mikro belegt?)")
|
||||||
|
}
|
||||||
|
audioRecord = record
|
||||||
|
|
||||||
|
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
|
||||||
|
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
|
||||||
|
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
|
||||||
|
try {
|
||||||
|
if (AcousticEchoCanceler.isAvailable()) {
|
||||||
|
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (NoiseSuppressor.isAvailable()) {
|
||||||
|
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (AutomaticGainControl.isAvailable()) {
|
||||||
|
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
||||||
|
|
||||||
|
resetInferenceState()
|
||||||
|
running.set(true)
|
||||||
|
record.startRecording()
|
||||||
|
recordingStartedMs = System.currentTimeMillis()
|
||||||
|
|
||||||
|
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine AudioRecord + Effects + Capture-Thread-Release. Sicher (catch all).
|
||||||
|
* Kein WakeLock-Release, kein Unregistrieren der Callbacks. */
|
||||||
|
private fun stopAndReleaseRecording() {
|
||||||
|
running.set(false)
|
||||||
|
try { captureThread?.join(1500) } catch (_: InterruptedException) {}
|
||||||
|
captureThread = null
|
||||||
|
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||||
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
|
audioRecord = null
|
||||||
|
releaseAudioEffects()
|
||||||
|
}
|
||||||
|
|
||||||
private fun releaseAudioEffects() {
|
private fun releaseAudioEffects() {
|
||||||
try { aec?.release() } catch (_: Exception) {}
|
try { aec?.release() } catch (_: Exception) {}
|
||||||
try { ns?.release() } catch (_: Exception) {}
|
try { ns?.release() } catch (_: Exception) {}
|
||||||
@@ -247,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun stop(promise: Promise) {
|
fun stop(promise: Promise) {
|
||||||
running.set(false)
|
unregisterRecordingCallback()
|
||||||
try {
|
externallyPaused = false
|
||||||
captureThread?.join(1500)
|
stopAndReleaseRecording()
|
||||||
} catch (_: InterruptedException) {}
|
|
||||||
captureThread = null
|
|
||||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
|
||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
|
||||||
audioRecord = null
|
|
||||||
releaseAudioEffects()
|
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Lauschen gestoppt")
|
Log.i(TAG, "Lauschen gestoppt")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
@@ -263,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun dispose(promise: Promise) {
|
fun dispose(promise: Promise) {
|
||||||
running.set(false)
|
unregisterRecordingCallback()
|
||||||
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
|
externallyPaused = false
|
||||||
captureThread = null
|
stopAndReleaseRecording()
|
||||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
|
||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
|
||||||
audioRecord = null
|
|
||||||
releaseAudioEffects()
|
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
disposeSessions()
|
disposeSessions()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── External-Mic-Sharing (AudioRecordingCallback) ──────────────────────
|
||||||
|
//
|
||||||
|
// Wenn eine andere App das Mic anfordert (WhatsApp-Voicenote, Telefonie,
|
||||||
|
// Sprach-Suche im Browser etc.), kriegt die zwar formal Audio — aber
|
||||||
|
// unsere VOICE_COMMUNICATION-Pipeline blockiert die naively neue Aufnahme
|
||||||
|
// mit Stille (Android-Audio-Policy). Loesung: AudioRecordingCallback
|
||||||
|
// beobachten, andere Recorder-Sessions detecten, und unsere Pipeline
|
||||||
|
// temporaer freigeben. Sobald die andere App fertig ist → reaktivieren.
|
||||||
|
//
|
||||||
|
// Effekt: Wake-Word funktioniert solange nicht — fairer Kompromiss.
|
||||||
|
|
||||||
|
private fun registerRecordingCallback() {
|
||||||
|
if (recordingCallback != null) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
Log.i(TAG, "AudioRecordingCallback nicht verfuegbar (API < 24) — Mic-Sharing inaktiv")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cb = object : AudioManager.AudioRecordingCallback() {
|
||||||
|
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>?) {
|
||||||
|
handleRecordingConfigChange(configs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
audioManager.registerAudioRecordingCallback(cb, mainHandler)
|
||||||
|
recordingCallback = cb
|
||||||
|
Log.i(TAG, "AudioRecordingCallback registriert — beobachtet andere Mic-User")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "registerAudioRecordingCallback failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterRecordingCallback() {
|
||||||
|
val cb = recordingCallback ?: return
|
||||||
|
try { audioManager.unregisterAudioRecordingCallback(cb) } catch (_: Exception) {}
|
||||||
|
recordingCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRecordingConfigChange(configs: MutableList<AudioRecordingConfiguration>?) {
|
||||||
|
if (configs == null) return
|
||||||
|
// Unsere eigene Session anhand der audioSessionId filtern. Wenn wir
|
||||||
|
// gerade keinen AudioRecord halten (externallyPaused), ist alles
|
||||||
|
// andere "extern" — dann zaehlt jeder Eintrag.
|
||||||
|
val ourSessionId = audioRecord?.audioSessionId
|
||||||
|
val externalActive = configs.any {
|
||||||
|
ourSessionId == null || it.clientAudioSessionId != ourSessionId
|
||||||
|
}
|
||||||
|
if (running.get() && externalActive) {
|
||||||
|
Log.i(TAG, "Andere App nutzt Mic — Wake-Word pausiert (configs=${configs.size})")
|
||||||
|
externallyPaused = true
|
||||||
|
stopAndReleaseRecording()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (externallyPaused && !externalActive) {
|
||||||
|
Log.i(TAG, "Mic wieder frei — Wake-Word reaktiviert in 300ms")
|
||||||
|
// Kurze Pause: der "andere" hat eben losgelassen, Audio-Stack braucht
|
||||||
|
// ein paar ms bis VOICE_COMMUNICATION wieder sauber initialisiert.
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (!externallyPaused) return@postDelayed // schon resumed
|
||||||
|
// Sicherheitscheck: wenn inzwischen jemand wieder rein ist
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val cur = audioManager.activeRecordingConfigurations
|
||||||
|
if (cur != null && cur.isNotEmpty()) {
|
||||||
|
Log.i(TAG, "Resume verworfen — anderer Mic-User noch da (${cur.size})")
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externallyPaused = false
|
||||||
|
try {
|
||||||
|
acquireAndStartRecording()
|
||||||
|
Log.i(TAG, "Wake-Word nach External-Pause reaktiviert")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Resume nach External-Pause failed: ${e.message}")
|
||||||
|
// bleiben unten — falls anderer App das Mic doch wieder
|
||||||
|
// freigibt, feuert der Callback erneut.
|
||||||
|
externallyPaused = true
|
||||||
|
}
|
||||||
|
}, 300L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
private fun releaseWakeLock() {
|
||||||
try {
|
try {
|
||||||
wakeLock?.takeIf { it.isHeld }?.release()
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
@@ -313,6 +433,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun emitDetected() {
|
private fun emitDetected() {
|
||||||
|
val sinceStart = System.currentTimeMillis() - recordingStartedMs
|
||||||
|
if (sinceStart in 0 until STARTUP_SUPPRESSION_MS) {
|
||||||
|
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
|
||||||
|
return
|
||||||
|
}
|
||||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||||
putString("model", modelName)
|
putString("model", modelName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.8.2",
|
"version": "0.1.9.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import brainApi, { Trigger } from '../services/brainApi';
|
import brainApi, { Trigger } from '../services/brainApi';
|
||||||
|
import rvs from '../services/rvs';
|
||||||
|
|
||||||
const COL_ACTIVE = '#34C759';
|
const COL_ACTIVE = '#34C759';
|
||||||
const COL_INACTIVE = '#555570';
|
const COL_INACTIVE = '#555570';
|
||||||
@@ -65,6 +66,17 @@ export const TriggerBrowser: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// Auto-Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail-
|
||||||
|
// Fehler aus brainApi ewig an obwohl die Verbindung schon wieder da ist.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = rvs.onStateChange((state) => {
|
||||||
|
if (state === 'connected') {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const visible = items.filter(t => {
|
const visible = items.filter(t => {
|
||||||
if (filter === 'active') return t.active;
|
if (filter === 'active') return t.active;
|
||||||
if (filter === 'inactive') return !t.active;
|
if (filter === 'inactive') return !t.active;
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf
|
* VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante).
|
||||||
*
|
*
|
||||||
* Zwei Modi:
|
* Push-to-Talk gibt's nicht mehr. Tap startet Streaming-Aufnahme an die
|
||||||
* 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden
|
* Whisper-Bridge. Tap nochmal sendet stt_stream_end → Whisper liefert den
|
||||||
* 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille
|
* finalen Text → aria-bridge forwardet direkt an Brain. Keine dB/VAD-
|
||||||
* (auch genutzt fuer Wake-Word-getriggerte Aufnahme)
|
* Stille-Erkennung mehr — Whisper hoert auf semantische Stille (kein
|
||||||
|
* neuer Text mehr).
|
||||||
*
|
*
|
||||||
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
|
* Diese Komponente ist absichtlich "dumm": sie kapselt nur den
|
||||||
|
* Tap-Lifecycle + die Animation. Recording-Optionen (voice/speed/
|
||||||
|
* location/interrupted) baut ChatScreen, die User-Bubble ebenfalls.
|
||||||
|
*
|
||||||
|
* Visuelles Feedback: pulsierende Animation + Dauer + dB-Pegel via
|
||||||
|
* audioService.onMeterUpdate (das macht audio.ts noch fuer alte Records;
|
||||||
|
* neu kommt der Pegel via NativeEventEmitter (PcmStreamMeter) — folgt).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
@@ -17,25 +24,28 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Easing,
|
Easing,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Pressable,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import audioService, { RecordingResult } from '../services/audio';
|
import audioService, { RecordingState } from '../services/audio';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
|
|
||||||
interface VoiceButtonProps {
|
interface VoiceButtonProps {
|
||||||
/** Wird aufgerufen wenn die Aufnahme fertig ist */
|
/** User hat getippt — ChatScreen soll Bubble bauen + startStreamingRecording.
|
||||||
onRecordingComplete: (result: RecordingResult) => void;
|
* Returns true wenn die Aufnahme tatsaechlich gestartet ist. */
|
||||||
|
onTapStart: () => Promise<boolean>;
|
||||||
|
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
|
||||||
|
onTapStop: () => Promise<void>;
|
||||||
/** Button deaktivieren */
|
/** Button deaktivieren */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Wake-Word-Modus aktiv (zeigt Indikator) */
|
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
|
||||||
wakeWordActive?: boolean;
|
wakeWordActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Komponente ---
|
// --- Komponente ---
|
||||||
|
|
||||||
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||||
onRecordingComplete,
|
onTapStart,
|
||||||
|
onTapStop,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
wakeWordActive = false,
|
wakeWordActive = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -45,6 +55,21 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// State via audioService.onStateChange spiegeln — der Service ist die
|
||||||
|
// Quelle der Wahrheit (Streaming-Session, Wake-Word-Multi-Turn, etc.
|
||||||
|
// koennen den Recording-State von extern aendern). isStreamingRecording
|
||||||
|
// ist auch true wenn die Wake-Word-Konversation gerade aufzeichnet —
|
||||||
|
// dann zeigt der Button "stop"-Symbol, und Tap stoppt die laufende
|
||||||
|
// Aufnahme (egal ob via Wake-Word oder Knopf gestartet).
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = audioService.onStateChange((next: RecordingState) => {
|
||||||
|
setIsRecording(next === 'recording');
|
||||||
|
});
|
||||||
|
// Initial-State synchronisieren
|
||||||
|
setIsRecording(audioService.getRecordingState() === 'recording');
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Puls-Animation starten/stoppen
|
// Puls-Animation starten/stoppen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
@@ -71,14 +96,13 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isRecording, pulseAnim]);
|
}, [isRecording, pulseAnim]);
|
||||||
|
|
||||||
// Aufnahmedauer zaehlen + Metering
|
// Aufnahmedauer zaehlen + Metering (Pegel-Bar)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
setDurationMs(0);
|
setDurationMs(0);
|
||||||
durationTimer.current = setInterval(() => {
|
durationTimer.current = setInterval(() => {
|
||||||
setDurationMs(prev => prev + 100);
|
setDurationMs(prev => prev + 100);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
|
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
|
||||||
return () => {
|
return () => {
|
||||||
unsubMeter();
|
unsubMeter();
|
||||||
@@ -89,74 +113,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
clearInterval(durationTimer.current);
|
clearInterval(durationTimer.current);
|
||||||
durationTimer.current = null;
|
durationTimer.current = null;
|
||||||
}
|
}
|
||||||
|
setMeterDb(-160);
|
||||||
}
|
}
|
||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
// VAD Silence Callback — Auto-Stop.
|
// Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop.
|
||||||
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
|
|
||||||
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
|
|
||||||
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
|
|
||||||
// feuert (silenceFired-Latch).
|
|
||||||
const onCompleteRef = useRef(onRecordingComplete);
|
|
||||||
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
|
||||||
if (audioService.getRecordingState() !== 'recording') return;
|
|
||||||
const result = await audioService.stopRecording();
|
|
||||||
setIsRecording(false);
|
|
||||||
if (result && result.durationMs > 500) {
|
|
||||||
onCompleteRef.current(result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return unsubSilence;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-Start fuer Wake Word (extern getriggert)
|
|
||||||
const startAutoRecording = useCallback(async () => {
|
|
||||||
if (disabled || isRecording) return;
|
|
||||||
const started = await audioService.startRecording(true); // autoStop = true
|
|
||||||
if (started) {
|
|
||||||
setIsRecording(true);
|
|
||||||
}
|
|
||||||
}, [disabled, isRecording]);
|
|
||||||
|
|
||||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
|
|
||||||
// Guard gegen Doppel-Tap während asyncer Start/Stop.
|
|
||||||
const tapBusy = useRef(false);
|
const tapBusy = useRef(false);
|
||||||
const handleTap = async () => {
|
const handleTap = useCallback(async () => {
|
||||||
if (disabled || tapBusy.current) return;
|
if (disabled || tapBusy.current) return;
|
||||||
tapBusy.current = true;
|
tapBusy.current = true;
|
||||||
try {
|
try {
|
||||||
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
|
// Service-State fragen statt React-State (Closure koennte stale sein)
|
||||||
const svcState = audioService.getRecordingState();
|
const svcState = audioService.getRecordingState();
|
||||||
if (svcState === 'recording') {
|
if (svcState === 'recording') {
|
||||||
// Aufnahme manuell stoppen
|
await onTapStop();
|
||||||
const result = await audioService.stopRecording();
|
|
||||||
setIsRecording(false);
|
|
||||||
if (result && result.durationMs > 300) {
|
|
||||||
onRecordingComplete(result);
|
|
||||||
}
|
|
||||||
} else if (svcState === 'idle') {
|
} else if (svcState === 'idle') {
|
||||||
// Aufnahme mit Auto-Stop starten
|
await onTapStart();
|
||||||
const started = await audioService.startRecording(true);
|
|
||||||
if (started) {
|
|
||||||
setIsRecording(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// svcState === 'processing': Stopp in progress — nichts tun, User
|
// 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen
|
||||||
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
|
|
||||||
// kurz damit der User's UI-Feedback synchron bleibt.
|
|
||||||
} finally {
|
} finally {
|
||||||
tapBusy.current = false;
|
tapBusy.current = false;
|
||||||
}
|
}
|
||||||
};
|
}, [disabled, onTapStart, onTapStop]);
|
||||||
|
|
||||||
// Expose startAutoRecording via ref fuer Wake Word
|
|
||||||
React.useImperativeHandle(
|
|
||||||
React.createRef(),
|
|
||||||
() => ({ startAutoRecording }),
|
|
||||||
[startAutoRecording],
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatDuration = (ms: number): string => {
|
const formatDuration = (ms: number): string => {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
@@ -164,7 +142,11 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
return `${seconds}.${tenths}s`;
|
return `${seconds}.${tenths}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Meter-Visualisierung (0-1 Skala)
|
// Meter-Visualisierung (-60..0 dB → 0..1). Bei Streaming-Mode liefert
|
||||||
|
// audio.ts (noch) keinen Pegel, also bleibt der Balken leer — wird in
|
||||||
|
// einem Folge-Commit nachgerueckt (PcmStreamRecorder-Module muss dafuer
|
||||||
|
// einen RMS-Wert mit-emitten). Tut der Streaming-Funktion keinen Abbruch,
|
||||||
|
// ist reines UI-Beiwerk.
|
||||||
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
|
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,9 +180,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose startAutoRecording fuer externe Aufrufe (Wake Word)
|
|
||||||
export type VoiceButtonHandle = { startAutoRecording: () => Promise<void> };
|
|
||||||
|
|
||||||
// --- Styles ---
|
// --- Styles ---
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* Voice-ID Enrollment + Status — App-seitig.
|
||||||
|
*
|
||||||
|
* User nimmt 5-7 Samples (je 4s) seiner Stimme auf, App schickt sie an
|
||||||
|
* die whisper-bridge via RVS (voice_id_enroll_request). Bridge berechnet
|
||||||
|
* SpeechBrain-ECAPA-Embeddings, mittelt sie zu einem Fingerprint, speichert
|
||||||
|
* /voice-id/fingerprint.json.
|
||||||
|
*
|
||||||
|
* Verwendung: in SettingsScreen für Section 'voice_id' eingebunden.
|
||||||
|
* Holt Status bei Mount + nach jedem Enroll/Delete neu ab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
ToastAndroid,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import audioService from '../services/audio';
|
||||||
|
import rvs from '../services/rvs';
|
||||||
|
|
||||||
|
const SAMPLE_DURATION_MS = 4000; // Pro Sample 4s aufnehmen
|
||||||
|
const SAMPLES_REQUIRED = 5; // Mindest-Sampleanzahl fuer Save
|
||||||
|
|
||||||
|
type Sample = {
|
||||||
|
base64: string;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { state: 'loading' }
|
||||||
|
| { state: 'unenrolled' }
|
||||||
|
| { state: 'enrolled'; sampleCount: number; durations: number[]; updatedAt: number; dim: number }
|
||||||
|
| { state: 'error'; message: string };
|
||||||
|
|
||||||
|
function _newReqId(prefix: string): string {
|
||||||
|
return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceIdEnrollment: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<Status>({ state: 'loading' });
|
||||||
|
const [samples, setSamples] = useState<Sample[]>([]);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [recordCountdown, setRecordCountdown] = useState(0);
|
||||||
|
const [enrollPending, setEnrollPending] = useState(false);
|
||||||
|
const [pendingReqId, setPendingReqId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Status laden
|
||||||
|
const refreshStatus = useCallback(() => {
|
||||||
|
setStatus({ state: 'loading' });
|
||||||
|
const reqId = _newReqId('vid');
|
||||||
|
setPendingReqId(reqId);
|
||||||
|
rvs.send('voice_id_status_request' as any, { requestId: reqId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStatus();
|
||||||
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
// RVS-Antworten verarbeiten
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = rvs.onMessage((msg: any) => {
|
||||||
|
if (!msg) return;
|
||||||
|
const p = msg.payload || {};
|
||||||
|
if (msg.type === 'voice_id_status_response') {
|
||||||
|
if (p.ok === false) {
|
||||||
|
setStatus({ state: 'error', message: p.error || 'Whisper-Bridge nicht erreichbar' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.enrolled) {
|
||||||
|
setStatus({
|
||||||
|
state: 'enrolled',
|
||||||
|
sampleCount: p.sample_count || 0,
|
||||||
|
durations: p.sample_durations_s || [],
|
||||||
|
updatedAt: p.updated_at || 0,
|
||||||
|
dim: p.embedding_dim || 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({ state: 'unenrolled' });
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'voice_id_enroll_response') {
|
||||||
|
setEnrollPending(false);
|
||||||
|
if (p.ok === false) {
|
||||||
|
Alert.alert('Enrollment fehlgeschlagen', p.error || 'Unbekannter Fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rejected = (p.rejected || []).length;
|
||||||
|
ToastAndroid.show(
|
||||||
|
`✓ Stimme gespeichert (${p.sample_count} Samples${rejected ? `, ${rejected} verworfen` : ''})`,
|
||||||
|
ToastAndroid.LONG,
|
||||||
|
);
|
||||||
|
setSamples([]);
|
||||||
|
refreshStatus();
|
||||||
|
} else if (msg.type === 'voice_id_delete_response') {
|
||||||
|
ToastAndroid.show(p.removed ? '✓ Stimme gelöscht' : 'Es war keine gespeichert', ToastAndroid.SHORT);
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
// Ein Sample aufnehmen — fest 4s, dann auto-stop
|
||||||
|
const recordSample = useCallback(async () => {
|
||||||
|
if (recording || enrollPending) return;
|
||||||
|
setRecording(true);
|
||||||
|
setRecordCountdown(SAMPLE_DURATION_MS / 1000);
|
||||||
|
try {
|
||||||
|
const ok = await audioService.startRecording(false);
|
||||||
|
if (!ok) {
|
||||||
|
ToastAndroid.show('Aufnahme konnte nicht gestartet werden', ToastAndroid.LONG);
|
||||||
|
setRecording(false);
|
||||||
|
setRecordCountdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Countdown-Timer (rein UI)
|
||||||
|
const tickInterval = setInterval(() => {
|
||||||
|
setRecordCountdown(c => Math.max(0, c - 1));
|
||||||
|
}, 1000);
|
||||||
|
// Auto-Stop nach festen 4s
|
||||||
|
await new Promise(r => setTimeout(r, SAMPLE_DURATION_MS));
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
const result = await audioService.stopRecording();
|
||||||
|
setRecordCountdown(0);
|
||||||
|
setRecording(false);
|
||||||
|
if (!result || !result.base64) {
|
||||||
|
ToastAndroid.show('Aufnahme leer — nochmal probieren', ToastAndroid.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSamples(prev => [...prev, { base64: result.base64, durationMs: result.durationMs }]);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[VoiceId] recordSample:', err);
|
||||||
|
try { await audioService.cancelRecording(); } catch {}
|
||||||
|
setRecording(false);
|
||||||
|
setRecordCountdown(0);
|
||||||
|
ToastAndroid.show('Aufnahmefehler: ' + (err?.message || err), ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
}, [recording, enrollPending]);
|
||||||
|
|
||||||
|
const removeSample = useCallback((idx: number) => {
|
||||||
|
setSamples(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendEnrollment = useCallback(() => {
|
||||||
|
if (samples.length < SAMPLES_REQUIRED) {
|
||||||
|
Alert.alert('Noch nicht genug',
|
||||||
|
`Bitte mindestens ${SAMPLES_REQUIRED} Samples aufnehmen — aktuell ${samples.length}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enrollPending) return;
|
||||||
|
setEnrollPending(true);
|
||||||
|
const reqId = _newReqId('videnroll');
|
||||||
|
rvs.send('voice_id_enroll_request' as any, {
|
||||||
|
requestId: reqId,
|
||||||
|
samples: samples.map(s => s.base64),
|
||||||
|
});
|
||||||
|
// Sicherheits-Timeout: wenn nach 60s nichts kommt, freigeben
|
||||||
|
setTimeout(() => {
|
||||||
|
setEnrollPending(prev => {
|
||||||
|
if (prev) {
|
||||||
|
ToastAndroid.show('Enrollment-Timeout — bitte erneut versuchen', ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
}, [samples, enrollPending]);
|
||||||
|
|
||||||
|
const deleteFingerprint = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Stimme löschen?',
|
||||||
|
'Danach muss ARIA neu enrolled werden, sonst greift Speaker-ID-Filter nicht.',
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Löschen', style: 'destructive', onPress: () => {
|
||||||
|
const reqId = _newReqId('viddel');
|
||||||
|
rvs.send('voice_id_delete_request' as any, { requestId: reqId });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: 30 }}>
|
||||||
|
<Text style={s.intro}>
|
||||||
|
ARIA erkennt deine Stimme an einem Fingerprint (SpeechBrain ECAPA-TDNN, 192 Dimensionen).
|
||||||
|
Andere Sprecher (TV, Hintergrund, andere Personen) werden gefiltert — keine Brain-Calls,
|
||||||
|
keine Tokens. {'\n\n'}
|
||||||
|
Sprich {SAMPLES_REQUIRED} Mal je {SAMPLE_DURATION_MS / 1000}s ganz normal — verschiedene
|
||||||
|
Sätze, ruhige Umgebung empfohlen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Status-Karte */}
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardLabel}>Status</Text>
|
||||||
|
{status.state === 'loading' && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<ActivityIndicator color="#0096FF" />
|
||||||
|
<Text style={s.statusText}>Wird abgefragt...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{status.state === 'unenrolled' && (
|
||||||
|
<Text style={[s.statusText, { color: '#FFD60A' }]}>○ Nicht enrolled — Stimme einrichten ↓</Text>
|
||||||
|
)}
|
||||||
|
{status.state === 'enrolled' && (
|
||||||
|
<>
|
||||||
|
<Text style={[s.statusText, { color: '#34C759' }]}>
|
||||||
|
✓ Enrolled — {status.sampleCount} Samples
|
||||||
|
({status.durations.reduce((a, b) => a + b, 0).toFixed(1)}s gesamt)
|
||||||
|
</Text>
|
||||||
|
<Text style={s.statusSub}>
|
||||||
|
Aktualisiert {new Date(status.updatedAt * 1000).toLocaleString('de-DE')} · dim={status.dim}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status.state === 'error' && (
|
||||||
|
<Text style={[s.statusText, { color: '#FF6E6E' }]}>⚠ {status.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Aufnahme-Bereich */}
|
||||||
|
<View style={s.card}>
|
||||||
|
<Text style={s.cardLabel}>Samples ({samples.length}/{SAMPLES_REQUIRED})</Text>
|
||||||
|
{samples.length === 0 && !recording && (
|
||||||
|
<Text style={s.hint}>Tipp: sprich klare normale Sätze, je 3-4 Sekunden Audio.</Text>
|
||||||
|
)}
|
||||||
|
{samples.map((sample, idx) => (
|
||||||
|
<View key={idx} style={s.sampleRow}>
|
||||||
|
<Text style={s.sampleText}>
|
||||||
|
Sample {idx + 1} · {(sample.durationMs / 1000).toFixed(1)}s
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => removeSample(idx)} disabled={enrollPending}>
|
||||||
|
<Text style={{ color: '#FF6E6E', fontSize: 18 }}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={recordSample}
|
||||||
|
disabled={recording || enrollPending}
|
||||||
|
style={[s.recordBtn, (recording || enrollPending) && { opacity: 0.5 }]}
|
||||||
|
>
|
||||||
|
{recording ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
<Text style={s.recordBtnText}>Aufnahme läuft… {recordCountdown}s</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={s.recordBtnText}>⏺ Sample {samples.length + 1} aufnehmen</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{samples.length > 0 && !recording && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setSamples([])}
|
||||||
|
disabled={enrollPending}
|
||||||
|
style={s.resetBtn}
|
||||||
|
>
|
||||||
|
<Text style={s.resetBtnText}>Alle verwerfen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Aktionen */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={sendEnrollment}
|
||||||
|
disabled={samples.length < SAMPLES_REQUIRED || enrollPending}
|
||||||
|
style={[
|
||||||
|
s.primaryBtn,
|
||||||
|
(samples.length < SAMPLES_REQUIRED || enrollPending) && { opacity: 0.4 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{enrollPending ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
<Text style={s.primaryBtnText}>Wird verarbeitet…</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={s.primaryBtnText}>
|
||||||
|
✓ Speichern ({samples.length}/{SAMPLES_REQUIRED})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Verwaltung */}
|
||||||
|
{status.state === 'enrolled' && (
|
||||||
|
<View style={[s.card, { marginTop: 20 }]}>
|
||||||
|
<Text style={s.cardLabel}>Verwaltung</Text>
|
||||||
|
<TouchableOpacity onPress={refreshStatus} style={s.secondaryBtn}>
|
||||||
|
<Text style={s.secondaryBtnText}>🔄 Status aktualisieren</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={deleteFingerprint} style={s.dangerBtn}>
|
||||||
|
<Text style={s.dangerBtnText}>🗑 Fingerprint löschen (Re-Enrollment nötig)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
intro: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: 'rgba(30,30,46,0.6)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
cardLabel: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
statusSub: {
|
||||||
|
color: '#555570',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: '#555570',
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sampleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderColor: '#2A2A3E',
|
||||||
|
},
|
||||||
|
sampleText: {
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
recordBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: '#E55C5C',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
recordBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
resetBtn: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
resetBtnText: {
|
||||||
|
color: '#FFD60A',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: '#34C759',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
backgroundColor: 'rgba(0,150,255,0.15)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: '#0096FF',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dangerBtn: {
|
||||||
|
backgroundColor: 'rgba(229,92,92,0.15)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
dangerBtnText: {
|
||||||
|
color: '#E55C5C',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VoiceIdEnrollment;
|
||||||
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
|
|||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
|
||||||
import phoneCallService from '../services/phoneCall';
|
import phoneCallService from '../services/phoneCall';
|
||||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +47,7 @@ import VoiceButton from '../components/VoiceButton';
|
|||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
import MessageText from '../components/MessageText';
|
import MessageText from '../components/MessageText';
|
||||||
import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
|
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
@@ -273,7 +273,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||||
const [wakeWordActive, setWakeWordActive] = useState(false);
|
const [wakeWordActive, setWakeWordActive] = useState(false);
|
||||||
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
||||||
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing' | 'listening'>('off');
|
||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||||
const [inboxVisible, setInboxVisible] = useState(false);
|
const [inboxVisible, setInboxVisible] = useState(false);
|
||||||
@@ -487,9 +487,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
|
// Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
|
||||||
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
|
// Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
|
||||||
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
|
// Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
|
||||||
// 'armed' oder 'off' fallen, darf Spotify wieder.
|
// 'armed' oder 'off' fallen, darf Spotify wieder. 'listening' soll
|
||||||
if (s === 'conversing') audioService.acquireConversationFocus();
|
// Spotify ebenfalls leise halten (User darf jederzeit weitersprechen).
|
||||||
|
if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus();
|
||||||
else audioService.releaseConversationFocus();
|
else audioService.releaseConversationFocus();
|
||||||
|
// Beim Verlassen von 'listening' (Timer abgelaufen) eine ggf. noch
|
||||||
|
// laufende passive Streaming-Aufnahme killen, sonst hat OpenWakeWord
|
||||||
|
// keinen Zugriff aufs Mic beim Re-Arm.
|
||||||
|
if ((s === 'armed' || s === 'off') && audioService.isStreamingRecording()) {
|
||||||
|
audioService.cancelStreamingRecording('wakeword-state-' + s);
|
||||||
|
}
|
||||||
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
|
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
|
||||||
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
|
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
|
||||||
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
|
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
|
||||||
@@ -522,8 +529,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
const sub = AppState.addEventListener('change', (next) => {
|
const sub = AppState.addEventListener('change', (next) => {
|
||||||
if (next === 'background' || next === 'inactive') {
|
if (next === 'background' || next === 'inactive') {
|
||||||
lastBackgroundAt = Date.now();
|
lastBackgroundAt = Date.now();
|
||||||
|
wakeWordService.setBackground();
|
||||||
} else if (lastState !== 'active' && next === 'active') {
|
} else if (lastState !== 'active' && next === 'active') {
|
||||||
wakeWordService.setResumeCooldown(3000);
|
wakeWordService.setForeground();
|
||||||
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
|
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
|
||||||
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
|
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
|
||||||
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
|
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
|
||||||
@@ -1263,11 +1271,30 @@ const ChatScreen: React.FC = () => {
|
|||||||
return () => { unsubUpdate(); clearTimeout(timer); };
|
return () => { unsubUpdate(); clearTimeout(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Gespraechsmodus: Nach TTS-Wiedergabe automatisch Aufnahme starten
|
// Gespraechsmodus: Nach TTS-Wiedergabe weiter im Multi-Turn (Conversation-
|
||||||
|
// Window) oder zurueck zu armed (Wake-Word lauscht wieder)?
|
||||||
|
//
|
||||||
|
// Foreground → resume() oeffnet das Mikro fuer N Sekunden Follow-Up
|
||||||
|
// (natuerlicher Dialog moeglich ohne erneutes "Computer")
|
||||||
|
// Background → endConversation() — Wake-Word direkt wieder armed.
|
||||||
|
//
|
||||||
|
// Grund: der setTimeout(800ms) in resume() wird im Doze stark verzoegert
|
||||||
|
// (siehe Wake-Detect-Bug von 0.1.7.0). Das hat zwei nervige Folgen:
|
||||||
|
// 1) Wake-Word ist solange "tot" — User kann ARIA nicht mehr triggern
|
||||||
|
// bis er die App vorholt
|
||||||
|
// 2) Wenn er die App dann vorholt, oeffnet der verspaetete Timer das
|
||||||
|
// Mikro — sieht aus wie ein Phantom-Wake-Word-Trigger
|
||||||
|
// Background = User nutzt das Handy anderweitig, das Multi-Turn-Konzept
|
||||||
|
// ist da eh nicht nuetzlich. Direkt re-armen ist robust und erwartungs-
|
||||||
|
// konform.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubPlayback = audioService.onPlaybackFinished(() => {
|
const unsubPlayback = audioService.onPlaybackFinished(() => {
|
||||||
if (wakeWordService.isActive()) {
|
if (!wakeWordService.isActive()) return;
|
||||||
|
if (AppState.currentState === 'active') {
|
||||||
wakeWordService.resume();
|
wakeWordService.resume();
|
||||||
|
} else {
|
||||||
|
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
|
||||||
|
wakeWordService.endConversation().catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => unsubPlayback();
|
return () => unsubPlayback();
|
||||||
@@ -1326,12 +1353,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
|
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
|
||||||
// aria-bridge bekommt das gleiche Event und triggert Brain
|
// aria-bridge bekommt das gleiche Event und triggert Brain
|
||||||
// direkt. App muss nix mehr senden.
|
// direkt. App muss nix mehr senden.
|
||||||
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error).
|
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error /
|
||||||
// Konversation beenden wie frueher der "kein Speech"-Fall.
|
// speaker_mismatch). Konversation beenden, oder bei
|
||||||
|
// passive-listening: nochmal lauschen.
|
||||||
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
|
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
|
||||||
if (ev.text && ev.text.trim()) {
|
if (ev.text && ev.text.trim()) {
|
||||||
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
|
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
|
||||||
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
|
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
|
||||||
|
// Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
|
||||||
|
// zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
|
||||||
|
if (wakeWordService.getState() === 'listening') {
|
||||||
|
wakeWordService.exitPassiveListening('speech').catch(() => {});
|
||||||
|
}
|
||||||
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
|
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
|
||||||
// chat(sender=aria) wie im Legacy-Pfad.
|
// chat(sender=aria) wie im Legacy-Pfad.
|
||||||
} else {
|
} else {
|
||||||
@@ -1341,11 +1374,28 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (ev.audioRequestId) {
|
if (ev.audioRequestId) {
|
||||||
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
|
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
|
||||||
}
|
}
|
||||||
wakeWordService.endConversation();
|
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
|
||||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
|
||||||
|
// Sonst endConversation wie bisher.
|
||||||
|
if (wakeWordService.getState() === 'listening') {
|
||||||
|
console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
|
||||||
|
startPassiveStreamingRecording();
|
||||||
|
} else {
|
||||||
|
wakeWordService.endConversation();
|
||||||
|
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Passive-Listen-Callback: Wake-Word-Service hat in den passiven Modus
|
||||||
|
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
|
||||||
|
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
|
||||||
|
// Whisper-Bridge filtert fremde Stimmen weg.
|
||||||
|
const unsubPassive = wakeWordService.onPassiveListen(() => {
|
||||||
|
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
|
||||||
|
startPassiveStreamingRecording();
|
||||||
|
});
|
||||||
|
|
||||||
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
|
// Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
|
||||||
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
|
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
|
||||||
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
|
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
|
||||||
@@ -1410,11 +1460,38 @@ const ChatScreen: React.FC = () => {
|
|||||||
unsubWake();
|
unsubWake();
|
||||||
unsubEndpoint();
|
unsubEndpoint();
|
||||||
unsubBarge();
|
unsubBarge();
|
||||||
|
unsubPassive();
|
||||||
unsubTtsStart();
|
unsubTtsStart();
|
||||||
unsubTtsEnd();
|
unsubTtsEnd();
|
||||||
};
|
};
|
||||||
}, [wakeWordActive]);
|
}, [wakeWordActive]);
|
||||||
|
|
||||||
|
// Passive-Listen-Aufnahme: ohne User-Bubble, ohne Wake-Sound, Speaker-ID-
|
||||||
|
// Gating in der Whisper-Bridge entscheidet ob Stefan spricht oder z.B.
|
||||||
|
// die Frau / TV. Bei text != '' → wakeWordService.exitPassiveListening('speech')
|
||||||
|
// schaltet auf conversing, Brain antwortet, TTS spielt, resume → endConv →
|
||||||
|
// ... und passive listening startet von vorne (mit frischem Timer).
|
||||||
|
// useCallback damit der useEffect oben die Funktion stabil capturen kann.
|
||||||
|
const startPassiveStreamingRecording = useCallback(async () => {
|
||||||
|
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
const location = await getCurrentLocation();
|
||||||
|
const passiveMs = await loadPassiveListenMs();
|
||||||
|
const { ok } = await audioService.startStreamingRecording({
|
||||||
|
audioRequestId,
|
||||||
|
voice: localXttsVoiceRef.current,
|
||||||
|
speed: ttsSpeedRef.current,
|
||||||
|
interrupted: false,
|
||||||
|
location: location || null,
|
||||||
|
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
|
||||||
|
endpointMs: 1500,
|
||||||
|
hardCapMs: Math.max(passiveMs + 5000, 35000),
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
console.warn('[Chat] passive streaming start failed — exit passive listening');
|
||||||
|
wakeWordService.exitPassiveListening('manual').catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Wake Word Toggle Handler
|
// Wake Word Toggle Handler
|
||||||
const toggleWakeWord = useCallback(async () => {
|
const toggleWakeWord = useCallback(async () => {
|
||||||
if (wakeWordActive) {
|
if (wakeWordActive) {
|
||||||
@@ -1761,49 +1838,59 @@ const ChatScreen: React.FC = () => {
|
|||||||
return true;
|
return true;
|
||||||
}, [agentActivity]);
|
}, [agentActivity]);
|
||||||
|
|
||||||
// Sprachaufnahme abgeschlossen
|
// Manueller Aufnahme-Knopf (VoiceButton) — Start.
|
||||||
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
// Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen
|
||||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
|
// die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt.
|
||||||
|
// Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird
|
||||||
|
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
|
||||||
|
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||||
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
const wasInterrupted = interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
|
||||||
|
|
||||||
const cmid = nextClientMsgId();
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
clientMsgId: cmid,
|
|
||||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
|
||||||
sendAttempts: 1,
|
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
dispatchWithAck(cmid, 'audio', {
|
const { ok } = await audioService.startStreamingRecording({
|
||||||
base64: result.base64,
|
audioRequestId,
|
||||||
durationMs: result.durationMs,
|
|
||||||
mimeType: result.mimeType,
|
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: wasInterrupted,
|
interrupted: wasInterrupted,
|
||||||
audioRequestId,
|
location: location || null,
|
||||||
...(location && { location }),
|
// Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum-
|
||||||
|
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
|
||||||
|
// die Session auch app-seitig haben wir +2s Toleranz.
|
||||||
|
noSpeechTimeoutMs: 0,
|
||||||
|
endpointMs: 1500,
|
||||||
|
hardCapMs: 300000,
|
||||||
});
|
});
|
||||||
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
|
if (!ok) {
|
||||||
|
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||||
|
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||||
|
return true;
|
||||||
|
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||||
|
|
||||||
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
|
// Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
|
||||||
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
|
// dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
|
||||||
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
|
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
|
||||||
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
|
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
|
||||||
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
|
const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
|
||||||
// Knopf.
|
await audioService.stopStreamingRecording('user');
|
||||||
if (wakeWordService.isConversing()) {
|
if (wakeWordService.isConversing()) {
|
||||||
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
||||||
await wakeWordService.endConversation();
|
await wakeWordService.endConversation();
|
||||||
}
|
}
|
||||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
}, []);
|
||||||
|
|
||||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||||
@@ -2572,7 +2659,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<VoiceButton
|
<VoiceButton
|
||||||
onRecordingComplete={handleVoiceRecording}
|
onTapStart={handleVoiceButtonStart}
|
||||||
|
onTapStop={handleVoiceButtonStop}
|
||||||
disabled={connectionState !== 'connected'}
|
disabled={connectionState !== 'connected'}
|
||||||
wakeWordActive={wakeWordActive}
|
wakeWordActive={wakeWordActive}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,9 +21,37 @@ import {
|
|||||||
PermissionsAndroid,
|
PermissionsAndroid,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
DeviceEventEmitter,
|
DeviceEventEmitter,
|
||||||
|
NativeModules,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
|
|
||||||
|
const { FileOpener } = NativeModules as {
|
||||||
|
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
|
||||||
|
};
|
||||||
|
|
||||||
|
// MIME-Type aus Dateinamen schaetzen — fuer den FileOpener-Intent. Android
|
||||||
|
// nutzt den MIME-Type um die passende App zu finden. Unknown → octet-stream.
|
||||||
|
function guessMimeFromName(name: string): string {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||||
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
if (lower.endsWith('.png')) return 'image/png';
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif';
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp';
|
||||||
|
if (lower.endsWith('.mp3')) return 'audio/mpeg';
|
||||||
|
if (lower.endsWith('.wav')) return 'audio/wav';
|
||||||
|
if (lower.endsWith('.ogg') || lower.endsWith('.opus')) return 'audio/ogg';
|
||||||
|
if (lower.endsWith('.mp4') || lower.endsWith('.m4a')) return 'audio/mp4';
|
||||||
|
if (lower.endsWith('.webm')) return 'video/webm';
|
||||||
|
if (lower.endsWith('.txt')) return 'text/plain';
|
||||||
|
if (lower.endsWith('.md')) return 'text/markdown';
|
||||||
|
if (lower.endsWith('.json')) return 'application/json';
|
||||||
|
if (lower.endsWith('.csv')) return 'text/csv';
|
||||||
|
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
|
||||||
|
if (lower.endsWith('.zip')) return 'application/zip';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
import DocumentPicker from 'react-native-document-picker';
|
import DocumentPicker from 'react-native-document-picker';
|
||||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +91,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
|
|||||||
import TriggerBrowser from '../components/TriggerBrowser';
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
import SkillBrowser from '../components/SkillBrowser';
|
import SkillBrowser from '../components/SkillBrowser';
|
||||||
import OAuthBrowser from '../components/OAuthBrowser';
|
import OAuthBrowser from '../components/OAuthBrowser';
|
||||||
|
import VoiceIdEnrollment from '../components/VoiceIdEnrollment';
|
||||||
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -108,6 +137,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
|
{ id: 'general', icon: '⚙️', label: 'Allgemein', desc: 'Betriebsmodus, GPS-Standort' },
|
||||||
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
|
{ id: 'voice_input', icon: '🎙️', label: 'Spracheingabe', desc: 'Stille-Toleranz, Aufnahmedauer' },
|
||||||
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
|
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
|
||||||
|
{ id: 'voice_id', icon: '🎤', label: 'Stimme einrichten', desc: 'Sprecher-Erkennung — nur deine Stimme triggert ARIA' },
|
||||||
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||||
@@ -180,6 +210,14 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
|
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
|
||||||
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
|
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
|
||||||
const [fileZipBusy, setFileZipBusy] = useState(false);
|
const [fileZipBusy, setFileZipBusy] = useState(false);
|
||||||
|
// Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git
|
||||||
|
// im diagnostic-Container. Browser-Variante davon laeuft schon, hier App-
|
||||||
|
// Side via RVS-Messages (file_version_list_request/...).
|
||||||
|
const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null);
|
||||||
|
const [versionsList, setVersionsList] = useState<Array<{hash: string; ts: number; subject: string; isCurrent?: boolean}>>([]);
|
||||||
|
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||||
|
const [versionsError, setVersionsError] = useState('');
|
||||||
|
const versionDlPending = useRef<string | null>(null); // requestId beim Versions-Download
|
||||||
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
||||||
const [tempPath, setTempPath] = useState('');
|
const [tempPath, setTempPath] = useState('');
|
||||||
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
|
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
|
||||||
@@ -497,6 +535,137 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
|
||||||
|
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
|
||||||
|
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
|
||||||
|
// passt. Hier behandeln wir die Manager-initiierten Downloads
|
||||||
|
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
|
||||||
|
// ~/Download/ wie der ZIP-Pfad.
|
||||||
|
if (message.type === ('file_response' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
const reqId = (p.requestId as string) || '';
|
||||||
|
const isDownload = reqId.startsWith('single-');
|
||||||
|
const isOpen = reqId.startsWith('open-');
|
||||||
|
if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.)
|
||||||
|
if (p.error) {
|
||||||
|
ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b64 = (p.base64 as string) || '';
|
||||||
|
if (!b64) return;
|
||||||
|
const fileName = (p.name as string) ||
|
||||||
|
(p.serverPath as string || '').split('/').pop() ||
|
||||||
|
'aria-download';
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (isOpen) {
|
||||||
|
// Open-Pfad: nach Caches schreiben + per FileOpener mit System-
|
||||||
|
// Viewer oeffnen. Caches damit der Speicher kein Dauer-Muell wird.
|
||||||
|
const dir = RNFS.CachesDirectoryPath;
|
||||||
|
const target = `${dir}/${fileName}`;
|
||||||
|
await RNFS.writeFile(target, b64, 'base64');
|
||||||
|
const mime = (p.mimeType as string) || guessMimeFromName(fileName);
|
||||||
|
if (FileOpener?.open) {
|
||||||
|
try {
|
||||||
|
await FileOpener.open(target, mime);
|
||||||
|
} catch (e: any) {
|
||||||
|
ToastAndroid.show('Öffnen fehlgeschlagen: ' + (e?.message || e), ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToastAndroid.show('FileOpener-Modul nicht verfügbar — APK neu bauen', ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Download-Pfad: nach Downloads-Ordner schreiben, mit Suffix bei
|
||||||
|
// Namens-Konflikt damit nichts ueberschrieben wird.
|
||||||
|
const dir = RNFS.DownloadDirectoryPath;
|
||||||
|
const filePath = `${dir}/${fileName}`;
|
||||||
|
let target = filePath;
|
||||||
|
let i = 1;
|
||||||
|
while (await RNFS.exists(target)) {
|
||||||
|
const dot = fileName.lastIndexOf('.');
|
||||||
|
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||||
|
const ext = dot > 0 ? fileName.slice(dot) : '';
|
||||||
|
target = `${dir}/${base} (${i})${ext}`;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
await RNFS.writeFile(target, b64, 'base64');
|
||||||
|
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
|
||||||
|
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
|
||||||
|
} catch (e: any) {
|
||||||
|
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Versions-Liste einer Datei
|
||||||
|
if (message.type === ('file_version_list_response' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
setVersionsLoading(false);
|
||||||
|
if (!p.ok) {
|
||||||
|
setVersionsError(p.error || 'Unbekannter Fehler');
|
||||||
|
setVersionsList([]);
|
||||||
|
} else {
|
||||||
|
setVersionsError('');
|
||||||
|
setVersionsList(p.versions || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Versions-Inhalt (Download einer alten Version)
|
||||||
|
if (message.type === ('file_version_download_response' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
if (p.requestId && p.requestId !== versionDlPending.current) return;
|
||||||
|
versionDlPending.current = null;
|
||||||
|
if (!p.ok) {
|
||||||
|
ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// base64 → Downloads-Ordner. Hash als Suffix damit Original nicht
|
||||||
|
// ueberschrieben wird wenn beide Versionen nebeneinander vorliegen
|
||||||
|
// sollen.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const baseName = (p.name as string) || 'aria-version';
|
||||||
|
const shortHash = (p.hash as string || '').slice(0, 7);
|
||||||
|
const dot = baseName.lastIndexOf('.');
|
||||||
|
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
|
||||||
|
const ext = dot > 0 ? baseName.slice(dot) : '';
|
||||||
|
const dir = RNFS.DownloadDirectoryPath;
|
||||||
|
let target = `${dir}/${stem}@${shortHash}${ext}`;
|
||||||
|
let i = 1;
|
||||||
|
while (await RNFS.exists(target)) {
|
||||||
|
target = `${dir}/${stem}@${shortHash}_${i}${ext}`;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
await RNFS.writeFile(target, p.base64, 'base64');
|
||||||
|
const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024);
|
||||||
|
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
|
||||||
|
} catch (e: any) {
|
||||||
|
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Restore-Bestaetigung
|
||||||
|
if (message.type === ('file_version_restore_response' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
if (!p.ok) {
|
||||||
|
ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT);
|
||||||
|
// Versions-Liste neu laden damit der neue restore-Commit auftaucht
|
||||||
|
if (versionsOpen) {
|
||||||
|
setVersionsLoading(true);
|
||||||
|
rvs.send('file_version_list_request' as any, { path: versionsOpen.path });
|
||||||
|
}
|
||||||
|
// File-Liste auch refreshen (mtime hat sich geaendert)
|
||||||
|
if (fileManagerOpen) {
|
||||||
|
setFileManagerLoading(true);
|
||||||
|
rvs.send('file_list_request' as any, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
|
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
|
||||||
if (message.type === ('xtts_voice_saved' as any)) {
|
if (message.type === ('xtts_voice_saved' as any)) {
|
||||||
const name = (message.payload as any).name as string;
|
const name = (message.payload as any).name as string;
|
||||||
@@ -541,6 +710,20 @@ const SettingsScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Datei-Manager: Auto-Reload bei RVS-Reconnect — sonst zeigt das offene
|
||||||
|
// Modal den Fehler "Connection refused" ewig an, obwohl die Verbindung
|
||||||
|
// schon wieder da ist. Triggered nur wenn das Modal gerade offen ist.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = rvs.onStateChange((state) => {
|
||||||
|
if (state === 'connected' && fileManagerOpen) {
|
||||||
|
setFileManagerError('');
|
||||||
|
setFileManagerLoading(true);
|
||||||
|
rvs.send('file_list_request' as any, {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, [fileManagerOpen]);
|
||||||
|
|
||||||
// --- QR-Code scannen ---
|
// --- QR-Code scannen ---
|
||||||
|
|
||||||
const openQRScanner = useCallback(() => {
|
const openQRScanner = useCallback(() => {
|
||||||
@@ -921,6 +1104,44 @@ const SettingsScreen: React.FC = () => {
|
|||||||
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
|
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
rvs.send('file_request' as any, {
|
||||||
|
serverPath: f.path,
|
||||||
|
requestId: 'open-' + Date.now(),
|
||||||
|
});
|
||||||
|
ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT);
|
||||||
|
}}
|
||||||
|
style={{padding:8}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#0096FF', fontSize:18}}>👁</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
rvs.send('file_request' as any, {
|
||||||
|
serverPath: f.path,
|
||||||
|
requestId: 'single-' + Date.now(),
|
||||||
|
});
|
||||||
|
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
|
||||||
|
}}
|
||||||
|
style={{padding:8}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#34C759', fontSize:18}}>⬇</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// path-relativ-zu-uploads = nur der Dateiname,
|
||||||
|
// weil der File-Manager-Bereich flach ist
|
||||||
|
setVersionsOpen({name: f.name, path: f.name});
|
||||||
|
setVersionsList([]);
|
||||||
|
setVersionsError('');
|
||||||
|
setVersionsLoading(true);
|
||||||
|
rvs.send('file_version_list_request' as any, { path: f.name });
|
||||||
|
}}
|
||||||
|
style={{padding:8}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#0096FF', fontSize:18}}>🕒</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -948,6 +1169,110 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */}
|
||||||
|
<Modal
|
||||||
|
visible={versionsOpen !== null}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setVersionsOpen(null)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'center', alignItems:'center'}}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setVersionsOpen(null)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => {}}
|
||||||
|
style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}}
|
||||||
|
>
|
||||||
|
<View style={{padding:12, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center'}}>
|
||||||
|
<Text style={{color:'#E0E0F0', fontSize:13, fontWeight:'bold', flex:1}} numberOfLines={1}>
|
||||||
|
Versionen — {versionsOpen?.name || ''}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => setVersionsOpen(null)} style={{padding:6}}>
|
||||||
|
<Text style={{color:'#888', fontSize:14}}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{maxHeight:'85%'}} contentContainerStyle={{padding:8}}>
|
||||||
|
{versionsLoading && (
|
||||||
|
<Text style={{color:'#888', textAlign:'center', padding:20}}>Lade...</Text>
|
||||||
|
)}
|
||||||
|
{!!versionsError && (
|
||||||
|
<Text style={{color:'#FF6B6B', padding:20}}>{versionsError}</Text>
|
||||||
|
)}
|
||||||
|
{!versionsLoading && !versionsError && versionsList.length === 0 && (
|
||||||
|
<Text style={{color:'#888', textAlign:'center', padding:20}}>
|
||||||
|
Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index).
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{versionsList.map(v => (
|
||||||
|
<View key={v.hash} style={{padding:10, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center', gap:8}}>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<View style={{flexDirection:'row', alignItems:'center', gap:6}}>
|
||||||
|
{v.isCurrent && (
|
||||||
|
<View style={{backgroundColor:'#34C75922', paddingHorizontal:6, paddingVertical:1, borderRadius:3}}>
|
||||||
|
<Text style={{color:'#34C759', fontSize:9}}>AKTIV</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={{color:'#0096FF', fontSize:11, fontFamily:'monospace'}}>
|
||||||
|
{v.hash.slice(0,7)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{color:'#888', fontSize:11, flex:1}} numberOfLines={1}>
|
||||||
|
{v.subject || ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
|
||||||
|
{new Date(v.ts).toLocaleString('de-DE')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!versionsOpen) return;
|
||||||
|
const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000);
|
||||||
|
versionDlPending.current = reqId;
|
||||||
|
rvs.send('file_version_download_request' as any, {
|
||||||
|
path: versionsOpen.path,
|
||||||
|
hash: v.hash,
|
||||||
|
requestId: reqId,
|
||||||
|
});
|
||||||
|
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
|
||||||
|
}}
|
||||||
|
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#0096FF', fontSize:11}}>⬇</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{!v.isCurrent && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!versionsOpen) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Version aktiv setzen?',
|
||||||
|
`Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{ text: 'Restore', onPress: () => {
|
||||||
|
rvs.send('file_version_restore_request' as any, {
|
||||||
|
path: versionsOpen.path,
|
||||||
|
hash: v.hash,
|
||||||
|
});
|
||||||
|
ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT);
|
||||||
|
}},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#fff', fontSize:11}}>⟲</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
@@ -1513,6 +1838,12 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Voice-ID Enrollment (Sprecher-Erkennung) === */}
|
||||||
|
{currentSection === 'voice_id' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Stimme einrichten</Text>
|
||||||
|
<VoiceIdEnrollment />
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Sprachausgabe (geraetelokal) === */}
|
{/* === Sprachausgabe (geraetelokal) === */}
|
||||||
{currentSection === 'voice_output' && (<>
|
{currentSection === 'voice_output' && (<>
|
||||||
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
|
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
|
||||||
|
|||||||
@@ -341,8 +341,21 @@ class AudioService {
|
|||||||
try {
|
try {
|
||||||
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
|
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
|
||||||
emitter.addListener('PcmPlaybackFinished', () => {
|
emitter.addListener('PcmPlaybackFinished', () => {
|
||||||
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
|
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
|
||||||
this._releaseFocusDeferred();
|
this._releaseFocusDeferred();
|
||||||
|
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
|
||||||
|
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
|
||||||
|
// braucht nach end() noch 1-2s zum Drainen seines Hardware-Buffers.
|
||||||
|
// Wenn wir die Listener zu frueh feuern, re-armt OpenWakeWord
|
||||||
|
// waehrend ARIA noch hoerbar spricht → ARIAs Stimme verwirrt die
|
||||||
|
// Wake-Word-Detection (kein gemeinsames AEC zwischen AudioTrack-
|
||||||
|
// und AudioRecord-Session). Stefan-Reproduktion: nach jeder ARIA-
|
||||||
|
// Antwort schluckte das Wake-Word den naechsten Trigger.
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.playback',
|
||||||
|
'PcmPlaybackFinished native event → fire listeners')).catch(()=>{});
|
||||||
|
this.playbackFinishedListeners.forEach(cb => {
|
||||||
|
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
|
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
|
||||||
@@ -416,24 +429,34 @@ class AudioService {
|
|||||||
private _releaseFocusDeferred(): void {
|
private _releaseFocusDeferred(): void {
|
||||||
if (this._conversationFocusActive) {
|
if (this._conversationFocusActive) {
|
||||||
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
|
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'_releaseFocusDeferred SKIPPED (conversation active)')).catch(()=>{});
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._cancelDeferredFocusRelease();
|
this._cancelDeferredFocusRelease();
|
||||||
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
|
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
`_releaseFocusDeferred scheduled in ${this.FOCUS_RELEASE_DELAY_MS}ms`)).catch(()=>{});
|
||||||
this.focusReleaseTimer = setTimeout(() => {
|
this.focusReleaseTimer = setTimeout(() => {
|
||||||
this.focusReleaseTimer = null;
|
this.focusReleaseTimer = null;
|
||||||
if (this._conversationFocusActive) {
|
if (this._conversationFocusActive) {
|
||||||
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
|
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'release timer fired but conversation now active → SKIP')).catch(()=>{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[Audio] AudioFocus jetzt released');
|
console.log('[Audio] AudioFocus jetzt released');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'AudioFocus.release() now')).catch(()=>{});
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
|
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
|
||||||
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
|
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
|
||||||
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
|
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
|
||||||
// 50ms Delay damit das Abandon erst durch ist.
|
// 50ms Delay damit das Abandon erst durch ist.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
|
||||||
AudioFocus?.nudgeMediaResume().catch(() => {});
|
AudioFocus?.nudgeMediaResume().catch(() => {});
|
||||||
}, 50);
|
}, 50);
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
@@ -1368,12 +1391,13 @@ class AudioService {
|
|||||||
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
|
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
|
||||||
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
|
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
|
||||||
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
|
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
|
||||||
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
|
// wirklich am Ende ist (siehe Constructor-PcmPlaybackFinished-Handler).
|
||||||
|
//
|
||||||
|
// playbackFinishedListeners feuern AUCH erst dort — frueher feuerten
|
||||||
|
// sie hier (beim Eintreffen des letzten Chunks), das fuehrte zu
|
||||||
|
// einem Race: OpenWakeWord re-armte waehrend AudioTrack noch hoerbar
|
||||||
|
// ARIAs Stimme abspielte → naechstes Wake-Word ging unter.
|
||||||
try { await PcmStreamPlayer!.end(); } catch {}
|
try { await PcmStreamPlayer!.end(); } catch {}
|
||||||
// playbackFinished-Listener informieren (UI-Logik)
|
|
||||||
this.playbackFinishedListeners.forEach(cb => {
|
|
||||||
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.pcmStreamActive = false;
|
this.pcmStreamActive = false;
|
||||||
|
|
||||||
@@ -1504,6 +1528,20 @@ class AudioService {
|
|||||||
this.playbackStartTime = Date.now();
|
this.playbackStartTime = Date.now();
|
||||||
this.currentPlaybackMsgId = this.pcmMessageId;
|
this.currentPlaybackMsgId = this.pcmMessageId;
|
||||||
}
|
}
|
||||||
|
// AudioFocus EXPLIZIT fuer TTS halten — sonst pausiert Spotify zwar
|
||||||
|
// beim Recording-requestExclusive, der wird aber 800ms nach STT-Endpoint
|
||||||
|
// released (Brain-Processing-Gap), und wenn dann TTS startet ist niemand
|
||||||
|
// mehr Focus-Owner. Spotify pausiert evtl. implizit beim AudioTrack-
|
||||||
|
// USAGE_ASSISTANT, aber unsere nachtraegliche release+nudge-Sequenz
|
||||||
|
// kann es dann nicht zuverlaessig wieder anstossen. Mit explizitem
|
||||||
|
// requestDuck IST Spotify sauber-via-Focus pausiert, und der Release
|
||||||
|
// beim PcmPlaybackFinished triggert das normale "Owner fertig → resume"-
|
||||||
|
// Pattern in Spotify — funktioniert versionsunabhaengig.
|
||||||
|
// Pending Release-Timer canceln damit der nicht mitten in der TTS feuert.
|
||||||
|
this._cancelDeferredFocusRelease();
|
||||||
|
AudioFocus?.requestDuck().catch(() => {});
|
||||||
|
import('./logger').then(m => m.reportAppDebug('audio.focus',
|
||||||
|
'TTS-start: requestDuck() called + canceled pending release')).catch(()=>{});
|
||||||
this.playbackStartedListeners.forEach(cb => {
|
this.playbackStartedListeners.forEach(cb => {
|
||||||
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ interface SendOpts {
|
|||||||
|
|
||||||
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
||||||
_ensureListener();
|
_ensureListener();
|
||||||
|
// Fast-Fail wenn RVS nicht verbunden — sonst tickt der Timeout 30s und
|
||||||
|
// der TriggerBrowser / Dateimanager zeigt ne ewig drehende Spinner.
|
||||||
|
// Stefan-Bug 06/2026: "Connection refused, App haengt 30 Sekunden".
|
||||||
|
const rvsState = rvs.getState();
|
||||||
|
if (rvsState !== 'connected') {
|
||||||
|
return Promise.reject(new Error(
|
||||||
|
`Keine Verbindung zum Brain (RVS: ${rvsState}). Warte auf Reconnect...`,
|
||||||
|
));
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestId = _newRequestId();
|
const requestId = _newRequestId();
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -26,8 +26,30 @@ import { acquireBackgroundAudio } from './backgroundAudio';
|
|||||||
|
|
||||||
type WakeWordCallback = () => void;
|
type WakeWordCallback = () => void;
|
||||||
type StateCallback = (state: WakeWordState) => void;
|
type StateCallback = (state: WakeWordState) => void;
|
||||||
|
type PassiveListenCallback = () => void;
|
||||||
|
|
||||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
|
||||||
|
|
||||||
|
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
|
||||||
|
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
|
||||||
|
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
|
||||||
|
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
|
||||||
|
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
|
||||||
|
|
||||||
|
export async function loadPassiveListenMs(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return PASSIVE_LISTEN_DEFAULT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePassiveListenMs(ms: number): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
|
||||||
|
}
|
||||||
|
|
||||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||||
|
|
||||||
@@ -91,6 +113,24 @@ class WakeWordService {
|
|||||||
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
||||||
* Stefan gar nicht in der App war). */
|
* Stefan gar nicht in der App war). */
|
||||||
private lastTriggerAt: number = 0;
|
private lastTriggerAt: number = 0;
|
||||||
|
/** App liegt im Hintergrund — alle Detections sperren. Wird vom
|
||||||
|
* AppState-Listener im ChatScreen via setBackground/setForeground gesetzt.
|
||||||
|
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
||||||
|
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
||||||
|
private inBackground: 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
|
||||||
|
* Event sofort verworfen. Reset beim Verlassen von 'conversing'.
|
||||||
|
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
|
||||||
|
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
|
||||||
|
private detectionInProgress: boolean = false;
|
||||||
|
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
|
||||||
|
* beendet den listening-State und geht zurueck zu armed. */
|
||||||
|
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
|
||||||
|
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||||
|
private passiveListenCallbacks: PassiveListenCallback[] = [];
|
||||||
|
|
||||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private nativeReady: boolean = false;
|
private nativeReady: boolean = false;
|
||||||
@@ -213,6 +253,7 @@ class WakeWordService {
|
|||||||
/** Komplett ausschalten (Ohr abschalten) */
|
/** Komplett ausschalten (Ohr abschalten) */
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
console.log('[WakeWord] Ohr deaktiviert');
|
console.log('[WakeWord] Ohr deaktiviert');
|
||||||
|
this.cancelPassiveListenTimer();
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await OpenWakeWord.stop(); } catch {}
|
try { await OpenWakeWord.stop(); } catch {}
|
||||||
}
|
}
|
||||||
@@ -228,14 +269,44 @@ class WakeWordService {
|
|||||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** App in den Hintergrund: alle Wake-Word-Detections sperren.
|
||||||
|
* Im Hintergrund will Stefan praktisch nie einen neuen Dialog starten —
|
||||||
|
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
||||||
|
setBackground(): void {
|
||||||
|
this.inBackground = true;
|
||||||
|
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
|
||||||
|
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
|
||||||
|
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
|
||||||
|
setForeground(): void {
|
||||||
|
this.inBackground = false;
|
||||||
|
this.cooldownUntilMs = Date.now() + 3000;
|
||||||
|
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
|
||||||
|
}
|
||||||
|
|
||||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||||
private async onWakeDetected(): Promise<void> {
|
private async onWakeDetected(): Promise<void> {
|
||||||
|
if (this.inBackground) {
|
||||||
|
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
||||||
|
// Ausnahme: Barge-In waehrend ARIA-TTS ist ein legitimer neuer Trigger.
|
||||||
|
if (this.detectionInProgress && !this.bargeListening) {
|
||||||
|
console.log('[WakeWord] Trigger ignoriert (Detection-Zyklus laeuft schon — Native-Doppel-Event-Race)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: detectionInProgress')).catch(()=>{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now < this.cooldownUntilMs) {
|
if (now < this.cooldownUntilMs) {
|
||||||
const left = this.cooldownUntilMs - now;
|
const left = this.cooldownUntilMs - now;
|
||||||
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
|
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.detectionInProgress = true;
|
||||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||||
this.keyword, this.state, this.bargeListening);
|
this.keyword, this.state, this.bargeListening);
|
||||||
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
||||||
@@ -344,25 +415,140 @@ class WakeWordService {
|
|||||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||||
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
||||||
* Ohne: zurueck zu 'off'.
|
* Ohne: zurueck zu 'off'.
|
||||||
|
*
|
||||||
|
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
|
||||||
|
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
|
||||||
|
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
|
||||||
|
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
|
||||||
|
* ist, und unseren frisch re-armierten Listener killen.
|
||||||
*/
|
*/
|
||||||
async endConversation(): Promise<void> {
|
async endConversation(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'conversing') {
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
|
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasBarge = this.bargeListening;
|
||||||
|
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
|
||||||
|
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
|
||||||
|
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
|
||||||
|
// den Listener stoppen.
|
||||||
|
this.bargeListening = false;
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
|
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
|
||||||
|
|
||||||
|
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
|
||||||
|
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
|
||||||
|
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
|
||||||
|
// Anrede weitersprechen.
|
||||||
|
const passiveMs = await loadPassiveListenMs();
|
||||||
|
if (passiveMs > 0 && this.nativeReady) {
|
||||||
|
this.enterPassiveListening(passiveMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
|
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
|
||||||
|
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
|
||||||
|
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
|
||||||
|
// als state extra zu fragen, garantiert dass nach diesem Pfad
|
||||||
|
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
|
||||||
|
try {
|
||||||
|
await OpenWakeWord.start();
|
||||||
|
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
|
`OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
|
||||||
|
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||||
|
this.setState('armed');
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
|
`OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
|
||||||
|
)).catch(()=>{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
||||||
|
`fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
|
||||||
|
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
|
||||||
|
this.setState('off');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eintritt in den Passive-Listen-Modus: state='listening', Timer fuer
|
||||||
|
* Auto-Ende setzen, Callbacks feuern damit ChatScreen die passive
|
||||||
|
* Streaming-Aufnahme startet. OpenWakeWord bleibt AUS (Mic-Exklusivitaet —
|
||||||
|
* audioService braucht das Mikro fuer die passive Aufnahme).
|
||||||
|
* Speaker-ID-Gating (Phase 3) filtert fremde Stimmen auf der Bridge. */
|
||||||
|
private enterPassiveListening(durationMs: number): void {
|
||||||
|
this.cancelPassiveListenTimer();
|
||||||
|
this.setState('listening');
|
||||||
|
const seconds = Math.round(durationMs / 1000);
|
||||||
|
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.passive',
|
||||||
|
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
|
||||||
|
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
|
||||||
|
this.passiveListenTimer = setTimeout(() => {
|
||||||
|
this.passiveListenTimer = null;
|
||||||
|
this.exitPassiveListening('timeout').catch(() => {});
|
||||||
|
}, durationMs);
|
||||||
|
this.passiveListenCallbacks.forEach(cb => {
|
||||||
|
try { cb(); } catch (e) { console.warn('[WakeWord] passive cb err:', e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verlassen des Passive-Listen-Modus.
|
||||||
|
* reason='speech' → User hat was gesagt (STT-Endpoint mit text) → uebergang
|
||||||
|
* in 'conversing' (Brain antwortet, TTS spielt, dann resume → endConversation
|
||||||
|
* → wieder passive listening, repeat).
|
||||||
|
* reason='timeout' → 30s nichts gehoert → zurueck zu armed (Wake-Word wieder an).
|
||||||
|
* reason='manual' → User hat App geschlossen / stopped → zurueck zu armed. */
|
||||||
|
async exitPassiveListening(reason: 'timeout' | 'speech' | 'manual'): Promise<void> {
|
||||||
|
if (this.state !== 'listening') return;
|
||||||
|
this.cancelPassiveListenTimer();
|
||||||
|
console.log('[WakeWord] Passive-Listen Ende (reason=%s)', reason);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.passive',
|
||||||
|
`exit reason=${reason}`)).catch(()=>{});
|
||||||
|
|
||||||
|
if (reason === 'speech') {
|
||||||
|
// Wechsel zu 'conversing' damit das Standard-Conversation-Flow greift
|
||||||
|
// (Brain-Response, TTS, resume etc.). Wake-Word bleibt aus (Mic belegt).
|
||||||
|
this.setState('conversing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeout oder manual → Wake-Word reaktivieren, armed-State.
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try {
|
try {
|
||||||
await OpenWakeWord.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
console.log('[WakeWord] zurueck zu armed nach passive-listen');
|
||||||
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||||
this.setState('armed');
|
this.setState('armed');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
console.warn('[WakeWord] re-arm nach passive-listen failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
|
||||||
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
|
|
||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cancelPassiveListenTimer(): void {
|
||||||
|
if (this.passiveListenTimer) {
|
||||||
|
clearTimeout(this.passiveListenTimer);
|
||||||
|
this.passiveListenTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf Passive-Listen-Events: feuert wenn der Service in den
|
||||||
|
* passiven Modus eintritt. ChatScreen startet hier eine streaming-
|
||||||
|
* Aufnahme OHNE User-Bubble (passiv lauschen). */
|
||||||
|
onPassiveListen(callback: PassiveListenCallback): () => void {
|
||||||
|
this.passiveListenCallbacks.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.passiveListenCallbacks = this.passiveListenCallbacks.filter(c => c !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
||||||
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
||||||
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
||||||
@@ -473,7 +659,12 @@ class WakeWordService {
|
|||||||
|
|
||||||
private setState(state: WakeWordState): void {
|
private setState(state: WakeWordState): void {
|
||||||
if (this.state !== state) {
|
if (this.state !== state) {
|
||||||
|
const wasConversing = this.state === 'conversing';
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
// Re-Entry-Guard freigeben sobald wir 'conversing' verlassen — Zyklus ist durch
|
||||||
|
if (wasConversing && state !== 'conversing') {
|
||||||
|
this.detectionInProgress = false;
|
||||||
|
}
|
||||||
this.stateCallbacks.forEach(cb => cb(state));
|
this.stateCallbacks.forEach(cb => cb(state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-1
@@ -127,6 +127,25 @@ META_TOOLS = [
|
|||||||
"items": {"type": "object"},
|
"items": {"type": "object"},
|
||||||
"description": "Argumente-Schema [{name, type, required, description}]",
|
"description": "Argumente-Schema [{name, type, required, description}]",
|
||||||
},
|
},
|
||||||
|
"fast_patterns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": (
|
||||||
|
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
|
||||||
|
"pause/next, Rollade hoch/runter etc.) eine Liste von "
|
||||||
|
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
|
||||||
|
"match (anchored Regex, case-insensitive) matched, ruft das "
|
||||||
|
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
|
||||||
|
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
|
||||||
|
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
|
||||||
|
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
|
||||||
|
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
|
||||||
|
"Skills mit kreativem Output / parametrisierter Logik — die "
|
||||||
|
"brauchen Claude. Beispiel: "
|
||||||
|
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
|
||||||
|
"\"reply\":\"Spotify: pausiert ⏸\"}]"
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name", "description", "entry_code"],
|
"required": ["name", "description", "entry_code"],
|
||||||
},
|
},
|
||||||
@@ -193,6 +212,16 @@ META_TOOLS = [
|
|||||||
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"fast_patterns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": (
|
||||||
|
"Optional komplette Fast-Path-Patterns-Liste UEBERSCHREIBEN — "
|
||||||
|
"[{match, args, reply}]. Siehe skill_create-Beschreibung fuer "
|
||||||
|
"Format. Leere Liste = alle Fast-Paths entfernen (alles geht "
|
||||||
|
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
},
|
},
|
||||||
@@ -782,6 +811,28 @@ META_TOOLS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
|
||||||
|
# von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
|
||||||
|
# ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
|
||||||
|
# returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
|
||||||
|
# 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
|
||||||
|
#
|
||||||
|
# Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
|
||||||
|
# case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
|
||||||
|
# eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
|
||||||
|
#
|
||||||
|
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
|
||||||
|
# skill_create / skill_update, das Brain orchestriert nur.
|
||||||
|
|
||||||
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
|
norm = (text or "").strip().lower()
|
||||||
|
norm = re.sub(r"[.!?]+$", "", norm)
|
||||||
|
norm = re.sub(r"\s+", " ", norm)
|
||||||
|
return norm
|
||||||
|
|
||||||
|
|
||||||
def _skill_to_tool(s: dict) -> dict:
|
def _skill_to_tool(s: dict) -> dict:
|
||||||
"""Mappt einen Skill auf ein OpenAI-Function-Tool."""
|
"""Mappt einen Skill auf ein OpenAI-Function-Tool."""
|
||||||
args = s.get("args") or []
|
args = s.get("args") or []
|
||||||
@@ -849,6 +900,54 @@ class Agent:
|
|||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
|
||||||
|
"""Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
|
||||||
|
gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
|
||||||
|
wird direkt aufgerufen, Reply geht ohne Claude zurueck.
|
||||||
|
|
||||||
|
Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
|
||||||
|
(ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
|
||||||
|
zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
|
||||||
|
Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
|
||||||
|
durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
|
||||||
|
norm = _normalize_for_fast_match(user_message)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
|
||||||
|
active_skills = [s for s in skills_mod.list_skills(active_only=False)
|
||||||
|
if s.get("active", True)]
|
||||||
|
for skill in active_skills:
|
||||||
|
patterns = skill.get("fast_patterns") or []
|
||||||
|
if not patterns:
|
||||||
|
continue
|
||||||
|
skill_name = skill.get("name") or ""
|
||||||
|
for pat in patterns:
|
||||||
|
rx = pat.get("match") or ""
|
||||||
|
if not rx:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not re.match(rx, norm, re.IGNORECASE):
|
||||||
|
continue
|
||||||
|
except re.error:
|
||||||
|
# Sollte durch _normalize_fast_patterns rausgefiltert sein.
|
||||||
|
continue
|
||||||
|
args = pat.get("args") or {}
|
||||||
|
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||||
|
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||||
|
skill_name, rx, user_message[:60])
|
||||||
|
try:
|
||||||
|
res = skills_mod.run_skill(skill_name, dict(args), timeout_sec=15)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
|
||||||
|
skill_name, exc)
|
||||||
|
return None
|
||||||
|
if not res.get("ok"):
|
||||||
|
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
||||||
|
hint = (tail[-1] if tail else "")[:120]
|
||||||
|
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
||||||
|
return reply
|
||||||
|
return None
|
||||||
|
|
||||||
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||||
|
|
||||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
||||||
@@ -861,6 +960,15 @@ class Agent:
|
|||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
|
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||||
|
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||||
|
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||||
|
fast_reply = self._try_skill_fast_path(user_message)
|
||||||
|
if fast_reply is not None:
|
||||||
|
self.conversation.add("user", user_message, source=source)
|
||||||
|
self.conversation.add("assistant", fast_reply)
|
||||||
|
return fast_reply
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 1. User-Turn an die Konversation
|
||||||
self.conversation.add("user", user_message, source=source)
|
self.conversation.add("user", user_message, source=source)
|
||||||
|
|
||||||
@@ -940,11 +1048,19 @@ class Agent:
|
|||||||
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
||||||
for tc in result.tool_calls:
|
for tc in result.tool_calls:
|
||||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
||||||
|
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
|
||||||
|
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
|
||||||
|
# viel zu wenig: Spotify _all=true mit 90 Playlists
|
||||||
|
# liefert ~34 KB compact, das wurde hier auf 8 KB
|
||||||
|
# zugeschnitten und ARIA glaubte die Liste sei
|
||||||
|
# abgeschnitten obwohl der Skill alles korrekt
|
||||||
|
# paginiert hatte. Claude-Context vertraegt locker
|
||||||
|
# 50 KB pro Tool-Result.
|
||||||
messages.append(ProxyMessage(
|
messages.append(ProxyMessage(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=tc["id"],
|
tool_call_id=tc["id"],
|
||||||
name=tc["name"],
|
name=tc["name"],
|
||||||
content=tool_result[:8000],
|
content=tool_result[:50000],
|
||||||
))
|
))
|
||||||
continue # next iteration mit Tool-Results
|
continue # next iteration mit Tool-Results
|
||||||
# Kein Tool-Call mehr → final reply
|
# Kein Tool-Call mehr → final reply
|
||||||
@@ -993,6 +1109,7 @@ class Agent:
|
|||||||
args=arguments.get("args", []),
|
args=arguments.get("args", []),
|
||||||
pip_packages=arguments.get("pip_packages", []),
|
pip_packages=arguments.get("pip_packages", []),
|
||||||
config_schema=arguments.get("config_schema") or None,
|
config_schema=arguments.get("config_schema") or None,
|
||||||
|
fast_patterns=arguments.get("fast_patterns") or None,
|
||||||
author="aria",
|
author="aria",
|
||||||
)
|
)
|
||||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||||
@@ -1056,6 +1173,8 @@ class Agent:
|
|||||||
patch["pip_packages"] = arguments["pip_packages"]
|
patch["pip_packages"] = arguments["pip_packages"]
|
||||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||||
patch["config_schema"] = arguments["config_schema"]
|
patch["config_schema"] = arguments["config_schema"]
|
||||||
|
if "fast_patterns" in arguments and isinstance(arguments["fast_patterns"], list):
|
||||||
|
patch["fast_patterns"] = arguments["fast_patterns"]
|
||||||
if not patch:
|
if not patch:
|
||||||
return "FEHLER: keine Felder zum Update angegeben."
|
return "FEHLER: keine Felder zum Update angegeben."
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,6 +45,54 @@ logger = logging.getLogger("aria-brain")
|
|||||||
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
|
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
|
||||||
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||||
|
|
||||||
|
def _seed_spotify_fast_patterns() -> None:
|
||||||
|
"""One-shot Migration: schreibt Standard-Steuer-Patterns ins Spotify-Skill
|
||||||
|
wenn das Skill existiert + aktiv ist + noch keine fast_patterns hat.
|
||||||
|
|
||||||
|
Nach diesem Run kann ARIA die Patterns frei via skill_update aendern."""
|
||||||
|
manifest = skills_mod.read_manifest("spotify")
|
||||||
|
if not manifest:
|
||||||
|
logger.info("[migrate] spotify skill nicht vorhanden — nichts zu tun")
|
||||||
|
return
|
||||||
|
if manifest.get("fast_patterns"):
|
||||||
|
logger.info("[migrate] spotify hat schon fast_patterns (%d) — skip",
|
||||||
|
len(manifest["fast_patterns"]))
|
||||||
|
return
|
||||||
|
default_patterns = [
|
||||||
|
# NEXT
|
||||||
|
{"match": r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$",
|
||||||
|
"args": {"path": "/v1/me/player/next", "method": "POST"},
|
||||||
|
"reply": "Spotify: nächster Track ⏭"},
|
||||||
|
{"match": r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$",
|
||||||
|
"args": {"path": "/v1/me/player/next", "method": "POST"},
|
||||||
|
"reply": "Spotify: nächster Track ⏭"},
|
||||||
|
# PREVIOUS
|
||||||
|
{"match": r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$",
|
||||||
|
"args": {"path": "/v1/me/player/previous", "method": "POST"},
|
||||||
|
"reply": "Spotify: vorheriger Track ⏮"},
|
||||||
|
{"match": r"^(zurueck|zurück)$",
|
||||||
|
"args": {"path": "/v1/me/player/previous", "method": "POST"},
|
||||||
|
"reply": "Spotify: vorheriger Track ⏮"},
|
||||||
|
# PAUSE
|
||||||
|
{"match": r"^(pause|pausiere|pausieren|stop|stopp|halt)$",
|
||||||
|
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
|
||||||
|
"reply": "Spotify: pausiert ⏸"},
|
||||||
|
{"match": r"^(musik|spotify) (pause|aus|stop|stopp)$",
|
||||||
|
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
|
||||||
|
"reply": "Spotify: pausiert ⏸"},
|
||||||
|
# PLAY
|
||||||
|
{"match": r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$",
|
||||||
|
"args": {"path": "/v1/me/player/play", "method": "PUT"},
|
||||||
|
"reply": "Spotify: spielt ▶"},
|
||||||
|
{"match": r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$",
|
||||||
|
"args": {"path": "/v1/me/player/play", "method": "PUT"},
|
||||||
|
"reply": "Spotify: spielt ▶"},
|
||||||
|
]
|
||||||
|
skills_mod.update_skill("spotify", {"fast_patterns": default_patterns})
|
||||||
|
logger.info("[migrate] spotify fast_patterns gesetzt (%d Eintraege)",
|
||||||
|
len(default_patterns))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
|
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
|
||||||
@@ -54,6 +102,15 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Lifespan: seed_rules angewendet (%s)", result)
|
logger.info("Lifespan: seed_rules angewendet (%s)", result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
|
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
|
||||||
|
|
||||||
|
# Einmalige Migration: Spotify-Skill ohne fast_patterns kriegt die Standard-
|
||||||
|
# Patterns injiziert. Idempotent — wenn schon welche da sind, nichts tun.
|
||||||
|
# ARIA kann sie spaeter via skill_update beliebig erweitern/ersetzen.
|
||||||
|
try:
|
||||||
|
_seed_spotify_fast_patterns()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc)
|
||||||
|
|
||||||
task = asyncio.create_task(background_mod.run_loop(agent))
|
task = asyncio.create_task(background_mod.run_loop(agent))
|
||||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -131,6 +131,54 @@ SEED_RULES: List[dict] = [
|
|||||||
"Skill-Friedhof und Stefan muss aufraeumen."
|
"Skill-Friedhof und Stefan muss aufraeumen."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/fast-patterns-for-control",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: fast_patterns fuer reines Steuern (spart 5-10s Latenz)",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn Du einen Skill baust oder aktualisierst, der **reine Steuer-"
|
||||||
|
"Befehle** behandelt (Licht an/aus, Spotify pause/next, Rollade "
|
||||||
|
"hoch/runter, Heizung +1°), trag ins Manifest `fast_patterns` ein. "
|
||||||
|
"Format pro Eintrag: `{match: \"^regex$\", args: {...}, reply: \"Text\"}`.\n"
|
||||||
|
"\n"
|
||||||
|
"Wirkung: das Brain matched eingehende User-Texte BEVOR Claude gerufen "
|
||||||
|
"wird. Match → run_skill(name, args) direkt → reply zurueck → Claude "
|
||||||
|
"uebersprungen. Stefan spart 5-10 Sekunden pro Befehl. Praktisch "
|
||||||
|
"Pflicht im Auto, wo Latenz nervt.\n"
|
||||||
|
"\n"
|
||||||
|
"REGELN beim Patterns schreiben:\n"
|
||||||
|
" - Mit `^` und `$` anchorn — sonst matched `pause` mitten in `pause "
|
||||||
|
"die musik dann erzaehl mir nen witz` und zerschiesst den Befehl.\n"
|
||||||
|
" - Case-insensitive (Brain matched mit re.IGNORECASE), Endsatzzeichen "
|
||||||
|
"werden vorher entfernt — schreibe Lowercase ohne Punkt.\n"
|
||||||
|
" - Mehrere Varianten = mehrere Eintraege (`^pause$`, `^pausiere$`, "
|
||||||
|
"`^stop$`). Sprachlich wechselt Stefan zwischen synonymen Kurzformen.\n"
|
||||||
|
" - reply = kurze Bestaetigung in genau einem Satz, gerne mit Emoji.\n"
|
||||||
|
"\n"
|
||||||
|
"NIE fast_patterns fuer:\n"
|
||||||
|
" - Skills mit kreativem Output (zusammenfassen, generieren, raten).\n"
|
||||||
|
" - Skills mit Parametern die aus Freitext extrahiert werden muessten "
|
||||||
|
" ('spiele jazz' geht nicht — was ist 'jazz'? Lass Claude entscheiden).\n"
|
||||||
|
" - Skills mit Multi-Step-Logik (z.B. Volumen +10 = erst Status holen, "
|
||||||
|
" rechnen, setzen). Wenn unbedingt: in den Skill-Code packen und "
|
||||||
|
" dem Skill einen `action`-Arg geben.\n"
|
||||||
|
"\n"
|
||||||
|
"Beispiel komplett:\n"
|
||||||
|
"```\n"
|
||||||
|
"fast_patterns = [\n"
|
||||||
|
" {\"match\": \"^pause$\",\n"
|
||||||
|
" \"args\": {\"path\": \"/v1/me/player/pause\", \"method\": \"PUT\"},\n"
|
||||||
|
" \"reply\": \"Spotify: pausiert ⏸\"}\n"
|
||||||
|
"]\n"
|
||||||
|
"```\n"
|
||||||
|
"\n"
|
||||||
|
"Stefan-Hinweis 06/2026: das war frueher hardcoded in agent.py fuer "
|
||||||
|
"Spotify und musste fuer jeden neuen Steuer-Skill nachgepflegt werden. "
|
||||||
|
"Jetzt steckt's pro Skill im Manifest — dein Job, ARIA, das gleich "
|
||||||
|
"mitzudenken wenn der Use-Case passt."
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
|
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
|
||||||
"type": "rule",
|
"type": "rule",
|
||||||
@@ -602,6 +650,135 @@ SEED_RULES: List[dict] = [
|
|||||||
"'API Key' im Auth-Kapitel). Nicht raten."
|
"'API Key' im Auth-Kapitel). Nicht raten."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/voice/tts-voice-tag",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "TTS-sprechbar: `<voice>...</voice>`-Tag fuer Antworten mit Einheiten/Zahlen/Markdown",
|
||||||
|
"category": "voice",
|
||||||
|
"content": (
|
||||||
|
"Die App spielt jede ARIA-Antwort als TTS ab. Der Brain-Bridge "
|
||||||
|
"filtert Markdown raus (Sternchen, Code-Bloecke, URLs), kennt "
|
||||||
|
"aber keine Einheiten-/Zahlen-Konvention — der Sprecher liest "
|
||||||
|
"dann '15 kt' als 'fuenfzehn k t' und '23,5°C' als 'dreiund-"
|
||||||
|
"zwanzig komma fuenf grad c'. Klingt scheisse.\n"
|
||||||
|
"\n"
|
||||||
|
"LOESUNG: Wenn deine Antwort eine der folgenden Eigenschaften hat, "
|
||||||
|
"haenge einen `<voice>...</voice>`-Block ans ENDE der Antwort. "
|
||||||
|
"Was DRIN steht ersetzt komplett den TTS-Text — Markdown im "
|
||||||
|
"Chat-Display bleibt unangetastet, gesprochen wird ausschliess-"
|
||||||
|
"lich die <voice>-Variante.\n"
|
||||||
|
"\n"
|
||||||
|
"WANN <voice>-Tag setzen:\n"
|
||||||
|
" - Einheiten-Abkuerzungen: kt, kg, km/h, °C, hPa, mbar, mph, "
|
||||||
|
" psi, dB, GB, MB, kWh, mAh ...\n"
|
||||||
|
" - Zahlen mit Komma (23,5 → 'dreiundzwanzig komma fuenf')\n"
|
||||||
|
" - Uhrzeiten mit Minuten (8:42 → 'acht Uhr zweiundvierzig')\n"
|
||||||
|
" - Wettervorhersagen / Statusberichte mit mehreren Daten\n"
|
||||||
|
" - Tabellen oder Listen mit Werten\n"
|
||||||
|
" - Lange Zahlen / IDs / Codes ('spotify:playlist:abc' nicht "
|
||||||
|
" vorlesen)\n"
|
||||||
|
" - Code-Bloecke (sollte ARIA in Sprache eh nicht zitieren)\n"
|
||||||
|
"\n"
|
||||||
|
"WANN NICHT (Overhead vermeiden):\n"
|
||||||
|
" - Kurze Statussaetze ('OK', 'mach ich', 'klar', 'spielt')\n"
|
||||||
|
" - Reine Prosa ohne Zahlen oder Einheiten\n"
|
||||||
|
" - Antworten unter 15 Worten ohne komplexes Element\n"
|
||||||
|
"\n"
|
||||||
|
"FORMAT:\n"
|
||||||
|
" Erst die Chat-Display-Variante (mit Markdown OK), dann an einer "
|
||||||
|
" neuen Zeile der <voice>-Block:\n"
|
||||||
|
"\n"
|
||||||
|
" Antwort-Text mit **Markdown**, Zahlen, Einheiten\n"
|
||||||
|
" <voice>Antwort-Text fuer den Lautsprecher, ausgeschrieben</voice>\n"
|
||||||
|
"\n"
|
||||||
|
"BEISPIEL Wetter:\n"
|
||||||
|
" **Wetter Berlin:** 23,5°C, Wind 15 kt aus NW, Druck 1018 hPa.\n"
|
||||||
|
" <voice>Das Wetter in Berlin: dreiundzwanzig Grad fuenf, "
|
||||||
|
" Wind mit fuenfzehn Knoten aus Nordwest, Luftdruck "
|
||||||
|
" tausendachtzehn Hektopascal.</voice>\n"
|
||||||
|
"\n"
|
||||||
|
"BEISPIEL Uhrzeit:\n"
|
||||||
|
" Stefan, dein Termin ist um **8:42** — noch 25 Minuten.\n"
|
||||||
|
" <voice>Stefan, dein Termin ist um acht Uhr zweiundvierzig. "
|
||||||
|
" Du hast noch fuenfundzwanzig Minuten.</voice>\n"
|
||||||
|
"\n"
|
||||||
|
"BEISPIEL Akku/Speicher:\n"
|
||||||
|
" Server: 87% Last, 12,4 GB RAM frei, Uptime 142h.\n"
|
||||||
|
" <voice>Server bei siebenundachtzig Prozent Last, zwoelf "
|
||||||
|
" Komma vier Gigabyte RAM frei, Laufzeit hundertzweiundvierzig "
|
||||||
|
" Stunden.</voice>\n"
|
||||||
|
"\n"
|
||||||
|
"BEISPIEL Multi-Track (NICHT vorlesen was nicht sprechbar ist):\n"
|
||||||
|
" Spielt jetzt: **Firestarter** (3:47) auf duffy-desktop.\n"
|
||||||
|
" <voice>Spielt jetzt Firestarter, drei Minuten siebenund-"
|
||||||
|
" vierzig.</voice> ← Device weglassen, war im Chat zur Info, "
|
||||||
|
" fuer Stefan akustisch redundant\n"
|
||||||
|
"\n"
|
||||||
|
"Der Voice-Tag wird automatisch aus Chat-Bubble und Chat-Backup "
|
||||||
|
"gestrippt — Stefan sieht NUR die Markdown-Variante in der App. "
|
||||||
|
"Voice-Text geht ausschliesslich an F5-TTS. Beide Welten happy.\n"
|
||||||
|
"\n"
|
||||||
|
"Sicherheitsnetz: wenn Du den Tag mal vergisst, faellt clean_text_"
|
||||||
|
"for_tts auf die alte Regex-Cleanup-Pipeline zurueck (Markdown weg, "
|
||||||
|
"Uhrzeiten teilweise ausgeschrieben). Aber 'kt' wird dann literal "
|
||||||
|
"vorgelesen. Also: lieber Tag setzen wenn unsicher."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/list-api-pagination-snapshot",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Listen-API: einmal vollstaendig laden, DANN entscheiden",
|
||||||
|
"category": "verhalten",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein Tool-Resultat ein Pagination-Schema hat (limit/offset/"
|
||||||
|
"next oder total > limit): ALLE Seiten in EINEM Tool-Call holen, "
|
||||||
|
"in EINEM Snapshot durchsuchen, ERST DANN handeln.\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern (31.05.2026, Stefan reproduziert mit 'Playlist Prodigy "
|
||||||
|
"raussuchen'):\n"
|
||||||
|
" - run_spotify path=/v1/me/playlists?limit=50\n"
|
||||||
|
" → 'nicht dabei'\n"
|
||||||
|
" - run_spotify path=/v1/me/playlists?limit=50&offset=50\n"
|
||||||
|
" → 'gefunden, ID=X' (46 Tracks)\n"
|
||||||
|
" - run_spotify path=/v1/me/player/play body={context_uri: ...:X}\n"
|
||||||
|
" → spielt aber FALSCHE Playlist\n"
|
||||||
|
" - Neue Suche, wieder paginiert → drittes Match ID=Y (15 Tracks)\n"
|
||||||
|
" - Insgesamt drei verschiedene IDs fuer dieselbe gesuchte Playlist\n"
|
||||||
|
" generiert, am Ende die falsche gespielt.\n"
|
||||||
|
"\n"
|
||||||
|
"Wurzel: Spotify sortiert /v1/me/playlists nach recently-played. "
|
||||||
|
"Zwischen aufeinanderfolgenden paginierten Calls AENDERT SICH die "
|
||||||
|
"Reihenfolge wenn parallel was abgespielt wird. Teilresultate aus "
|
||||||
|
"verschiedenen Calls vergleichen → inkonsistent.\n"
|
||||||
|
"\n"
|
||||||
|
"Richtig fuer Spotify (seit 31.05.2026 unterstuetzt):\n"
|
||||||
|
" run_spotify path=/v1/me/playlists?limit=50&_all=true\n"
|
||||||
|
" → Skill paginiert intern, liefert {items, total, fetched_count}.\n"
|
||||||
|
" → In items[] suchen, EINE ID waehlen, sofort handeln.\n"
|
||||||
|
" → Match-Logik: bevorzugt exakter Name (case-insensitive). "
|
||||||
|
"Wenn mehrere Substring-Matches: explizit nachfragen statt raten.\n"
|
||||||
|
"\n"
|
||||||
|
"Wann _all=true sinnvoll:\n"
|
||||||
|
" - /v1/me/playlists (alle eigenen Playlists)\n"
|
||||||
|
" - /v1/playlists/{id}/tracks (alle Tracks einer Playlist)\n"
|
||||||
|
" - /v1/me/tracks (Liked Songs)\n"
|
||||||
|
" - /v1/search?type=playlist&q=... (Such-Ergebnisse mit next)\n"
|
||||||
|
" - Andere Endpunkte mit items+next-Schema.\n"
|
||||||
|
"\n"
|
||||||
|
"Wann NICHT _all=true:\n"
|
||||||
|
" - /v1/me/player/currently-playing (kein Listen-Endpunkt)\n"
|
||||||
|
" - /v1/me/player/devices (kurze Liste, kein next)\n"
|
||||||
|
" - Wenn Du explizit nur 'die ersten 10' willst.\n"
|
||||||
|
"\n"
|
||||||
|
"Fuer andere Skills (yt-dlp, andere APIs) die noch kein _all "
|
||||||
|
"unterstuetzen: manuell paginieren bis total erreicht, ALLES in "
|
||||||
|
"EINEM mentalen Snapshot mergen, NIEMALS auf Teilresultaten "
|
||||||
|
"Entscheidungen treffen. Wenn zwei Pagination-Runs unterschiedliche "
|
||||||
|
"Matches liefern: ehrlich melden ('zwei verschiedene Playlists "
|
||||||
|
"namens X gefunden — welche meinst Du?') statt sich auf eine "
|
||||||
|
"festzulegen."
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+57
-5
@@ -164,6 +164,7 @@ def create_skill(
|
|||||||
pip_packages: Optional[list[str]] = None,
|
pip_packages: Optional[list[str]] = None,
|
||||||
author: str = "aria",
|
author: str = "aria",
|
||||||
config_schema: Optional[list] = None,
|
config_schema: Optional[list] = None,
|
||||||
|
fast_patterns: Optional[list] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||||
|
|
||||||
@@ -213,6 +214,7 @@ def create_skill(
|
|||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"author": author,
|
"author": author,
|
||||||
"config_schema": _normalize_config_schema(config_schema),
|
"config_schema": _normalize_config_schema(config_schema),
|
||||||
|
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||||
"version_history": [],
|
"version_history": [],
|
||||||
}
|
}
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
@@ -261,6 +263,38 @@ def _normalize_config_schema(schema: Optional[list]) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fast_patterns(patterns: Optional[list]) -> list:
|
||||||
|
"""Filter + Normalisiert fast_patterns. Erwartet Liste von Dicts mit:
|
||||||
|
- match (str) : Regex, wird gegen normalisierten User-Text (lowercase,
|
||||||
|
Endsatzzeichen weg, Whitespace gestrafft) gematched.
|
||||||
|
Sollte mit ^...$ anchored sein damit keine Teilmatches
|
||||||
|
reinrutschen. re.IGNORECASE wird automatisch gesetzt.
|
||||||
|
- args (dict?): Args fuer run_skill — leerer Dict wenn weggelassen.
|
||||||
|
- reply (str) : Fixe Antwort die ohne Claude an den User geht.
|
||||||
|
|
||||||
|
Patterns mit kaputter Regex werden ausgefiltert + geloggt — sonst wuerde
|
||||||
|
der ganze Fast-Path-Pass jedes Mal crashen wenn ARIA mal ein Pattern
|
||||||
|
falsch baut."""
|
||||||
|
if not patterns:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in patterns:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
match = (p.get("match") or "").strip()
|
||||||
|
reply = (p.get("reply") or "").strip()
|
||||||
|
if not match or not reply:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
re.compile(match)
|
||||||
|
except re.error as exc:
|
||||||
|
logger.warning("fast_patterns: Regex %r kaputt — geskippt: %s", match, exc)
|
||||||
|
continue
|
||||||
|
args = p.get("args") if isinstance(p.get("args"), dict) else {}
|
||||||
|
out.append({"match": match, "args": args, "reply": reply[:300]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
||||||
venv = skill_dir / "venv"
|
venv = skill_dir / "venv"
|
||||||
logger.info("venv erstellen: %s", venv)
|
logger.info("venv erstellen: %s", venv)
|
||||||
@@ -307,6 +341,8 @@ def update_skill(name: str, patch: dict) -> dict:
|
|||||||
manifest[k] = v
|
manifest[k] = v
|
||||||
if "config_schema" in patch:
|
if "config_schema" in patch:
|
||||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||||
|
if "fast_patterns" in patch:
|
||||||
|
manifest["fast_patterns"] = _normalize_fast_patterns(patch["fast_patterns"])
|
||||||
|
|
||||||
# Code austauschen
|
# Code austauschen
|
||||||
if "entry_code" in patch and patch["entry_code"]:
|
if "entry_code" in patch and patch["entry_code"]:
|
||||||
@@ -683,8 +719,13 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
|||||||
timed_out = True
|
timed_out = True
|
||||||
duration = time.time() - t0
|
duration = time.time() - t0
|
||||||
|
|
||||||
# Log schreiben (gekuerzt damit es nicht explodiert)
|
# Log auf der Disk wird gekuerzt (8000 chars) — sonst sammeln sich
|
||||||
record = {
|
# logs/*.json mit MBs an grossen Skill-Outputs an. Der Return-Value
|
||||||
|
# an den Caller (Agent) bekommt aber den vollen Output, dort wird
|
||||||
|
# nochmal in agent.py auf 50000 gecappt. Stefan-Fall: spotify-Skill
|
||||||
|
# mit _all=true liefert 50+ KB JSON, das hier wurde vorher auf 8 KB
|
||||||
|
# gekappt → ARIA sah immer nur den Anfang der Liste.
|
||||||
|
log_record = {
|
||||||
"ts": _now(),
|
"ts": _now(),
|
||||||
"args": args or {},
|
"args": args or {},
|
||||||
"exit_code": exit_code,
|
"exit_code": exit_code,
|
||||||
@@ -694,7 +735,7 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
|||||||
"timed_out": timed_out,
|
"timed_out": timed_out,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
log_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
|
log_path.write_text(json.dumps(log_record, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -703,8 +744,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
|||||||
manifest["use_count"] = int(manifest.get("use_count", 0)) + 1
|
manifest["use_count"] = int(manifest.get("use_count", 0)) + 1
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
|
|
||||||
record["ok"] = exit_code == 0
|
# Return-Value: nicht kuerzen (Agent kuerzt downstream selbst). Nur
|
||||||
record["log_path"] = str(log_path)
|
# die Disk-Log-Variante war beschnitten.
|
||||||
|
record = {
|
||||||
|
"ts": log_record["ts"],
|
||||||
|
"args": log_record["args"],
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"duration_sec": log_record["duration_sec"],
|
||||||
|
"stdout": out_text or "",
|
||||||
|
"stderr": err_text or "",
|
||||||
|
"timed_out": timed_out,
|
||||||
|
"ok": exit_code == 0,
|
||||||
|
"log_path": str(log_path),
|
||||||
|
}
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+164
-9
@@ -208,6 +208,30 @@ _UNIT_WORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def strip_voice_tag_for_display(text: str) -> str:
|
||||||
|
"""Entfernt `<voice>...</voice>`-Bloecke aus dem Chat-Display-Text.
|
||||||
|
|
||||||
|
ARIA kann einen <voice>-Block ANHAENGEN um eine TTS-freundliche Variante
|
||||||
|
ihrer Antwort zu liefern (Zahlen ausgeschrieben, Einheiten als Wort,
|
||||||
|
Markdown entfernt). Der Block wird dann von clean_text_for_tts als
|
||||||
|
TTS-Quelle benutzt — fuer die Chat-Bubble in der App soll er aber NICHT
|
||||||
|
sichtbar sein, sonst sieht Stefan literal '<voice>...' in seinem Chat.
|
||||||
|
|
||||||
|
Beispiel-Input (Stefan-typisch fuer Wetterbericht):
|
||||||
|
'**Wetter:** 23,5°C, Wind 15 kt NW\\n<voice>Wetter: dreiundzwanzig
|
||||||
|
komma fuenf Grad, Wind fuenfzehn Knoten Nordwest.</voice>'
|
||||||
|
Output:
|
||||||
|
'**Wetter:** 23,5°C, Wind 15 kt NW'
|
||||||
|
|
||||||
|
Mehrere Voice-Bloecke werden alle entfernt (ARIA koennte theoretisch
|
||||||
|
mehrere setzen, machen wir robust). Trailing-Whitespace nach dem Block
|
||||||
|
auch wegtrimmen.
|
||||||
|
"""
|
||||||
|
if not text or "<voice>" not in text.lower():
|
||||||
|
return text
|
||||||
|
return _re_tts.sub(r'\s*<voice>[\s\S]*?</voice>\s*', '\n', text, flags=_re_tts.IGNORECASE).strip()
|
||||||
|
|
||||||
|
|
||||||
def clean_text_for_tts(text: str) -> str:
|
def clean_text_for_tts(text: str) -> str:
|
||||||
"""Bereitet Chat-Text fuer Sprachausgabe auf.
|
"""Bereitet Chat-Text fuer Sprachausgabe auf.
|
||||||
|
|
||||||
@@ -1150,11 +1174,15 @@ class ARIABridge:
|
|||||||
f"aber nicht erstellt:\n{missing_list}\n"
|
f"aber nicht erstellt:\n{missing_list}\n"
|
||||||
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
|
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
|
||||||
|
|
||||||
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker)
|
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker
|
||||||
|
# UND ohne <voice>-Tag — der ist eine TTS-Annotation, gehoert nicht in
|
||||||
|
# die Chat-Historie weil ARIA ihre eigene Vorgaenger-Antwort sonst mit
|
||||||
|
# Voice-Tag-Noise als Kontext sieht).
|
||||||
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
|
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
|
||||||
|
display_text = strip_voice_tag_for_display(text)
|
||||||
assistant_backup_ts = self._append_chat_backup({
|
assistant_backup_ts = self._append_chat_backup({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": text,
|
"text": display_text,
|
||||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||||
})
|
})
|
||||||
@@ -1181,11 +1209,14 @@ class ARIABridge:
|
|||||||
# TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional)
|
# TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional)
|
||||||
tts_text_preview = clean_text_for_tts(text)
|
tts_text_preview = clean_text_for_tts(text)
|
||||||
|
|
||||||
# Antwort an die App weiterleiten (als Chat-Nachricht)
|
# Antwort an die App weiterleiten (als Chat-Nachricht).
|
||||||
|
# display_text == text aber ohne <voice>-Tag — der lebt nur transient
|
||||||
|
# in `text` damit clean_text_for_tts weiter unten daraus die TTS-
|
||||||
|
# Variante zieht. Im Chat-Bubble soll der Tag nicht erscheinen.
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
"payload": {
|
"payload": {
|
||||||
"text": text,
|
"text": display_text,
|
||||||
"sender": "aria",
|
"sender": "aria",
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
|
||||||
@@ -1575,11 +1606,12 @@ class ARIABridge:
|
|||||||
try:
|
try:
|
||||||
url = f"{current_url}?token={self.rvs_token}"
|
url = f"{current_url}?token={self.rvs_token}"
|
||||||
logger.info("[rvs] Verbinde: %s", current_url)
|
logger.info("[rvs] Verbinde: %s", current_url)
|
||||||
# max_size=100MB synchron zum RVS-Server (siehe rvs/server.js).
|
# max_size=1500MB synchron zum RVS-Server (siehe rvs/server.js).
|
||||||
# File-Re-Download fuer Anhaenge braucht Platz fuer base64-
|
# File-Re-Download fuer Anhaenge braucht Platz fuer base64-
|
||||||
# inflate (~1.33×). Groessere Files lehnt der file_request-
|
# inflate (~1.33×) — 1 GB binaer ≈ 1.34 GB base64, plus Margin.
|
||||||
# Handler proaktiv ab bevor's zur 1009-Disconnection kommt.
|
# Groessere Files lehnt der file_request-Handler proaktiv ab
|
||||||
async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws:
|
# bevor's zur 1009-Disconnection kommt.
|
||||||
|
async with websockets.connect(url, max_size=1500 * 1024 * 1024) as ws:
|
||||||
self.ws_rvs = ws
|
self.ws_rvs = ws
|
||||||
retry_delay = 2
|
retry_delay = 2
|
||||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||||
@@ -2374,6 +2406,129 @@ class ARIABridge:
|
|||||||
logger.warning("[rvs] file_delete_request: %s", e)
|
logger.warning("[rvs] file_delete_request: %s", e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "file_version_list_request":
|
||||||
|
# Versions-Historie einer Datei (App-Side Dateimanager).
|
||||||
|
# Pfad ist relativ-zu-/shared/uploads, kommt vom App-File-Manager
|
||||||
|
# der eh nur diesen flachen Bereich anzeigt. Diagnostic hat die
|
||||||
|
# git-Logik — wir proxien.
|
||||||
|
req_path = payload.get("path", "")
|
||||||
|
logger.info("[rvs] file_version_list_request: %s", req_path)
|
||||||
|
try:
|
||||||
|
qs = urllib.parse.urlencode({"path": req_path})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://localhost:3001/api/files-versions?{qs}",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
def _do_list():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8", errors="ignore"))
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
d = await asyncio.get_event_loop().run_in_executor(None, _do_list)
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_version_list_response",
|
||||||
|
"payload": d,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_version_list_request: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type == "file_version_download_request":
|
||||||
|
# Inhalt einer alten Version holen, base64 zurueck. Diagnostic
|
||||||
|
# liefert Binary, wir wrappen als base64 in der Response damit
|
||||||
|
# die App's RVS-WS damit umgehen kann.
|
||||||
|
req_path = payload.get("path", "")
|
||||||
|
req_hash = payload.get("hash", "")
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
logger.info("[rvs] file_version_download_request: %s @ %s",
|
||||||
|
req_path, req_hash[:7])
|
||||||
|
try:
|
||||||
|
qs = urllib.parse.urlencode({"path": req_path, "hash": req_hash})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://localhost:3001/api/files-version-content?{qs}",
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
def _do_dl():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read()
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e).encode("utf-8")
|
||||||
|
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_dl)
|
||||||
|
if status == 200 and isinstance(body, (bytes, bytearray)):
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_version_download_response",
|
||||||
|
"payload": {
|
||||||
|
"ok": True,
|
||||||
|
"requestId": req_id,
|
||||||
|
"path": req_path,
|
||||||
|
"hash": req_hash,
|
||||||
|
"base64": base64.b64encode(body).decode("ascii"),
|
||||||
|
"size": len(body),
|
||||||
|
"name": (req_path.rsplit("/", 1)[-1] or "file"),
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
err = body.decode("utf-8", "ignore") if isinstance(body, (bytes, bytearray)) else str(body)
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_version_download_response",
|
||||||
|
"payload": {
|
||||||
|
"ok": False,
|
||||||
|
"requestId": req_id,
|
||||||
|
"path": req_path,
|
||||||
|
"hash": req_hash,
|
||||||
|
"error": f"HTTP {status}: {err[:200]}",
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_version_download_request: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
elif msg_type == "file_version_restore_request":
|
||||||
|
# Eine Version als neue aktive setzen — non-destructive
|
||||||
|
# (diagnostic schreibt den alten Inhalt + macht einen neuen Commit).
|
||||||
|
req_path = payload.get("path", "")
|
||||||
|
req_hash = payload.get("hash", "")
|
||||||
|
logger.warning("[rvs] file_version_restore_request: %s <- %s",
|
||||||
|
req_path, req_hash[:7])
|
||||||
|
try:
|
||||||
|
body_bytes = json.dumps({"path": req_path, "hash": req_hash}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://localhost:3001/api/files-version-restore",
|
||||||
|
data=body_bytes,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
def _do_restore():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return resp.status, resp.read().decode("utf-8", errors="ignore")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", errors="ignore")
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restore)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body) if body else {"ok": False, "error": "leer"}
|
||||||
|
except Exception:
|
||||||
|
parsed = {"ok": False, "error": body[:200]}
|
||||||
|
if status != 200 and "ok" not in parsed:
|
||||||
|
parsed = {"ok": False, "error": f"HTTP {status}"}
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "file_version_restore_response",
|
||||||
|
"payload": parsed,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[rvs] file_version_restore_request: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "location_update":
|
elif msg_type == "location_update":
|
||||||
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
|
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
|
||||||
# /shared/state/location.json geschrieben, damit Watcher-Trigger
|
# /shared/state/location.json geschrieben, damit Watcher-Trigger
|
||||||
@@ -2440,7 +2595,7 @@ class ARIABridge:
|
|||||||
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
|
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
|
||||||
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
|
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
|
||||||
# und Bilder ab; alles drueber soll der User per SSH abholen.
|
# und Bilder ab; alles drueber soll der User per SSH abholen.
|
||||||
FILE_MAX_BYTES = 70 * 1024 * 1024
|
FILE_MAX_BYTES = 1024 * 1024 * 1024 # 1 GB binaer
|
||||||
try:
|
try:
|
||||||
file_size = os.path.getsize(server_path)
|
file_size = os.path.getsize(server_path)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
|
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
|
||||||
RUN apk add --no-cache zip
|
# git fuer Auto-Versionierung von /shared/uploads/ (siehe server.js)
|
||||||
|
RUN apk add --no-cache zip git
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --production
|
RUN npm install --production
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -764,6 +764,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Voice-ID (Sprecher-Erkennung) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Voice-ID (Sprecher-Erkennung)</h2>
|
||||||
|
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||||
|
ARIA erkennt Stefans Stimme anhand eines Fingerprints (SpeechBrain ECAPA-TDNN).
|
||||||
|
Andere Sprecher (TV, Hintergrund-Gespraeche) werden gefiltert — keine Brain-
|
||||||
|
Calls, keine Tokens. Enrollment passiert in der App (Settings → Stimme einrichten),
|
||||||
|
weil das Handy-Mikro auch im Betrieb hoert.
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:500px;">
|
||||||
|
<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;">Match-Threshold:</label>
|
||||||
|
<input type="range" id="diag-voice-id-threshold" min="0.30" max="0.70" step="0.05" value="0.50"
|
||||||
|
oninput="document.getElementById('voice-id-threshold-display').textContent = this.value"
|
||||||
|
onchange="sendVoiceConfig()"
|
||||||
|
style="flex:1;">
|
||||||
|
<span id="voice-id-threshold-display" style="color:#E0E0F0;font-family:monospace;min-width:40px;text-align:right;">0.50</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#555570;margin-bottom:12px;">
|
||||||
|
Niedriger = mehr Treffer auch bei Nebengeraeuschen (false-positives).
|
||||||
|
Hoeher = strenger, kann Stefan auch mal verpassen. 0.50 ist konservativer Default.
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn secondary" onclick="refreshVoiceIdStatus()" style="padding:6px 14px;font-size:12px;">
|
||||||
|
🔄 Status aktualisieren
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" onclick="deleteVoiceId()" style="padding:6px 14px;font-size:12px;">
|
||||||
|
🗑 Fingerprint löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Runtime-Konfiguration -->
|
<!-- Runtime-Konfiguration -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>Runtime-Konfiguration</h2>
|
<h2>Runtime-Konfiguration</h2>
|
||||||
@@ -1475,6 +1511,46 @@
|
|||||||
setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw);
|
setIfPresent('diag-flux-keyword-raw', msg.fluxKeywordRaw);
|
||||||
setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch);
|
setIfPresent('diag-flux-keyword-switch', msg.fluxKeywordSwitch);
|
||||||
setIfPresent('diag-flux-hf-token', msg.huggingfaceToken);
|
setIfPresent('diag-flux-hf-token', msg.huggingfaceToken);
|
||||||
|
// Voice-ID-Threshold wiederherstellen (Default 0.50)
|
||||||
|
if (msg.voiceIdThreshold !== undefined && msg.voiceIdThreshold !== null) {
|
||||||
|
const slider = document.getElementById('diag-voice-id-threshold');
|
||||||
|
const display = document.getElementById('voice-id-threshold-display');
|
||||||
|
if (slider) slider.value = msg.voiceIdThreshold;
|
||||||
|
if (display) display.textContent = Number(msg.voiceIdThreshold).toFixed(2);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'voice_id_status_response') {
|
||||||
|
const el = document.getElementById('voice-id-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (msg.payload && msg.payload.ok === false) {
|
||||||
|
el.innerHTML = '<span style="color:#FF6E6E;">⚠ Whisper-Bridge nicht erreichbar: ' +
|
||||||
|
(msg.payload.error || 'unbekannt') + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = msg.payload || msg;
|
||||||
|
if (p.enrolled) {
|
||||||
|
const when = p.updated_at ? new Date(p.updated_at * 1000).toLocaleString('de-DE') : '?';
|
||||||
|
const totalSec = (p.sample_durations_s || []).reduce((a, b) => a + b, 0);
|
||||||
|
el.innerHTML = '<span style="color:#34C759;">✓ Enrolled</span> · ' +
|
||||||
|
p.sample_count + ' Samples (' + totalSec.toFixed(1) + 's) · ' +
|
||||||
|
'aktualisiert ' + when + ' · dim=' + (p.embedding_dim || '?');
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<span style="color:#FFD60A;">○ Nicht enrolled</span> — ' +
|
||||||
|
'in der App unter "Stimme einrichten" 5-10× je 3s aufnehmen.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'voice_id_delete_response') {
|
||||||
|
const p = msg.payload || msg;
|
||||||
|
if (p.removed) {
|
||||||
|
alert('Fingerprint gelöscht — Voice-ID-Gating fällt zurück auf Fail-Open.');
|
||||||
|
} else {
|
||||||
|
alert('Es war kein Fingerprint vorhanden.');
|
||||||
|
}
|
||||||
|
refreshVoiceIdStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2607,6 +2683,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshVoiceIdStatus() {
|
||||||
|
const el = document.getElementById('voice-id-status');
|
||||||
|
if (el) el.textContent = '⏳ Status wird abgefragt...';
|
||||||
|
send({ action: 'voice_id_status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVoiceId() {
|
||||||
|
if (!confirm('Voice-ID-Fingerprint loeschen?\n\nDanach muss in der App neu enrolled werden.')) return;
|
||||||
|
send({ action: 'voice_id_delete' });
|
||||||
|
}
|
||||||
|
|
||||||
function deleteXttsVoice(name) {
|
function deleteXttsVoice(name) {
|
||||||
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
|
if (!confirm(`Stimme "${name}" endgueltig loeschen?`)) return;
|
||||||
send({ action: 'xtts_delete_voice', name });
|
send({ action: 'xtts_delete_voice', name });
|
||||||
@@ -2823,12 +2910,15 @@
|
|||||||
const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value;
|
const fluxKeywordRaw = document.getElementById('diag-flux-keyword-raw')?.value;
|
||||||
const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value;
|
const fluxKeywordSwitch = document.getElementById('diag-flux-keyword-switch')?.value;
|
||||||
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 voiceIdThreshold = voiceIdThresholdRaw ? parseFloat(voiceIdThresholdRaw) : undefined;
|
||||||
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,
|
||||||
});
|
});
|
||||||
const statusEl = document.getElementById('voice-status');
|
const statusEl = document.getElementById('voice-status');
|
||||||
if (statusEl && xttsVoice) {
|
if (statusEl && xttsVoice) {
|
||||||
@@ -3354,6 +3444,7 @@
|
|||||||
loadRuntimeConfig();
|
loadRuntimeConfig();
|
||||||
loadOnboardingQR();
|
loadOnboardingQR();
|
||||||
loadOAuthServices();
|
loadOAuthServices();
|
||||||
|
refreshVoiceIdStatus();
|
||||||
} else if (tab === 'brain') {
|
} else if (tab === 'brain') {
|
||||||
loadBrainStatus();
|
loadBrainStatus();
|
||||||
loadBrainMemoryList();
|
loadBrainMemoryList();
|
||||||
@@ -4038,12 +4129,85 @@
|
|||||||
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
|
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
|
||||||
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
|
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn secondary" onclick="openFileInline('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Öffnen">👁</button>
|
||||||
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen">⬇</button>
|
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen">⬇</button>
|
||||||
|
<button class="btn secondary" onclick="showVersions('${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;" title="Versionen">🕒</button>
|
||||||
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
|
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Versions-Modal ──────────────────────────────────────
|
||||||
|
async function showVersions(fileName) {
|
||||||
|
// path-relative-to-/shared/uploads ist hier == fileName, weil unser
|
||||||
|
// file-Manager-Verzeichnis flach ist
|
||||||
|
const rel = fileName;
|
||||||
|
const modal = document.getElementById('versions-modal');
|
||||||
|
const title = document.getElementById('versions-title');
|
||||||
|
const body = document.getElementById('versions-body');
|
||||||
|
title.textContent = `Versionen — ${fileName}`;
|
||||||
|
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Lade...</div>';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.dataset.path = rel;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/files-versions?path=' + encodeURIComponent(rel));
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
if (!d.versions.length) {
|
||||||
|
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Noch keine Versions-Historie (Datei kommt erst nach naechstem Auto-Commit in den Index).</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fmtDate = (ms) => new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
body.innerHTML = d.versions.map(v => {
|
||||||
|
const isCur = v.isCurrent
|
||||||
|
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">AKTIV</span>'
|
||||||
|
: '';
|
||||||
|
const subjShort = (v.subject || '').slice(0, 60);
|
||||||
|
return `<div style="padding:10px;border-bottom:1px solid #1E1E2E;display:flex;gap:8px;align-items:center;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="color:#E0E0F0;font-size:12px;">${isCur}<code style="color:#0096FF;">${v.hash.slice(0,7)}</code> · ${escapeHtml(subjShort)}</div>
|
||||||
|
<div style="color:#555570;font-size:10px;">${fmtDate(v.ts)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn secondary" onclick="downloadVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;">⬇ Download</button>
|
||||||
|
${v.isCurrent ? '' : `<button class="btn" onclick="restoreVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;background:#0096FF;color:#fff;">⟲ Restore</button>`}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<div style="color:#FF6B6B;padding:20px;">${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVersionsModal() {
|
||||||
|
document.getElementById('versions-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadVersion(rel, hash) {
|
||||||
|
const url = '/api/files-version-content?path=' + encodeURIComponent(rel) + '&hash=' + encodeURIComponent(hash);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = '';
|
||||||
|
document.body.appendChild(a); a.click();
|
||||||
|
setTimeout(() => a.remove(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreVersion(rel, hash) {
|
||||||
|
if (!confirm(`Diese Version (${hash.slice(0,7)}) als aktive Version setzen?\n\nDie aktuelle Version bleibt rollback-bar in der Historie.`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/files-version-restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: rel, hash }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
// Modal neu laden mit aktualisierter Liste
|
||||||
|
showVersions(rel);
|
||||||
|
loadFiles();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Restore fehlgeschlagen: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadSelected() {
|
async function downloadSelected() {
|
||||||
const paths = [...filesSelected];
|
const paths = [...filesSelected];
|
||||||
if (!paths.length) return;
|
if (!paths.length) return;
|
||||||
@@ -4102,6 +4266,12 @@
|
|||||||
window.location.href = '/api/files-download?path=' + encPath;
|
window.location.href = '/api/files-download?path=' + encPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFileInline(encPath) {
|
||||||
|
// Inline-View — Browser zeigt PDF / Bild / Text im neuen Tab,
|
||||||
|
// bei unbekanntem MIME landet's als Download-Fallback.
|
||||||
|
window.open('/api/files-view?path=' + encPath, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteFile(p, name) {
|
async function deleteFile(p, name) {
|
||||||
if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return;
|
if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -5612,5 +5782,16 @@
|
|||||||
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
|
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
|
||||||
loadAriaStreamHistory();
|
loadAriaStreamHistory();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Versions-Modal fuer Datei-Manager -->
|
||||||
|
<div id="versions-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:1000;align-items:center;justify-content:center;" onclick="if(event.target===this)closeVersionsModal()">
|
||||||
|
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:8px;width:90%;max-width:600px;max-height:80vh;display:flex;flex-direction:column;">
|
||||||
|
<div style="padding:12px 16px;border-bottom:1px solid #1E1E2E;display:flex;align-items:center;gap:8px;">
|
||||||
|
<strong id="versions-title" style="color:#E0E0F0;flex:1;font-size:13px;">Versionen</strong>
|
||||||
|
<button class="btn secondary" onclick="closeVersionsModal()" style="padding:4px 10px;font-size:11px;">✕ Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div id="versions-body" style="overflow-y:auto;padding:4px 12px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+291
-3
@@ -92,6 +92,174 @@ let activeSessionKey = (() => {
|
|||||||
return "main";
|
return "main";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Auto-Versionierung /shared/uploads/ via git ────────────────
|
||||||
|
//
|
||||||
|
// Jede Aenderung im uploads/-Verzeichnis (User-Upload, ARIA-Generate,
|
||||||
|
// ARIA-Bearbeitung) wird durch eine 30s-Polling-Loop in einen git-Commit
|
||||||
|
// gepackt. Idempotent (kein Commit ohne Diff), kein Bloat im Normalbetrieb.
|
||||||
|
// Stefan kann via UI eine Version anschauen, herunterladen oder als
|
||||||
|
// neue aktive Version setzen (Restore = neuer commit mit altem Inhalt,
|
||||||
|
// non-destructive).
|
||||||
|
const SHARED_UPLOADS = "/shared/uploads";
|
||||||
|
const VERSIONING_INTERVAL_MS = 30 * 1000;
|
||||||
|
const { execFile } = require("child_process");
|
||||||
|
|
||||||
|
function git(args, opts = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = execFile(
|
||||||
|
"git",
|
||||||
|
["-C", SHARED_UPLOADS, ...args],
|
||||||
|
{ maxBuffer: 20 * 1024 * 1024, ...opts },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err && !opts.allowFail) {
|
||||||
|
err.stderr = stderr;
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
stdout: stdout || "",
|
||||||
|
stderr: stderr || "",
|
||||||
|
code: err ? (err.code || 1) : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (opts.input != null) {
|
||||||
|
try { child.stdin.write(opts.input); } catch (_) {}
|
||||||
|
try { child.stdin.end(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSharedVersioning() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(SHARED_UPLOADS, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[shared-git] mkdir uploads fehlgeschlagen: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gitDir = path.join(SHARED_UPLOADS, ".git");
|
||||||
|
if (!fs.existsSync(gitDir)) {
|
||||||
|
console.log("[shared-git] Initialisiere /shared/uploads als git-Repo");
|
||||||
|
try {
|
||||||
|
await git(["init", "-q", "-b", "main"]);
|
||||||
|
await git(["config", "user.email", "aria@diagnostic"]);
|
||||||
|
await git(["config", "user.name", "aria-diagnostic"]);
|
||||||
|
// Initial commit (auch wenn leer) damit log/checkout immer funktioniert
|
||||||
|
await git(["commit", "-q", "--allow-empty", "-m", "initial snapshot"]);
|
||||||
|
// Falls schon Files drin sind: noch ein 'auto'-Commit hinten dran
|
||||||
|
const status = await git(["status", "--porcelain"]);
|
||||||
|
if (status.stdout.trim()) {
|
||||||
|
await git(["add", "-A"]);
|
||||||
|
await git(["commit", "-q", "-m", `auto: ${new Date().toISOString()}`]);
|
||||||
|
}
|
||||||
|
console.log("[shared-git] Init OK");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[shared-git] Init fehlgeschlagen: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[shared-git] Bestehendes git-Repo erkannt — uebernehme");
|
||||||
|
}
|
||||||
|
setInterval(autoCommitTick, VERSIONING_INTERVAL_MS);
|
||||||
|
console.log(`[shared-git] Auto-Commit-Loop alle ${VERSIONING_INTERVAL_MS}ms aktiv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoCommitBusy = false;
|
||||||
|
async function autoCommitTick() {
|
||||||
|
if (autoCommitBusy) return; // re-entrancy guard fuer langsame git ops
|
||||||
|
autoCommitBusy = true;
|
||||||
|
try {
|
||||||
|
const status = await git(["status", "--porcelain"]);
|
||||||
|
if (!status.stdout.trim()) return;
|
||||||
|
await git(["add", "-A"]);
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
await git(["commit", "-q", "-m", `auto: ${ts}`]);
|
||||||
|
console.log(`[shared-git] auto-commit @ ${ts}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[shared-git] auto-commit fehlgeschlagen: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
autoCommitBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versions-API helpers — werden weiter unten von den Routen genutzt.
|
||||||
|
function isPathSafe(rel) {
|
||||||
|
if (!rel || typeof rel !== "string") return false;
|
||||||
|
if (rel.includes("..") || rel.startsWith("/") || rel.startsWith(".git")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function listVersionsForFile(rel) {
|
||||||
|
// git log --follow damit Renames trotzdem die Historie zeigen.
|
||||||
|
// NUL-Separator damit Subjects mit Leerzeichen nicht falsch splitten.
|
||||||
|
const out = await git(["log", "--follow", "--format=%H%x00%aI%x00%s", "--", rel]);
|
||||||
|
const lines = out.stdout.trim().split("\n").filter(Boolean);
|
||||||
|
const enriched = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const [hash, isoTs, subject] = line.split("\x00");
|
||||||
|
if (!hash) continue;
|
||||||
|
let blob = null;
|
||||||
|
try {
|
||||||
|
const ls = await git(["ls-tree", hash, "--", rel]);
|
||||||
|
// Format: "100644 blob <40-hex>\t<path>"
|
||||||
|
const m = ls.stdout.match(/blob ([0-9a-f]{40})/);
|
||||||
|
if (m) blob = m[1];
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!blob) continue;
|
||||||
|
enriched.push({ hash, ts: Date.parse(isoTs) || 0, subject: subject || "", blob });
|
||||||
|
}
|
||||||
|
// Dedup auf Blob-Ebene — Restore-Commits sind inhaltlich gleich mit dem
|
||||||
|
// restorten alten Commit. Zeige nur den AELTESTEN (= zuerst erschienenen)
|
||||||
|
// Eintrag pro identischem Blob. Damit blaeht Restore die Liste nicht auf.
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = [];
|
||||||
|
for (let i = enriched.length - 1; i >= 0; i--) {
|
||||||
|
const v = enriched[i];
|
||||||
|
if (seen.has(v.blob)) continue;
|
||||||
|
seen.add(v.blob);
|
||||||
|
unique.push(v);
|
||||||
|
}
|
||||||
|
unique.reverse(); // wieder neueste-zuerst fuers UI
|
||||||
|
// AKTIV-Marker: Commit dessen Blob == aktuelle Working-Copy. Nach Restore
|
||||||
|
// wandert AKTIV auf den restorten alten Stand, nicht auf den gefilterten
|
||||||
|
// Restore-Commit.
|
||||||
|
let currentBlob = null;
|
||||||
|
try {
|
||||||
|
const abs = path.join(SHARED_UPLOADS, rel);
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
const r = await git(["hash-object", abs]);
|
||||||
|
currentBlob = (r.stdout || "").trim();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
for (const v of unique) {
|
||||||
|
if (currentBlob && v.blob === currentBlob) v.isCurrent = true;
|
||||||
|
}
|
||||||
|
// Blob aus Response strippen — sieht im UI aus wie zweite Commit-ID, unnoetig.
|
||||||
|
return unique.map(({ blob, ...rest }) => rest);
|
||||||
|
}
|
||||||
|
async function getVersionContent(rel, hash) {
|
||||||
|
// git show <hash>:<path> liefert den Inhalt aus diesem Commit
|
||||||
|
// Binary-safe via stdio buffer
|
||||||
|
const out = await git(["show", `${hash}:${rel}`], { encoding: "buffer" });
|
||||||
|
return out.stdout; // Buffer
|
||||||
|
}
|
||||||
|
async function restoreVersion(rel, hash) {
|
||||||
|
// Variante: non-destructive — wir holen den alten Inhalt und schreiben
|
||||||
|
// ihn als NEUE Version drueber. Damit bleibt die aktuelle Version
|
||||||
|
// ebenfalls in der git-History rollback-bar.
|
||||||
|
const content = await getVersionContent(rel, hash);
|
||||||
|
const abs = path.join(SHARED_UPLOADS, rel);
|
||||||
|
fs.writeFileSync(abs, content);
|
||||||
|
await git(["add", "--", rel]);
|
||||||
|
await git(["commit", "-q", "-m", `restore: ${rel} <- ${hash.slice(0, 7)}`]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Startup einmalig aufrufen
|
||||||
|
initSharedVersioning().catch(e =>
|
||||||
|
console.error(`[shared-git] initSharedVersioning crashed: ${e.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
// ── Runtime-Config: /shared/config/runtime.json ─────────────
|
// ── Runtime-Config: /shared/config/runtime.json ─────────────
|
||||||
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
|
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
|
||||||
// Bridge und ggf. andere Komponenten lesen dieselbe Datei.
|
// Bridge und ggf. andere Komponenten lesen dieselbe Datei.
|
||||||
@@ -1454,7 +1622,10 @@ const server = http.createServer((req, res) => {
|
|||||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (req.url.startsWith("/api/files-download?") && req.method === "GET") {
|
} else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") {
|
||||||
|
// /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
|
||||||
|
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
|
||||||
|
const isInline = req.url.startsWith("/api/files-view?");
|
||||||
const u = new URL("http://x" + req.url);
|
const u = new URL("http://x" + req.url);
|
||||||
const p = u.searchParams.get("path") || "";
|
const p = u.searchParams.get("path") || "";
|
||||||
const safe = path.resolve(p);
|
const safe = path.resolve(p);
|
||||||
@@ -1465,10 +1636,26 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
const stat = fs.statSync(safe);
|
const stat = fs.statSync(safe);
|
||||||
const fname = path.basename(safe);
|
const fname = path.basename(safe);
|
||||||
|
// Beim View-Modus echten MIME-Type setzen damit Browser inline rendert.
|
||||||
|
// Bei Download-Modus weiter octet-stream + attachment-Disposition.
|
||||||
|
const ext = path.extname(fname).toLowerCase();
|
||||||
|
const mimeMap = {
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
||||||
|
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
||||||
|
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
||||||
|
".mp4": "video/mp4", ".webm": "video/webm",
|
||||||
|
".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8",
|
||||||
|
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8",
|
||||||
|
".zip": "application/zip",
|
||||||
|
};
|
||||||
|
const mime = isInline ? (mimeMap[ext] || "application/octet-stream")
|
||||||
|
: "application/octet-stream";
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": mime,
|
||||||
"Content-Length": stat.size,
|
"Content-Length": stat.size,
|
||||||
"Content-Disposition": `attachment; filename="${fname}"`,
|
"Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
|
||||||
});
|
});
|
||||||
fs.createReadStream(safe).pipe(res);
|
fs.createReadStream(safe).pipe(res);
|
||||||
return;
|
return;
|
||||||
@@ -1594,6 +1781,92 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} else if (req.url.startsWith("/api/files-versions?") && req.method === "GET") {
|
||||||
|
// Liste der git-Versionen einer Datei. Query: ?path=<rel-to-uploads>
|
||||||
|
const u = new URL("http://x" + req.url);
|
||||||
|
const rel = u.searchParams.get("path") || "";
|
||||||
|
if (!isPathSafe(rel)) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listVersionsForFile(rel)
|
||||||
|
.then(versions => {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, path: rel, versions }));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
log("warn", "server", `files-versions failed: ${err.message}`);
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (req.url.startsWith("/api/files-version-content?") && req.method === "GET") {
|
||||||
|
// Inhalt einer alten Version downloaden. Query: ?path=...&hash=<sha>
|
||||||
|
const u = new URL("http://x" + req.url);
|
||||||
|
const rel = u.searchParams.get("path") || "";
|
||||||
|
const hash = u.searchParams.get("hash") || "";
|
||||||
|
if (!isPathSafe(rel) || !/^[0-9a-f]{7,40}$/i.test(hash)) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getVersionContent(rel, hash)
|
||||||
|
.then(content => {
|
||||||
|
const base = path.basename(rel);
|
||||||
|
const stem = base.replace(/(\.[^.]+)?$/, "");
|
||||||
|
const ext = path.extname(base);
|
||||||
|
const shortHash = hash.slice(0, 7);
|
||||||
|
const downloadName = `${stem}@${shortHash}${ext}`;
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Disposition": `attachment; filename="${downloadName}"`,
|
||||||
|
"Content-Length": content.length,
|
||||||
|
});
|
||||||
|
res.end(content);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
log("warn", "server", `files-version-content failed: ${err.message}`);
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (req.url === "/api/files-version-restore" && req.method === "POST") {
|
||||||
|
// Eine alte Version als neue aktive Version setzen — non-destructive,
|
||||||
|
// erzeugt einen neuen "restore:"-Commit. Body: {path, hash}
|
||||||
|
let body = "";
|
||||||
|
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
|
||||||
|
req.on("end", () => {
|
||||||
|
let p, h;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body || "{}");
|
||||||
|
p = String(parsed.path || "");
|
||||||
|
h = String(parsed.hash || "");
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "bad json" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPathSafe(p) || !/^[0-9a-f]{7,40}$/i.test(h)) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreVersion(p, h)
|
||||||
|
.then(() => {
|
||||||
|
log("info", "server", `Version restored: ${p} <- ${h.slice(0,7)}`);
|
||||||
|
// Datei hat sich geaendert — Browser-Listen invalidieren
|
||||||
|
broadcast({ type: "file_version_restored", path: p, hash: h });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, path: p, hash: h }));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
log("warn", "server", `restore failed: ${err.message}`);
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
} else if (req.url === "/api/voice-config-export" && req.method === "GET") {
|
} else if (req.url === "/api/voice-config-export" && req.method === "GET") {
|
||||||
// voice_config.json + highlight_triggers.json als JSON-Bundle exportieren
|
// voice_config.json + highlight_triggers.json als JSON-Bundle exportieren
|
||||||
try {
|
try {
|
||||||
@@ -2094,6 +2367,12 @@ wss.on("connection", (ws) => {
|
|||||||
if (msg.huggingfaceToken !== undefined) {
|
if (msg.huggingfaceToken !== undefined) {
|
||||||
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
|
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
|
||||||
}
|
}
|
||||||
|
// Voice-ID Match-Threshold (0.30-0.70). Wird von der whisper-bridge
|
||||||
|
// ueber den config-Broadcast aufgenommen — Phase 3 nutzt's beim Gating.
|
||||||
|
if (msg.voiceIdThreshold !== undefined && !isNaN(msg.voiceIdThreshold)) {
|
||||||
|
const t = parseFloat(msg.voiceIdThreshold);
|
||||||
|
if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
|
||||||
|
}
|
||||||
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));
|
||||||
@@ -2117,6 +2396,15 @@ wss.on("connection", (ws) => {
|
|||||||
handleGetModel(ws);
|
handleGetModel(ws);
|
||||||
} else if (msg.action === "set_model") {
|
} else if (msg.action === "set_model") {
|
||||||
handleSetModel(ws, msg.model);
|
handleSetModel(ws, msg.model);
|
||||||
|
} else if (msg.action === "voice_id_status") {
|
||||||
|
// An whisper-bridge weiterleiten + Antwort an Browser zurueck
|
||||||
|
const reqId = `vid_${Date.now().toString(36)}`;
|
||||||
|
sendToRVS_withResponse("voice_id_status_request", { requestId: reqId },
|
||||||
|
"voice_id_status_response", ws);
|
||||||
|
} else if (msg.action === "voice_id_delete") {
|
||||||
|
const reqId = `viddel_${Date.now().toString(36)}`;
|
||||||
|
sendToRVS_withResponse("voice_id_delete_request", { requestId: reqId },
|
||||||
|
"voice_id_delete_response", ws);
|
||||||
}
|
}
|
||||||
// get_openclaw_config entfernt — aria-core ist raus.
|
// get_openclaw_config entfernt — aria-core ist raus.
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ services:
|
|||||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||||
environment:
|
environment:
|
||||||
- MAX_SESSIONS=10
|
- MAX_SESSIONS=10
|
||||||
|
# 4 GB V8-Heap — sonst OOM beim Empfang von 1 GB-Files
|
||||||
|
# (base64 inflated ~1.34 GB plus WS-Frame-Margin).
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=4096
|
||||||
networks:
|
networks:
|
||||||
- aria-rvs-net
|
- aria-rvs-net
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -42,6 +42,17 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
|
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
|
||||||
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
|
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
|
||||||
"stt_partial", "stt_endpoint", "stt_stream_done",
|
"stt_partial", "stt_endpoint", "stt_stream_done",
|
||||||
|
// Speaker-ID / Voice-Enrollment (Phase 1+2): App schickt 5-10 Samples zur
|
||||||
|
// whisper-bridge, die berechnet einen Voice-Fingerprint (Embedding-Vektor)
|
||||||
|
// und nutzt ihn um nur Stefans Stimme an Whisper STT durchzulassen.
|
||||||
|
"voice_id_status_request", "voice_id_status_response",
|
||||||
|
"voice_id_enroll_request", "voice_id_enroll_response",
|
||||||
|
"voice_id_delete_request", "voice_id_delete_response",
|
||||||
|
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
|
||||||
|
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
|
||||||
|
"file_version_list_request", "file_version_list_response",
|
||||||
|
"file_version_download_request", "file_version_download_response",
|
||||||
|
"file_version_restore_request", "file_version_restore_response",
|
||||||
"service_status",
|
"service_status",
|
||||||
"config_request",
|
"config_request",
|
||||||
"flux_request", "flux_response",
|
"flux_request", "flux_response",
|
||||||
@@ -88,15 +99,20 @@ function cleanupRooms() {
|
|||||||
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
|
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
|
||||||
// schoenen "Tab schliessen"-Seite.
|
// schoenen "Tab schliessen"-Seite.
|
||||||
//
|
//
|
||||||
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
// maxPayload 1500MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
||||||
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
||||||
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
||||||
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
|
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
|
||||||
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
|
// (Code 1009 message too big, Bridge crashed im cleanup). 1500 MB
|
||||||
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
|
// deckt bis ~1 GB binaer ab (mit base64 ~33% Overhead + WS-Frame-
|
||||||
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
|
// Margin); groessere Files werden Bridge-seitig abgewiesen (siehe
|
||||||
|
// file_request-Handler) bevor die WS abreisst.
|
||||||
|
//
|
||||||
|
// WICHTIG: Node-Default-Heap ist ~1.5 GB. Fuer 1 GB-Files muss der
|
||||||
|
// Container mit --max-old-space-size=4096 (oder NODE_OPTIONS env var)
|
||||||
|
// gestartet werden, sonst OOM-Crash beim Empfang.
|
||||||
const httpServer = http.createServer(handleHttpRequest);
|
const httpServer = http.createServer(handleHttpRequest);
|
||||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 });
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 1500 * 1024 * 1024 });
|
||||||
|
|
||||||
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
||||||
httpServer.on("upgrade", (req, socket, head) => {
|
httpServer.on("upgrade", (req, socket, head) => {
|
||||||
|
|||||||
@@ -85,4 +85,7 @@ services:
|
|||||||
# ein Modell muss nur einmal pro
|
# ein Modell muss nur einmal pro
|
||||||
# Maschine geladen werden, kein
|
# Maschine geladen werden, kein
|
||||||
# Re-Download bei Container-Restart.
|
# Re-Download bei Container-Restart.
|
||||||
|
- ./voice-id:/voice-id # Speaker-ID-Fingerprint (Stefans
|
||||||
|
# Stimm-Embedding) persistent zwischen
|
||||||
|
# Container-Restarts.
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+10
-2
@@ -1,14 +1,22 @@
|
|||||||
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 python3-pip ffmpeg \
|
python3 python3-pip ffmpeg git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# PyTorch CUDA-Wheels zuerst (sonst zieht speechbrain CPU-only Torch rein
|
||||||
|
# falls f5tts den Cache noch nicht geseedet hat).
|
||||||
|
RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY bridge.py .
|
COPY bridge.py speaker_id.py ./
|
||||||
|
|
||||||
CMD ["python3", "bridge.py"]
|
CMD ["python3", "bridge.py"]
|
||||||
|
|||||||
+222
-1
@@ -33,6 +33,8 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import speaker_id
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -61,6 +63,7 @@ ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
|||||||
|
|
||||||
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
||||||
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
|
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
|
||||||
|
STREAM_SPEAKER_CHECK_MS = 1500 # Mindest-Audio fuer Speaker-ID-Pruefung
|
||||||
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
|
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
|
||||||
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
|
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
|
||||||
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
||||||
@@ -109,7 +112,27 @@ class WhisperRunner:
|
|||||||
segments, info = self.model.transcribe(
|
segments, info = self.model.transcribe(
|
||||||
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
|
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
|
||||||
)
|
)
|
||||||
text = " ".join(seg.text.strip() for seg in segments)
|
# Per-segment no_speech_prob auswerten: faster-whisper liefert das
|
||||||
|
# mit. Bei Stille/Rauschen halluziniert Whisper bekannte YouTube-
|
||||||
|
# Untertitel-Patterns ("Untertitelung des ZDF", "Vielen Dank fuer's
|
||||||
|
# Zuschauen", ...). Segmente mit hohem no_speech_prob filtern wir
|
||||||
|
# raus. Plus: bekannte Hallucination-Patterns explizit blacklisten.
|
||||||
|
kept = []
|
||||||
|
for seg in segments:
|
||||||
|
# no_speech_prob: 1.0 = sicher Stille; 0.0 = sicher Sprache.
|
||||||
|
# Threshold 0.6 ist nicht zu strikt (echte leise Sprache geht
|
||||||
|
# noch durch) und nicht zu locker (Halluzinationen werden
|
||||||
|
# zuverlaessig erwischt).
|
||||||
|
nsp = getattr(seg, "no_speech_prob", 0.0)
|
||||||
|
if nsp is not None and nsp >= 0.6:
|
||||||
|
continue
|
||||||
|
stext = (seg.text or "").strip()
|
||||||
|
if not stext:
|
||||||
|
continue
|
||||||
|
if _is_known_hallucination(stext):
|
||||||
|
continue
|
||||||
|
kept.append(stext)
|
||||||
|
text = " ".join(kept)
|
||||||
return text, info.duration
|
return text, info.duration
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -117,6 +140,61 @@ class WhisperRunner:
|
|||||||
return await loop.run_in_executor(None, _run)
|
return await loop.run_in_executor(None, _run)
|
||||||
|
|
||||||
|
|
||||||
|
# Bekannte Whisper-Halluzinations-Patterns. Tritt typisch bei Stille oder
|
||||||
|
# Rauschen auf — Whispers Trainings-Corpus enthaelt Stunden von YouTube-
|
||||||
|
# Videos mit diesen Untertitel-Outros. Substring-Match (case-insensitive)
|
||||||
|
# ueber gestrippten Text. Wenn ein Segment EXAKT (nach Normalisierung) so
|
||||||
|
# aussieht, ist's mit ~99% Sicherheit eine Halluzination.
|
||||||
|
_HALLUCINATION_PHRASES = (
|
||||||
|
"untertitelung des zdf",
|
||||||
|
"untertitel im auftrag des zdf",
|
||||||
|
"untertitelung im auftrag des zdf",
|
||||||
|
"untertitel der amara.org community",
|
||||||
|
"untertitel von stephanie geiges",
|
||||||
|
"amara.org",
|
||||||
|
"untertitel: kerstin grass",
|
||||||
|
"vielen dank fuers zuschauen",
|
||||||
|
"vielen dank fürs zuschauen",
|
||||||
|
"vielen dank für's zuschauen",
|
||||||
|
"vielen dank fuer's zuschauen",
|
||||||
|
"vielen dank für das zuschauen",
|
||||||
|
"vielen dank fuer das zuschauen",
|
||||||
|
"danke für's zuschauen",
|
||||||
|
"danke fürs zuschauen",
|
||||||
|
"danke fuers zuschauen",
|
||||||
|
"subs by",
|
||||||
|
"subtitle by",
|
||||||
|
"subtitles by",
|
||||||
|
"thanks for watching",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_hallu(text: str) -> str:
|
||||||
|
"""Lowercase + trailing-Satzzeichen/Whitespace strippen. Jahreszahlen
|
||||||
|
(4 Ziffern am Ende) auch entfernen — 'Untertitelung des ZDF, 2020'
|
||||||
|
matcht damit auf 'untertitelung des zdf'."""
|
||||||
|
t = text.lower().strip()
|
||||||
|
# Entferne trailing punctuation incl. comma+digits
|
||||||
|
while t and t[-1] in ".,!? \t\n":
|
||||||
|
t = t[:-1]
|
||||||
|
# 4-stellige Jahreszahl am Ende
|
||||||
|
import re
|
||||||
|
t = re.sub(r"[,\s]+\d{4}$", "", t).strip()
|
||||||
|
while t and t[-1] in ".,!? \t\n":
|
||||||
|
t = t[:-1]
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _is_known_hallucination(text: str) -> bool:
|
||||||
|
norm = _normalize_for_hallu(text)
|
||||||
|
if not norm:
|
||||||
|
return True
|
||||||
|
for pat in _HALLUCINATION_PHRASES:
|
||||||
|
if pat in norm:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
||||||
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
|
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
|
||||||
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type:
|
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type:
|
||||||
@@ -234,6 +312,12 @@ class StreamSession:
|
|||||||
last_transcribe_at: float = 0.0
|
last_transcribe_at: float = 0.0
|
||||||
closed: bool = False # nach stream_end gesetzt
|
closed: bool = False # nach stream_end gesetzt
|
||||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||||
|
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||||
|
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
|
||||||
|
# synthetischem stt_endpoint(text='', reason='speaker_mismatch').
|
||||||
|
speaker_checked: bool = False
|
||||||
|
speaker_match: Optional[bool] = None
|
||||||
|
speaker_similarity: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
@@ -345,6 +429,77 @@ class SessionManager:
|
|||||||
sid[:8], now - sess.last_chunk_at)
|
sid[:8], now - sess.last_chunk_at)
|
||||||
self.drop(sid)
|
self.drop(sid)
|
||||||
|
|
||||||
|
async def _check_speaker(self, sess: StreamSession, ws) -> None:
|
||||||
|
"""Speaker-ID einmalig pro Session: nimmt die ersten ~1.5s Audio,
|
||||||
|
rechnet das Embedding, vergleicht mit dem persistierten Fingerprint.
|
||||||
|
Ohne Fingerprint → fail-open (match=True). Bei mismatch wird die
|
||||||
|
Session sofort beendet mit synthetischem stt_endpoint."""
|
||||||
|
sess.speaker_checked = True
|
||||||
|
# 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:
|
||||||
|
# Zu wenig — durchlassen
|
||||||
|
sess.speaker_match = True
|
||||||
|
sess.speaker_similarity = 0.0
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
is_match, sim = await loop.run_in_executor(
|
||||||
|
None, speaker_id.verify, head_bytes,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Stream %s: speaker-check crashed (%s) — fail-open",
|
||||||
|
sess.request_id[:8], exc)
|
||||||
|
sess.speaker_match = True
|
||||||
|
sess.speaker_similarity = 0.0
|
||||||
|
return
|
||||||
|
sess.speaker_match = is_match
|
||||||
|
sess.speaker_similarity = sim
|
||||||
|
logger.info("Stream %s: speaker-check sim=%.2f → %s (threshold=%.2f)",
|
||||||
|
sess.request_id[:8], sim, "MATCH" if is_match else "REJECT",
|
||||||
|
speaker_id.DEFAULT_THRESHOLD)
|
||||||
|
await _debug_log(ws, "speaker.check",
|
||||||
|
f"id={sess.request_id[:12]} sim={sim:.2f} "
|
||||||
|
f"thr={speaker_id.DEFAULT_THRESHOLD:.2f} "
|
||||||
|
f"{'MATCH' if is_match else 'REJECT'}")
|
||||||
|
if not is_match:
|
||||||
|
await self._finalize_speaker_mismatch(sess, ws, sim)
|
||||||
|
|
||||||
|
async def _finalize_speaker_mismatch(self, sess: StreamSession, ws,
|
||||||
|
similarity: float) -> None:
|
||||||
|
"""Bei Speaker-Mismatch: synthetisches stt_endpoint (text='', reason=
|
||||||
|
'speaker_mismatch') schicken damit der App-Pfad sauber endet
|
||||||
|
(endConversation), Session droppen. Kein Whisper-Transcribe.
|
||||||
|
Spart die Token + die STT-Latenz fuer fremde Stimmen."""
|
||||||
|
if sess.endpoint_sent:
|
||||||
|
return
|
||||||
|
sess.endpoint_sent = True
|
||||||
|
duration_s = self._buffer_duration_ms(sess) / 1000.0
|
||||||
|
logger.info("Stream %s: speaker-mismatch (sim=%.2f) — DROP nach %.1fs",
|
||||||
|
sess.request_id[:8], similarity, duration_s)
|
||||||
|
endpoint_payload = {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "",
|
||||||
|
"reason": "speaker_mismatch",
|
||||||
|
"durationS": duration_s,
|
||||||
|
"sttMs": 0,
|
||||||
|
"voice": sess.voice,
|
||||||
|
"speed": sess.speed,
|
||||||
|
"interrupted": sess.interrupted,
|
||||||
|
"speakerSimilarity": float(similarity),
|
||||||
|
}
|
||||||
|
if sess.location:
|
||||||
|
endpoint_payload["location"] = sess.location
|
||||||
|
await _send(ws, "stt_endpoint", endpoint_payload)
|
||||||
|
await _send(ws, "stt_stream_done", {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": "",
|
||||||
|
"reason": "speaker_mismatch",
|
||||||
|
})
|
||||||
|
self.drop(sess.request_id)
|
||||||
|
|
||||||
async def _tick_session(self, sess: StreamSession, now: float) -> None:
|
async def _tick_session(self, sess: StreamSession, now: float) -> None:
|
||||||
ws = self._ws
|
ws = self._ws
|
||||||
if ws is None:
|
if ws is None:
|
||||||
@@ -365,6 +520,15 @@ class SessionManager:
|
|||||||
await self._finalize(sess, ws, reason="stream_end")
|
await self._finalize(sess, ws, reason="stream_end")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Speaker-ID Gating: sobald genug Audio da ist, einmalig pruefen ob's
|
||||||
|
# Stefan ist. Bei Mismatch → synthetisches Endpoint, Session zu.
|
||||||
|
# Wenn kein Fingerprint persistiert ist, returnt verify() fail-open
|
||||||
|
# mit (True, 0.0) — keine Auswirkung.
|
||||||
|
if not sess.speaker_checked and audio_ms >= STREAM_SPEAKER_CHECK_MS:
|
||||||
|
await self._check_speaker(sess, ws)
|
||||||
|
if sess.speaker_match is False:
|
||||||
|
return # Session bereits beendet via _finalize_speaker_mismatch
|
||||||
|
|
||||||
# Noch zu wenig Audio fuer eine erste Transkription
|
# Noch zu wenig Audio fuer eine erste Transkription
|
||||||
if audio_ms < STREAM_MIN_AUDIO_MS:
|
if audio_ms < STREAM_MIN_AUDIO_MS:
|
||||||
return
|
return
|
||||||
@@ -654,10 +818,67 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
||||||
sessions.end_session(req_id)
|
sessions.end_session(req_id)
|
||||||
|
|
||||||
|
elif mtype == "voice_id_status_request":
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
try:
|
||||||
|
status = speaker_id.status()
|
||||||
|
except Exception as exc:
|
||||||
|
await _send(ws, "voice_id_status_response", {
|
||||||
|
"requestId": req_id, "ok": False, "error": str(exc)[:200],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
await _send(ws, "voice_id_status_response", {
|
||||||
|
"requestId": req_id, "ok": True, **status,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif mtype == "voice_id_enroll_request":
|
||||||
|
# samples: Liste von base64-kodierten int16-LE-PCM-Buffern,
|
||||||
|
# 16kHz mono, je ~3-5s. App nimmt sie nacheinander auf und
|
||||||
|
# schickt sie zusammen.
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
samples = payload.get("samples") or []
|
||||||
|
logger.info("voice_id_enroll_request: %d Samples (id=%s)",
|
||||||
|
len(samples), req_id[:8])
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, speaker_id.enroll_from_samples, samples
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("voice_id_enroll failed: %s", exc)
|
||||||
|
await _send(ws, "voice_id_enroll_response", {
|
||||||
|
"requestId": req_id, "ok": False, "error": str(exc)[:300],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
await _send(ws, "voice_id_enroll_response", {
|
||||||
|
"requestId": req_id, "ok": True,
|
||||||
|
"sample_count": result.get("sample_count", 0),
|
||||||
|
"rejected": result.get("rejected", []),
|
||||||
|
"updated_at": result.get("updated_at"),
|
||||||
|
"embedding_dim": result.get("embedding_dim"),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif mtype == "voice_id_delete_request":
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
removed = speaker_id.delete_fingerprint()
|
||||||
|
await _send(ws, "voice_id_delete_response", {
|
||||||
|
"requestId": req_id, "ok": True, "removed": removed,
|
||||||
|
})
|
||||||
|
|
||||||
elif mtype == "config":
|
elif mtype == "config":
|
||||||
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
|
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
|
||||||
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
|
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
|
||||||
# die Logs an/aus schalten kann.
|
# die Logs an/aus schalten kann.
|
||||||
|
# Voice-ID Match-Threshold (von Diagnostic gesendet) auf das
|
||||||
|
# speaker_id-Modul setzen — wird erst in Phase 3 beim Gating
|
||||||
|
# genutzt, aber persistiert bereits jetzt.
|
||||||
|
if "voiceIdThreshold" in payload:
|
||||||
|
try:
|
||||||
|
t = float(payload.get("voiceIdThreshold", 0.5))
|
||||||
|
if 0.0 <= t <= 1.0:
|
||||||
|
speaker_id.DEFAULT_THRESHOLD = t
|
||||||
|
logger.info("[speaker-id] threshold gesetzt: %.2f", t)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
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
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ faster-whisper==1.0.3
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
numpy>=1.24
|
numpy>=1.24
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
# Speaker-ID via SpeechBrain ECAPA-TDNN — Stimme von Stefan zuverlaessig
|
||||||
|
# rauskennen damit Hintergrund-Gespraeche keine Brain-Calls triggern.
|
||||||
|
speechbrain>=1.0.0
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Speaker-ID Backend fuer ARIAs Stimmen-Erkennung.
|
||||||
|
|
||||||
|
Nutzt SpeechBrain ECAPA-TDNN (192-dim Embeddings, auf VoxCeleb-1+2 trainiert).
|
||||||
|
Fingerprint = gemittelter, L2-normalisierter Embedding-Vektor aus N
|
||||||
|
Enrollment-Samples. Verify: cosine_similarity(neue_aufnahme, fingerprint).
|
||||||
|
|
||||||
|
Persistenz: /voice-id/fingerprint.json (Float-Liste + Metadaten).
|
||||||
|
Modell-Cache: /root/.cache/huggingface/ (Bind-Mount mit f5tts geteilt).
|
||||||
|
|
||||||
|
Verhalten OHNE Enrollment (kein Fingerprint vorhanden):
|
||||||
|
verify() → (True, 0.0) — Fail-open, damit Speaker-ID-Gating den
|
||||||
|
ungeenrollten Brain-Pfad nicht versehentlich blockiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VOICE_ID_DIR = Path(os.environ.get("VOICE_ID_DIR", "/voice-id"))
|
||||||
|
FINGERPRINT_FILE = VOICE_ID_DIR / "fingerprint.json"
|
||||||
|
|
||||||
|
# Cosine-Threshold: 0.5 ist konservativ (wenig false-positives), 0.3 ist
|
||||||
|
# locker (mehr Treffer auch bei Nebengeraeuschen). Stefan kann's per
|
||||||
|
# Diagnostic-Setting feintunen.
|
||||||
|
DEFAULT_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
# Minimal-Sample-Laenge fuer ein verlaessliches Embedding (~1s @ 16kHz int16 = 32000 bytes)
|
||||||
|
MIN_SAMPLE_BYTES = 32000
|
||||||
|
|
||||||
|
_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_loaded():
|
||||||
|
"""Lazy-Load des ECAPA-TDNN. Holt das Modell beim ersten Aufruf von HF;
|
||||||
|
danach cached im HF-Cache-Volume. Erste Init: ~30s download + load,
|
||||||
|
danach <1s warm. Wirft bei Fehler — Caller muss catchen + fail-open."""
|
||||||
|
global _model
|
||||||
|
if _model is not None:
|
||||||
|
return _model
|
||||||
|
import torch
|
||||||
|
from speechbrain.inference.speaker import EncoderClassifier
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
logger.info("[speaker-id] loading ECAPA-TDNN on %s ...", device)
|
||||||
|
_model = EncoderClassifier.from_hparams(
|
||||||
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||||
|
savedir="/root/.cache/huggingface/speechbrain-ecapa",
|
||||||
|
run_opts={"device": device},
|
||||||
|
)
|
||||||
|
logger.info("[speaker-id] model ready (device=%s)", device)
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
if (len(audio_bytes) >= 44
|
||||||
|
and audio_bytes[:4] == b"RIFF"
|
||||||
|
and audio_bytes[8:12] == b"WAVE"):
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
|
||||||
|
sr = wav.getframerate()
|
||||||
|
ch = wav.getnchannels()
|
||||||
|
sw = wav.getsampwidth()
|
||||||
|
if sr != 16000:
|
||||||
|
raise ValueError(f"WAV-Samplerate {sr} != 16000")
|
||||||
|
if ch != 1:
|
||||||
|
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
|
||||||
|
if sw != 2:
|
||||||
|
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||||
|
return wav.readframes(wav.getnframes())
|
||||||
|
return audio_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_bytes_to_tensor(audio_bytes: bytes):
|
||||||
|
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
|
||||||
|
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
|
||||||
|
import torch
|
||||||
|
raw = _normalize_audio_bytes(audio_bytes)
|
||||||
|
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
return torch.from_numpy(arr).unsqueeze(0)
|
||||||
|
|
||||||
|
|
||||||
|
def embed(audio_bytes: bytes) -> np.ndarray:
|
||||||
|
"""Berechnet das Speaker-Embedding fuer einen Audio-Chunk.
|
||||||
|
Erwartet 16kHz int16 LE PCM Mono. Returns 192-dim numpy float32."""
|
||||||
|
import torch
|
||||||
|
model = _ensure_loaded()
|
||||||
|
wav = _audio_bytes_to_tensor(audio_bytes)
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = model.encode_batch(wav)
|
||||||
|
return emb.squeeze().cpu().numpy().astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
"""Kosinus-Aehnlichkeit zwischen zwei 1D-Vektoren, Range [-1, 1].
|
||||||
|
Hoeher = aehnlicher. Bei normalisierten Vektoren ist das gleich dem Skalarprodukt."""
|
||||||
|
na = np.linalg.norm(a)
|
||||||
|
nb = np.linalg.norm(b)
|
||||||
|
if na < 1e-9 or nb < 1e-9:
|
||||||
|
return 0.0
|
||||||
|
return float(np.dot(a, b) / (na * nb))
|
||||||
|
|
||||||
|
|
||||||
|
def save_fingerprint(embeddings: list[np.ndarray], sample_durations_s: list[float]) -> dict:
|
||||||
|
"""Mittelt + L2-normalisiert die Embeddings und schreibt sie nach
|
||||||
|
FINGERPRINT_FILE. Returns das gespeicherte Dict."""
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError("Keine Embeddings zum Speichern")
|
||||||
|
VOICE_ID_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
stacked = np.stack(embeddings)
|
||||||
|
mean = stacked.mean(axis=0)
|
||||||
|
mean = mean / max(np.linalg.norm(mean), 1e-9)
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"embedding": mean.tolist(),
|
||||||
|
"embedding_dim": int(mean.shape[0]),
|
||||||
|
"sample_count": len(embeddings),
|
||||||
|
"sample_durations_s": [float(s) for s in sample_durations_s],
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
}
|
||||||
|
FINGERPRINT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
logger.info("[speaker-id] fingerprint gespeichert: %d Samples, dim=%d, total_s=%.1f",
|
||||||
|
len(embeddings), mean.shape[0], sum(sample_durations_s))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_fingerprint() -> Optional[dict]:
|
||||||
|
"""Returns das Fingerprint-Dict oder None wenn noch nicht enrolled."""
|
||||||
|
if not FINGERPRINT_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(FINGERPRINT_FILE.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[speaker-id] fingerprint laden fehlgeschlagen: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_fingerprint() -> bool:
|
||||||
|
"""Loescht den Fingerprint (z.B. fuer Re-Enrollment). True wenn was weg ist."""
|
||||||
|
if FINGERPRINT_FILE.exists():
|
||||||
|
FINGERPRINT_FILE.unlink()
|
||||||
|
logger.info("[speaker-id] fingerprint geloescht")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify(audio_bytes: bytes, threshold: Optional[float] = None) -> tuple[bool, float]:
|
||||||
|
"""Returns (is_match, similarity).
|
||||||
|
|
||||||
|
Wenn threshold=None: nutzt den Modul-Default (DEFAULT_THRESHOLD) — der wird
|
||||||
|
vom config-Broadcast zur Laufzeit auf den Diagnostic-Slider-Wert gesetzt.
|
||||||
|
Default-Arg-Bindung waere zur Def-Zeit, also bewusst None statt direkt.
|
||||||
|
|
||||||
|
Fail-open: wenn kein Fingerprint vorhanden ist oder das Embedding-Modell
|
||||||
|
crasht, returnt (True, 0.0) — kein Filtering. Sonst wuerde ein kaputter
|
||||||
|
Speaker-ID-Service die ganze Aufnahme blockieren."""
|
||||||
|
if threshold is None:
|
||||||
|
threshold = DEFAULT_THRESHOLD
|
||||||
|
fp = load_fingerprint()
|
||||||
|
if fp is None:
|
||||||
|
return True, 0.0
|
||||||
|
if len(audio_bytes) < MIN_SAMPLE_BYTES:
|
||||||
|
# Zu wenig Audio fuer ein verlaessliches Embedding → durchlassen
|
||||||
|
return True, 0.0
|
||||||
|
try:
|
||||||
|
saved_emb = np.array(fp["embedding"], dtype=np.float32)
|
||||||
|
new_emb = embed(audio_bytes)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[speaker-id] verify embed failed: %s — fail-open", exc)
|
||||||
|
return True, 0.0
|
||||||
|
sim = cosine_similarity(new_emb, saved_emb)
|
||||||
|
return sim >= threshold, sim
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict:
|
||||||
|
"""Status-Snapshot fuer die App / Diagnostic."""
|
||||||
|
fp = load_fingerprint()
|
||||||
|
return {
|
||||||
|
"enrolled": fp is not None,
|
||||||
|
"sample_count": fp.get("sample_count", 0) if fp else 0,
|
||||||
|
"sample_durations_s": fp.get("sample_durations_s", []) if fp else [],
|
||||||
|
"updated_at": fp.get("updated_at") if fp else None,
|
||||||
|
"embedding_dim": fp.get("embedding_dim") if fp else None,
|
||||||
|
"default_threshold": DEFAULT_THRESHOLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_from_samples(samples_b64: list[str]) -> dict:
|
||||||
|
"""Verarbeitet base64-Samples (16kHz int16 LE PCM Mono) zu einem neuen
|
||||||
|
Fingerprint. Returns Status-Dict. Wirft ValueError wenn nichts brauchbar ist."""
|
||||||
|
if not samples_b64:
|
||||||
|
raise ValueError("Keine Samples uebergeben")
|
||||||
|
embeddings: list[np.ndarray] = []
|
||||||
|
durations: list[float] = []
|
||||||
|
rejected: list[dict] = []
|
||||||
|
for idx, s in enumerate(samples_b64):
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(s)
|
||||||
|
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)"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
emb = embed(raw)
|
||||||
|
embeddings.append(emb)
|
||||||
|
durations.append(len(raw) / 2 / 16000.0)
|
||||||
|
except Exception as exc:
|
||||||
|
rejected.append({"index": idx, "reason": f"embed: {exc}"})
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError(
|
||||||
|
f"Keine Samples konnten verarbeitet werden ({len(rejected)} rejected). "
|
||||||
|
f"Details: {rejected[:3]}"
|
||||||
|
)
|
||||||
|
fingerprint = save_fingerprint(embeddings, durations)
|
||||||
|
fingerprint["rejected"] = rejected
|
||||||
|
return fingerprint
|
||||||
Reference in New Issue
Block a user