Compare commits

...

11 Commits

Author SHA1 Message Date
duffyduck 095a10aaf0 release: bump version to 0.1.9.2 2026-06-06 09:30:13 +02:00
duffyduck e3a224478d fix(wakeword): Mic an andere Apps freigeben (WhatsApp-Voicenote etc.)
Bug: ARIAs VOICE_COMMUNICATION-Lock liess WhatsApp-Sprachnachrichten,
Sprach-Suchen u.ae. nur Stille aufnehmen — Android-Audio-Policy gibt
der zweiten App formal Audio, liefert aber Null-Samples solange unsere
Pipeline aktiv ist.

Fix: AudioRecordingCallback (API 24+) registriert sich beim start() und
beobachtet andere Mic-Sessions. Fremder Mic-User detected → unsere
AudioRecord + Effects sofort freigeben (externallyPaused=true), WakeLock
+ Callback bleiben aktiv. Fremder weg → 300ms warten (Audio-Stack-
Settling), nochmal pruefen, dann reaktivieren.

Refactor mit drin: start()/stop() benutzen jetzt zwei Helper
(acquireAndStartRecording, stopAndReleaseRecording) damit die Mic-Setup-
Logik nicht zwischen start() und resumeAfterExternal() dupliziert wird.

Trade-offs:
- Wake-Word taub solange andere App das Mic nutzt — akzeptabel.
- API < 24: kein Callback verfuegbar, alter Stand (kein Mic-Sharing).
- Phone-Call kollidiert nicht mit phoneCallService.pauseForCall —
  beide pausieren/reaktivieren idempotent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 09:29:00 +02:00
duffyduck 61c9183033 refactor(brain): Fast-Path als Skill-Capability — fast_patterns im Manifest
Frueher: Spotify-spezifische Patterns hardcoded in agent.py — jeder neue
Steuer-Skill haette wieder Brain-Code-Aenderungen gebraucht.
Jetzt: jeder Skill deklariert seine eigenen Patterns im Manifest unter
fast_patterns: [{match, args, reply}]. Brain iteriert generisch, kein
Skill bekommt Sonderbehandlung.

- agent.py: _try_skill_fast_path liest aus skills.list_skills(), keine
  Spotify-Konstanten mehr. skill_create/skill_update Tool-Schema kennt
  fast_patterns (mit Beispiel + Wann-nutzen-Hinweis).
- skills.py: _normalize_fast_patterns validiert Regex + filtert kaputte
  Eintraege; create_skill/update_skill akzeptieren das Feld.
- main.py: einmalige Lifespan-Migration — wenn spotify-Skill existiert
  und kein fast_patterns hat, werden die alten Hardcoded-Patterns
  rueberkopiert. Idempotent, laeuft bei jedem Restart sicher mehrfach.
- seed_rules.py: neue Regel `seed/skill-rule/fast-patterns-for-control`
  erklaert ARIA wann sie das Feature nutzen soll (reines Steuern: ja,
  kreativer Output / Parametrisierung: nein) — mit Beispiel.

Trade-off: Volume-Patterns (lauter/leiser) fallen aus dem Fast-Path raus,
weil die Multi-Step-Logik (GET state → compute → PUT) sich nicht
deklarativ ausdruecken laesst. Wer das zurueck will: Spotify-Skill um
einen action=volume_relative-Arg erweitern der die Mathe intern macht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:56:15 +02:00
duffyduck e04bbef361 release: bump version to 0.1.9.1 2026-06-06 08:30:31 +02:00
duffyduck e82e07e3a2 fix: 5er-Bundle — Wake-Word, Spotify-Latenz, File-Limit, Connection-Refused
- WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event-
  Race + setBackground/setForeground statt setResumeCooldown im AppState.
- Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin
  emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr.
- Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track,
  pause, play, lauter, ...) matchen via Regex und gehen direkt aufs
  spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl.
- File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload +
  Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB.
- TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send
  fast-failt bei disconnected RVS statt 30s zu timeouten, und beide
  UIs reloaden automatisch beim Reconnect-Event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:27:08 +02:00
duffyduck 886b4409d2 release: bump version to 0.1.2.0 2026-06-02 15:22:02 +02:00
duffyduck bcea49365d feat(filemanager): 👁 Open + ⬇ Download pro Datei in App + Diagnostic
Stefan's UX-Wunsch: Datei direkt oeffnen ohne Umweg ueber Download.
Plus: in der App fehlte komplett der Per-Row-Download-Button (nur via
Checkbox + Bulk-Download). Beides jetzt gefixt.

