package com.ariacockpit

import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
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
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.nio.FloatBuffer
import java.util.concurrent.atomic.AtomicBoolean

/**
 * Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord).
 *
 * Drei-stufige ONNX Pipeline:
 *   1. Audio (16kHz mono int16, 1280-Sample-Chunks) → Melspectrogram → 32-mel Frames
 *   2. 76 Mel-Frames Sliding Window (stride 8) → Speech-Embedding → 96-dim Vektor
 *   3. Letzte 16 Embeddings (~1.28s Kontext) → Wake-Word-Klassifikator → Sigmoid-Score
 *
 *  Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword
 *  ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden
 *  Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`.
 *
 *  Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde.
 */
class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
    override fun getName() = "OpenWakeWord"

    companion object {
        private const val TAG = "OpenWakeWord"
        private const val SAMPLE_RATE = 16000
        private const val CHUNK_SAMPLES = 1280               // 80ms @ 16kHz
        private const val MEL_FRAMES_PER_EMBEDDING = 76      // Embedding-Fenster
        private const val EMBEDDING_STRIDE = 8               // Slide um 8 Mel-Frames
        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()
    private var melSession: OrtSession? = null
    private var embSession: OrtSession? = null
    private var wwSession: OrtSession? = null

    private var melInputName: String = "input"
    private var embInputName: String = "input_1"
    private var wwInputName: String = "input"
    // Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
    // hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
    // Wird beim init() aus den Modell-Metadaten gelesen.
    private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES

    // Konfiguration
    private var threshold: Float = 0.5f
    private var patience: Int = 2
    private var debounceMs: Long = 1500
    private var modelName: String = "hey_jarvis"

    // Audio-Capture-Thread
    private var audioRecord: AudioRecord? = null
    private val running = AtomicBoolean(false)
    private var captureThread: Thread? = null

    // Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
    // das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
    // Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
    private var aec: AcousticEchoCanceler? = null
    private var ns: NoiseSuppressor? = null
    private var agc: AutomaticGainControl? = null

    // PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
    // die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
    // queuen sich die Events nur und werden erst beim App-Foreground
    // delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
    // kommen erst wenn ich die App nach vorne hole").
    private var wakeLock: PowerManager.WakeLock? = null

    // Inferenz-State
    private val melBuffer: ArrayList<FloatArray> = ArrayList(256)   // Liste von 32-dim Frames
    private var melProcessedIdx: Int = 0
    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.
     *  modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
     */
    @ReactMethod
    fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
        try {
            disposeSessions()
            this.modelName = modelName
            this.threshold = threshold.toFloat()
            this.patience = patience.coerceAtLeast(1)
            this.debounceMs = debounceMs.toLong()

            val ctx = reactApplicationContext
            val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
            val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
            val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }

            val opts = OrtSession.SessionOptions()
            melSession = env.createSession(melBytes, opts)
            embSession = env.createSession(embBytes, opts)
            wwSession = env.createSession(wwBytes, opts)

            melInputName = melSession!!.inputNames.first()
            embInputName = embSession!!.inputNames.first()
            wwInputName = wwSession!!.inputNames.first()

            // WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
            // Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
            val wwInputInfo = wwSession!!.inputInfo[wwInputName]
            val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
            wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES

            Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
                    "debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
            promise.resolve(true)
        } catch (e: Exception) {
            Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
            disposeSessions()
            promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
        }
    }

    @ReactMethod
    fun start(promise: Promise) {
        if (running.get()) {
            promise.resolve(true)
            return
        }
        if (melSession == null || embSession == null || wwSession == null) {
            promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
            return
        }
        // Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
        // aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
        // failt.
        val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
        if (perm != PackageManager.PERMISSION_GRANTED) {
            promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
            return
        }

        try {
            acquireAndStartRecording()

            // PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
            // die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
            // 8h Cap als Sicherheit gegen forgotten-release.
            try {
                val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
                wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
                                          "AriaCockpit:WakeWordRecord").apply {
                    setReferenceCounted(false)
                    acquire(8 * 60 * 60 * 1000L)
                }
                Log.i(TAG, "WakeLock acquired")
            } catch (e: Exception) {
                Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
            }

            // 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)
        } catch (e: Exception) {
            Log.e(TAG, "start fehlgeschlagen", e)
            running.set(false)
            audioRecord?.release()
            audioRecord = null
            promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
        }
    }

    /** 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) {}
        try { agc?.release() } catch (_: Exception) {}
        aec = null; ns = null; agc = null
    }

    @ReactMethod
    fun stop(promise: Promise) {
        unregisterRecordingCallback()
        externallyPaused = false
        stopAndReleaseRecording()
        releaseWakeLock()
        Log.i(TAG, "Lauschen gestoppt")
        promise.resolve(true)
    }

    @ReactMethod
    fun dispose(promise: Promise) {
        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()
            if (wakeLock != null) Log.i(TAG, "WakeLock released")
        } catch (e: Exception) {
            Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
        }
        wakeLock = null
    }

    @ReactMethod
    fun isAvailable(promise: Promise) {
        // Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
        promise.resolve(true)
    }

    // RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
    @ReactMethod fun addListener(eventName: String) {}
    @ReactMethod fun removeListeners(count: Int) {}

    private fun disposeSessions() {
        try { melSession?.close() } catch (_: Exception) {}
        try { embSession?.close() } catch (_: Exception) {}
        try { wwSession?.close() } catch (_: Exception) {}
        melSession = null
        embSession = null
        wwSession = null
    }

    private fun resetInferenceState() {
        melBuffer.clear()
        melProcessedIdx = 0
        embBuffer.clear()
        consecutiveAboveThreshold = 0
        lastDetectionMs = 0L
    }

    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)
        }
        try {
            reactApplicationContext
                .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
                .emit("WakeWordDetected", params)
        } catch (e: Exception) {
            Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
        }
    }

    private fun captureLoop() {
        val buf = ShortArray(CHUNK_SAMPLES)
        val record = audioRecord ?: return
        Log.i(TAG, "Capture-Loop gestartet")
        while (running.get()) {
            var read = 0
            while (read < CHUNK_SAMPLES && running.get()) {
                val n = record.read(buf, read, CHUNK_SAMPLES - read)
                if (n <= 0) {
                    Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
                    running.set(false)
                    return
                }
                read += n
            }
            if (!running.get()) break
            try {
                processChunk(buf)
            } catch (e: Exception) {
                Log.w(TAG, "processChunk: ${e.message}")
            }
        }
        Log.i(TAG, "Capture-Loop beendet")
    }

    /** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
    private fun processChunk(audio: ShortArray) {
        // 1) Audio → mel (output (1, 1, frames, 32))
        val floats = FloatArray(audio.size) { audio[it].toFloat() }
        val melTensor = OnnxTensor.createTensor(
            env,
            FloatBuffer.wrap(floats),
            longArrayOf(1L, audio.size.toLong()),
        )
        val melResult = melSession!!.run(mapOf(melInputName to melTensor))
        val melOut = melResult.get(0).value
        melTensor.close()
        @Suppress("UNCHECKED_CAST")
        val mel4 = melOut as Array<Array<Array<FloatArray>>>
        val frames = mel4[0][0]
        // openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
        for (frame in frames) {
            val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
            melBuffer.add(scaled)
        }
        melResult.close()

        // 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
        while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
            val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
            var pos = 0
            for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
                val src = melBuffer[melProcessedIdx + i]
                System.arraycopy(src, 0, flat, pos, MEL_BINS)
                pos += MEL_BINS
            }
            val embIn = OnnxTensor.createTensor(
                env,
                FloatBuffer.wrap(flat),
                longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
            )
            val embRes = embSession!!.run(mapOf(embInputName to embIn))
            val embOut = embRes.get(0).value
            embIn.close()
            // Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
            // Die Google-Embedding-Pipeline behaelt extra Dimensionen.
            @Suppress("UNCHECKED_CAST")
            val embArr = embOut as Array<Array<Array<FloatArray>>>
            embBuffer.addLast(embArr[0][0][0].copyOf())
            while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
            embRes.close()

            melProcessedIdx += EMBEDDING_STRIDE
        }
        // Mel-Buffer trimmen — verhindert Memory-Wachstum
        if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) {
            val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING
            val newList = ArrayList<FloatArray>(melBuffer.size - keepFrom)
            for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i])
            melBuffer.clear()
            melBuffer.addAll(newList)
            melProcessedIdx = MEL_FRAMES_PER_EMBEDDING
        }

        // 3) Klassifikation — sobald wir 16 Embeddings haben
        if (embBuffer.size < wwInputFrames) return
        val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
        var p = 0
        // Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
        for (e in embBuffer) {
            System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
            p += EMBEDDING_DIM
        }
        val wwIn = OnnxTensor.createTensor(
            env,
            FloatBuffer.wrap(flatEmb),
            longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
        )
        val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
        val wwOut = wwRes.get(0).value
        wwIn.close()
        // Erwartete Output-Form: (1, 1) → Array<FloatArray>
        @Suppress("UNCHECKED_CAST")
        val score = (wwOut as Array<FloatArray>)[0][0]
        wwRes.close()

        if (score >= threshold) {
            consecutiveAboveThreshold++
            if (consecutiveAboveThreshold >= patience) {
                val now = System.currentTimeMillis()
                if (now - lastDetectionMs >= debounceMs) {
                    lastDetectionMs = now
                    consecutiveAboveThreshold = 0
                    Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName")
                    emitDetected()
                }
            }
        } else {
            consecutiveAboveThreshold = 0
        }
    }
}