App (SettingsScreen.tsx):
  - Neue per-Row-Buttons: 👁 Open + ⬇ Download + 🕒 Versionen + 🗑 Loeschen
  - Open-Pfad nutzt requestId-Praefix 'open-' im file_response-Handler
    → Datei wird nach CachesDirectory geschrieben (kein Storage-Bloat)
    → FileOpener-Native-Module (Intent.ACTION_VIEW mit MIME) oeffnet
      mit dem System-Picker → User waehlt PDF-Viewer / Galerie / Player
  - guessMimeFromName-Helper fuer den Intent damit Android die passende
    App findet
  - Download-Pfad unveraendert ('single-' Praefix), schreibt nach
    DownloadDirectory mit Suffix-Inkrement bei Namens-Konflikt

Diagnostic (server.js + index.html):
  - Neue Route /api/files-view (gleicher Code-Pfad wie files-download,
    aber Content-Disposition:inline + echter MIME-Type statt octet-stream)
  - Browser zeigt PDF / Bilder / Text im neuen Tab statt forcierten Download
  - 👁-Button in jeder File-Row neben ⬇/🕒/🗑
  - Fallback fuer unbekannte MIMEs: octet-stream → Browser bietet Download

Bei beiden Pfaden bleibt der Cache nutzbar: nach App-Open kann der User
die Datei im jeweiligen Viewer behalten; im Browser bleibt sie im Tab.
2026-06-02 14:55:24 +02:00
duffyduck 05eb7ed144 fix(whisper): Halluzinations-Filter — kein 'Untertitelung des ZDF' bei Stille
Stefan-Reproduktion: nach Wake-Word + ARIA-Antwort oeffnet das
Conversation-Window automatisch das Mikro fuer Follow-Up. Wenn Stefan
nichts sagt, ist das 4-8s Stille. Whisper halluziniert dann YouTube-
Untertitel-Patterns aus seinem Trainings-Corpus — gemessen 'Untertitelung
des ZDF, 2020' — und ARIA antwortet brav darauf. Endlos-Loop bis Stefan
manuell stoppt.

Fix in faster-whisper-transcribe:

1. Per-Segment no_speech_prob auswerten. Bei >= 0.6 (relativ konservativ:
   echte leise Sprache geht noch durch) → Segment verwerfen. Das eliminiert
   die offensichtlichen Halluzinationen schon zu 90%.

2. Bekannte Hallucination-Phrasen-Blacklist:
     - Untertitelung/Untertitel des ZDF (mit/ohne Jahr)
     - Amara.org community
     - Vielen Dank fuer's Zuschauen (mit allen Umlaut/Apostroph-Varianten)
     - Thanks for watching / Subs by ...
   Substring-Match (case-insensitive) auf normalisiertem Text (lowercase,
   Trailing-Punctuation und Jahres-Suffix '2020' weg).

3. Wenn ALLE Segmente einer Aufnahme rausgefiltert werden, ist text=''
   → App behandelt das via existierende no-speech-Pfad: Conversation-
   Window endet sauber, kein TTS-Echo-Loop.

Tradeoff: echte Phrasen wie 'Vielen Dank' allein gehen durch (Pattern
ist 'vielen dank fuer's zuschauen' — voller Match). Nur die bekannten
Halluzinations-Phrasen werden weggefiltert.

Falls in Zukunft neue Patterns auftauchen (Whispers Modell ändert sich):
einfach _HALLUCINATION_PHRASES erweitern, kein Brain-Restart noetig (lebt
in der Whisper-Bridge, die hot-reloaded werden kann).
2026-06-02 14:19:22 +02:00
duffyduck ddfc4261e5 fix(diagnostic): Versions-Liste dedupliziert via Blob-Hash — keine Restore-Duplikate
Stefan-Beobachtung: Wenn man V1 restored, taucht der neue Restore-Commit
als V4 in der Liste auf, mit identischem Inhalt wie V1. Bei mehrfachem
Hin- und Herrestoren wird die Liste schnell unuebersichtlich.

Fix: listVersionsForFile dedupliziert auf Blob-Hash-Ebene. Pro
inhaltlich identischer Datei-Variante wird nur der AELTESTE (= zuerst
in der History entstandene) Commit gezeigt. Restore-Commits werden
damit gefiltert da ihr Blob = der Blob eines aelteren Commits ist.

AKTIV-Marker wandert mit: vergleicht Blob der Working-Copy mit jedem
sichtbaren Eintrag — der Match-Eintrag bekommt isCurrent=true. So
zeigt das UI nach Restore "V1 ist AKTIV" obwohl im git ein neuer
V4-Commit existiert.

Implementation:
  - log --format=%H + ls-tree pro Commit → blob-hash sammeln
  - rueckwaerts durchgehen (chronologisch aelteste zuerst), seen-Set
    dedupliziert
  - Reverse fuer UI (neueste-zuerst)
  - git hash-object <working-copy> → currentBlob, mit jedem Eintrag
    vergleichen fuer den AKTIV-Marker
  - blob-Feld aus Response strippen (sieht aus wie zweite Commit-ID)

Audit-Trail bleibt im git intakt — Restore-Commits sind weiterhin
da, nur nicht im UI sichtbar. Falls jemals forensische Untersuchung
noetig: `git log` im /shared/uploads zeigt alle, inkl. Restore-Commits.
2026-06-02 13:59:42 +02:00
duffyduck 20e623dc37 feat(app): Versions-Historie pro Datei im App-Datei-Manager
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
  - pro Datei via 🕒-Button die Versions-Liste anzeigen
  - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
  - per ⟲ eine alte Version als neue aktive setzen (non-destructive,
    macht im Backend einen 'restore:'-Commit, die bisherige Version
    bleibt in der Historie)

Drei neue RVS-Message-Type-Paare:
  file_version_list_request    / _response
  file_version_download_request / _response (base64)
  file_version_restore_request  / _response

rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist.

bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic
(http://localhost:3001/api/files-versions / -version-content / -restore).
Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge
wrappt die Binary-Antwort als base64 fuer den RVS-Transport.

android/src/screens/SettingsScreen.tsx:
  - State versionsOpen/versionsList/-Loading/-Error
  - Drei rvs.onMessage-Branches fuer die neuen *_response Types
  - 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne)
  - Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject,
    formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag)
  - Restore-Button hat Confirm-Alert
  - Bei file_version_restore_response: list refresh + file-list refresh
2026-06-02 13:50:35 +02:00
duffyduck 6464dbe28c feat(diagnostic): Auto-Versionierung fuer /shared/uploads/ + Versions-UI
Stefan-Wunsch: ARIA-Aenderungen an Dateien sollen vom System (nicht
von ARIA selbst) automatisch versioniert werden. Im Datei-Manager:
Versionen auflisten, einzelne downloaden, oder als neue aktive Version
setzen (Restore = non-destructive neuer Commit).

Implementierung (alles im diagnostic-Container, da der eh schon
File-Handling kann):

1. Dockerfile: apk add git

2. server.js — Auto-Commit-Loop:
   - Beim Start: /shared/uploads als git-Repo initialisieren (idempotent;
     bestehendes .git wird uebernommen)
   - setInterval(30s): git status --porcelain → wenn dirty, add+commit
     mit "auto: <ISO-Timestamp>"-Message
   - Re-Entrancy-Guard fuer langsame git-Ops

3. server.js — drei neue HTTP-Routen:
   GET  /api/files-versions?path=X
     → [{hash, ts, subject, isCurrent}] aus git log --follow
   GET  /api/files-version-content?path=X&hash=Y
     → Binary-Stream der Datei aus diesem Commit (Content-Disposition
       attachment mit "name@<short-hash>.ext" als Default-Dateiname)
   POST /api/files-version-restore body={path, hash}
     → non-destructive: schreibt alten Inhalt als NEUE Version, neuer
       Commit "restore: <path> <- <short>". Aktive Version damit
       weiterhin rollback-bar.

4. index.html — Datei-Manager:
   - Pro Datei zusaetzlich 🕒-Button neben ⬇/🗑
   - Klick zeigt Modal mit Version-Liste (timestamp, short-hash,
     'AKTIV'-Marker fuer den jeweils letzten)
   - Pro Version: ⬇ Download + ⟲ Restore (mit Confirm)
   - Restore broadcasted file_version_restored damit Browser refreshen

Path-Safety: alle Pfade muessen relative-to-uploads sein, kein '..',
kein '/', kein '.git/'. Hash muss [0-9a-f]{7,40}.

.gitignore zunaechst keine — uploads/ ist eh nur User-/ARIA-Dateien,
kein Log-Noise erwartet. Falls Disk explodiert: spaeter ergaenzen.

Step-2 (App-Side via RVS-Messages) folgt im naechsten Commit, sobald
das hier in Diagnostic funktioniert.
2026-06-02 09:25:06 +02:00
19 changed files with 1392 additions and 90 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10900
versionName "0.1.9.0"
versionCode 10902
versionName "0.1.9.2"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -7,11 +7,16 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
@@ -49,6 +54,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private const val EMBEDDING_DIM = 96
private const val MEL_BINS = 32
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()
@@ -95,6 +106,22 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
// 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.
@@ -159,53 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
}
try {
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()
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()
acquireAndStartRecording()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// 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}")
}
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
// AudioRecordingCallback registrieren: andere Apps (WhatsApp-
// Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
// es vorruebergehend frei statt sie ins Leere recorden zu lassen.
registerRecordingCallback()
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
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() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
@@ -247,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
@@ -263,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
@ReactMethod
fun dispose(promise: Promise) {
running.set(false)
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
disposeSessions()
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() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
@@ -313,6 +433,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
}
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 {
putString("model", modelName)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.9.0",
"version": "0.1.9.2",
"private": true,
"scripts": {
"android": "react-native run-android",
+12
View File
@@ -23,6 +23,7 @@ import {
} from 'react-native';
import brainApi, { Trigger } from '../services/brainApi';
import rvs from '../services/rvs';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
@@ -65,6 +66,17 @@ export const TriggerBrowser: React.FC = () => {
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 => {
if (filter === 'active') return t.active;
if (filter === 'inactive') return !t.active;
+2 -1
View File
@@ -522,8 +522,9 @@ const ChatScreen: React.FC = () => {
const sub = AppState.addEventListener('change', (next) => {
if (next === 'background' || next === 'inactive') {
lastBackgroundAt = Date.now();
wakeWordService.setBackground();
} else if (lastState !== 'active' && next === 'active') {
wakeWordService.setResumeCooldown(3000);
wakeWordService.setForeground();
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
+284 -4
View File
@@ -21,9 +21,37 @@ import {
PermissionsAndroid,
useWindowDimensions,
DeviceEventEmitter,
NativeModules,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
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 rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import {
@@ -180,6 +208,14 @@ const SettingsScreen: React.FC = () => {
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
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 [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -506,9 +542,11 @@ const SettingsScreen: React.FC = () => {
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage
const isDownload = reqId.startsWith('single-');
const isOpen = reqId.startsWith('open-');
if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.)
if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
@@ -518,10 +556,28 @@ const SettingsScreen: React.FC = () => {
'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}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
@@ -540,6 +596,74 @@ const SettingsScreen: React.FC = () => {
})();
}
// 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
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
@@ -584,6 +708,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 ---
const openQRScanner = useCallback(() => {
@@ -964,6 +1102,44 @@ const SettingsScreen: React.FC = () => {
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</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
onPress={() => {
Alert.alert(
@@ -991,6 +1167,110 @@ const SettingsScreen: React.FC = () => {
})()}
</View>
</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
style={styles.container}
contentContainerStyle={styles.content}
+9
View File
@@ -77,6 +77,15 @@ interface SendOpts {
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
_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) => {
const requestId = _newRequestId();
const timer = setTimeout(() => {
+47
View File
@@ -91,6 +91,18 @@ class WakeWordService {
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
* Stefan gar nicht in der App war). */
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;
private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false;
@@ -228,14 +240,44 @@ class WakeWordService {
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. */
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();
if (now < this.cooldownUntilMs) {
const left = this.cooldownUntilMs - now;
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
return;
}
this.detectionInProgress = true;
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening);
import('./logger').then(m => m.reportAppDebug('wake.detect',
@@ -503,7 +545,12 @@ class WakeWordService {
private setState(state: WakeWordState): void {
if (this.state !== state) {
const wasConversing = this.state === 'conversing';
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));
}
}
+111
View File
@@ -127,6 +127,25 @@ META_TOOLS = [
"items": {"type": "object"},
"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"],
},
@@ -193,6 +212,16 @@ META_TOOLS = [
"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"],
},
@@ -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:
"""Mappt einen Skill auf ein OpenAI-Function-Tool."""
args = s.get("args") or []
@@ -849,6 +900,54 @@ class Agent:
self._pending_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 ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
@@ -861,6 +960,15 @@ class Agent:
# Events vom letzten Turn weglassen
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
self.conversation.add("user", user_message, source=source)
@@ -1001,6 +1109,7 @@ class Agent:
args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None,
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1064,6 +1173,8 @@ class Agent:
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
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:
return "FEHLER: keine Felder zum Update angegeben."
try:
+57
View File
@@ -45,6 +45,54 @@ logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
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
async def lifespan(app: FastAPI):
"""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)
except Exception as 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))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
+48
View File
@@ -131,6 +131,54 @@ SEED_RULES: List[dict] = [
"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",
"type": "rule",
+36
View File
@@ -164,6 +164,7 @@ def create_skill(
pip_packages: Optional[list[str]] = None,
author: str = "aria",
config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None,
) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -213,6 +214,7 @@ def create_skill(
"version": "1.0",
"author": author,
"config_schema": _normalize_config_schema(config_schema),
"fast_patterns": _normalize_fast_patterns(fast_patterns),
"version_history": [],
}
write_manifest(name, manifest)
@@ -261,6 +263,38 @@ def _normalize_config_schema(schema: Optional[list]) -> list:
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:
venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv)
@@ -307,6 +341,8 @@ def update_skill(name: str, patch: dict) -> dict:
manifest[k] = v
if "config_schema" in patch:
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
if "entry_code" in patch and patch["entry_code"]:
+129 -5
View File
@@ -1606,11 +1606,12 @@ class ARIABridge:
try:
url = f"{current_url}?token={self.rvs_token}"
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-
# inflate (~1.33×). Groessere Files lehnt der file_request-
# Handler proaktiv ab bevor's zur 1009-Disconnection kommt.
async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws:
# inflate (~1.33×) — 1 GB binaer ≈ 1.34 GB base64, plus Margin.
# Groessere Files lehnt der file_request-Handler proaktiv ab
# bevor's zur 1009-Disconnection kommt.
async with websockets.connect(url, max_size=1500 * 1024 * 1024) as ws:
self.ws_rvs = ws
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
@@ -2405,6 +2406,129 @@ class ARIABridge:
logger.warning("[rvs] file_delete_request: %s", e)
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":
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
# /shared/state/location.json geschrieben, damit Watcher-Trigger
@@ -2471,7 +2595,7 @@ class ARIABridge:
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
# 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:
file_size = os.path.getsize(server_path)
except OSError as exc:
+2 -1
View File
@@ -1,7 +1,8 @@
FROM node:22-alpine
WORKDIR /app
# 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 ./
RUN npm install --production
COPY . .
+90
View File
@@ -4038,12 +4038,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:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</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="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>
</div>`;
}).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() {
const paths = [...filesSelected];
if (!paths.length) return;
@@ -4102,6 +4175,12 @@
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) {
if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return;
try {
@@ -5612,5 +5691,16 @@
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
loadAriaStreamHistory();
</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>
</html>
+276 -3
View File
@@ -92,6 +92,174 @@ let activeSessionKey = (() => {
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 ─────────────
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
// 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 }));
}
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 p = u.searchParams.get("path") || "";
const safe = path.resolve(p);
@@ -1465,10 +1636,26 @@ const server = http.createServer((req, res) => {
}
const stat = fs.statSync(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, {
"Content-Type": "application/octet-stream",
"Content-Type": mime,
"Content-Length": stat.size,
"Content-Disposition": `attachment; filename="${fname}"`,
"Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
});
fs.createReadStream(safe).pipe(res);
return;
@@ -1594,6 +1781,92 @@ const server = http.createServer((req, res) => {
}
});
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") {
// voice_config.json + highlight_triggers.json als JSON-Bundle exportieren
try {
+3
View File
@@ -26,6 +26,9 @@ services:
- ./updates:/updates # APK-Dateien fuer Auto-Update
environment:
- 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:
- aria-rvs-net
+15 -5
View File
@@ -42,6 +42,11 @@ const ALLOWED_TYPES = new Set([
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done",
// 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",
"config_request",
"flux_request", "flux_response",
@@ -88,15 +93,20 @@ function cleanupRooms() {
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
// 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.
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
// (Code 1009 message too big, Bridge crashed im cleanup). 1500 MB
// deckt bis ~1 GB binaer ab (mit base64 ~33% Overhead + WS-Frame-
// 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 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
httpServer.on("upgrade", (req, socket, head) => {
+76 -1
View File
@@ -109,7 +109,27 @@ class WhisperRunner:
segments, info = self.model.transcribe(
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
loop = asyncio.get_event_loop()
@@ -117,6 +137,61 @@ class WhisperRunner:
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:
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type: