// STTManager.swift

import Foundation
import AVFoundation
import React
import Accelerate
import ten_vad

// Speaker profile for diarization
struct SpeakerProfile {
    let speakerId: Int
    var embedding: [Float]
    var numSegments: Int
    var status: String // "pending" or "confirmed"
    var totalSpeechDurationMs: Int64
    var lastSeenTimestamp: TimeInterval
}

// Diarization configuration (using STTConstants for defaults)
struct DiarizationConfigStruct {
    var speakerThreshold: Float = STTConstants.defaultSpeakerThreshold      // Min similarity for speaker assignment (lenient)
    var embeddingThreshold: Float = STTConstants.defaultEmbeddingThreshold  // Min similarity for profile update (strict)
    var minSpeechDurationMs: Int = STTConstants.defaultMinSpeechDurationMs  // Min speech duration to create speaker
    var minEmbeddingUpdateMs: Int = STTConstants.defaultMinEmbeddingUpdateMs // Min speech duration to update profile
    var maxSpeakers: Int = STTConstants.defaultMaxSpeakers                  // Limit tracked speakers (LRU eviction)
    var embeddingAverageWeight: Float = STTConstants.defaultEmbeddingAverageWeight // Weight for running average update
    var onboardingSegments: Int = STTConstants.defaultOnboardingSegments    // First N segments always assigned to Speaker 1
    var minSpeechForNewSpeaker: Int = STTConstants.defaultMinSpeechForNewSpeaker  // Min accumulated speech (ms) before creating new speaker
}

// Streaming state update
struct StreamingUpdate {
    let volatile: String
    let confirmed: String
    let fullText: String
    let confidence: Float
    let processingTimeMs: Int64
    let audioDurationMs: Int64
}

// Streaming state manager for volatile/confirmed transcripts (thread-safe)
class StreamingStateManager {
    private let lock = NSLock()
    private var volatileText: String = ""
    private var confirmedText: String = ""
    private var totalContextMs: Int64 = 0
    private var lastProcessingTimeMs: Int64 = 0

    var confirmationThreshold: Float = STTConstants.defaultConfirmationThreshold
    var minContextMs: Int64 = STTConstants.defaultMinContextMs

    func updateState(newText: String, confidence: Float, audioLengthMs: Int64, processingTimeMs: Int64) -> StreamingUpdate {
        totalContextMs += audioLengthMs
        lastProcessingTimeMs = processingTimeMs

        let shouldConfirm = confidence >= confirmationThreshold
            && totalContextMs >= minContextMs
            && !volatileText.isEmpty

        if shouldConfirm {
            confirmedText = confirmedText.isEmpty ? volatileText : "\(confirmedText) \(volatileText)"
            volatileText = newText
            totalContextMs = 0
        } else {
            volatileText = newText
        }

        let fullText: String
        if confirmedText.isEmpty {
            fullText = volatileText
        } else if volatileText.isEmpty {
            fullText = confirmedText
        } else {
            fullText = "\(confirmedText) \(volatileText)"
        }

        return StreamingUpdate(
            volatile: volatileText,
            confirmed: confirmedText,
            fullText: fullText.trimmingCharacters(in: .whitespaces),
            confidence: confidence,
            processingTimeMs: processingTimeMs,
            audioDurationMs: audioLengthMs
        )
    }

    func confirmCurrent(processingTimeMs: Int64) -> StreamingUpdate {
        if !volatileText.isEmpty {
            confirmedText = confirmedText.isEmpty ? volatileText : "\(confirmedText) \(volatileText)"
            volatileText = ""
        }

        return StreamingUpdate(
            volatile: "",
            confirmed: confirmedText,
            fullText: confirmedText.trimmingCharacters(in: .whitespaces),
            confidence: 1.0,
            processingTimeMs: processingTimeMs,
            audioDurationMs: 0
        )
    }

    func reset() {
        volatileText = ""
        confirmedText = ""
        totalContextMs = 0
        lastProcessingTimeMs = 0
    }
}

// TEN-VAD wrapper class
class TenVadWrapper {
    private var handle: ten_vad_handle_t?
    private let hopSize: Int
    private let threshold: Float

    init(hopSize: Int = 160, threshold: Float = 0.5) {
        self.hopSize = hopSize
        self.threshold = threshold

        var handlePtr: ten_vad_handle_t? = nil
        let result = ten_vad_create(&handlePtr, hopSize, threshold)
        if result == 0 {
            self.handle = handlePtr
        }
    }

    deinit {
        destroy()
    }

    func process(samples: [Int16]) -> (probability: Float, isSpeech: Bool)? {
        guard let handle = handle, samples.count == hopSize else { return nil }

        var probability: Float = 0
        var flag: Int32 = 0

        let result = samples.withUnsafeBufferPointer { ptr -> Int32 in
            return ten_vad_process(handle, ptr.baseAddress, samples.count, &probability, &flag)
        }

        if result != 0 { return nil }
        return (probability, flag != 0)
    }

    func destroy() {
        if var handle = handle {
            ten_vad_destroy(&handle)
            self.handle = nil
        }
    }

    func getVersion() -> String {
        guard let version = ten_vad_get_version() else { return "unknown" }
        return String(cString: version)
    }

    var isInitialized: Bool { handle != nil }
}

@objc(STTManager)
class STTManager: RCTEventEmitter {
    // STT components - Online (streaming)
    private var onlineRecognizer: OpaquePointer?
    private var recognizerStream: OpaquePointer?

    // STT components - Offline
    private var offlineRecognizer: OpaquePointer?
    private var modelType: String = "streaming"
    private var normalizeCase: String = "none"

    // Denoiser
    private var denoiser: OpaquePointer?
    private var denoiserEnabled: Bool = false

    // Punctuation
    private var punctuation: OpaquePointer?
    private var punctuationEnabled: Bool = false

    // TEN-VAD
    private var tenVad: TenVadWrapper?
    private var vadHopSize: Int = 160
    private var vadFrameBuffer: [Int16] = []

    // Speaker diarization
    private var speakerExtractor: OpaquePointer?
    private var speakerProfiles: [SpeakerProfile] = []
    private var currentSpeakerId: Int = -1
    private var nextSpeakerId: Int = 1
    private var diarizationEnabled: Bool = false
    private var diarizationConfig: DiarizationConfigStruct = DiarizationConfigStruct()

    // Thread-safety for diarization operations
    private let diarizationQueue = DispatchQueue(label: "com.stt.diarization", qos: .userInitiated)
    private let speakerProfilesLock = NSLock()

    // Last match metrics for event emission
    private var lastConfidence: Float = 0
    private var lastEmbeddingQuality: Float = 0

    // Onboarding mode: first N segments always go to Speaker 1
    private var onboardingRemaining: Int = 0

    // New speaker buffering: accumulate evidence before creating new speaker
    private var candidateEmbeddings: [[Float]] = []
    private var candidateSpeechMs: Int64 = 0
    private var lastMatchedSpeakerId: Int = -1

    // Audio capture for debugging
    private var audioCaptureEnabled: Bool = false
    private var audioCaptureFileHandle: FileHandle?
    private var audioCaptureFilePath: String?
    private var audioCaptureSamplesWritten: Int64 = 0

    // Audio recording
    private var audioEngine: AVAudioEngine?
    private var isRecording: Bool = false

    // Configuration (using STTConstants for defaults)
    private var sampleRate: Int = STTConstants.defaultSampleRate
    private var vadThreshold: Float = STTConstants.defaultVadThreshold
    private var vadMinSpeechMs: Int = STTConstants.defaultMinSpeechMs
    private var vadMinSilenceMs: Int = STTConstants.defaultMinSilenceMs
    private var vadMaxSpeechMs: Int = STTConstants.defaultMaxSpeechMs
    private var vadSpeechPaddingMs: Int = STTConstants.defaultSpeechPaddingMs
    private var vadMode: String = "normal"

    // VAD state tracking
    private var vadState: String = "silence"
    private var speechStartTime: TimeInterval = 0
    private var silenceStartTime: TimeInterval = 0
    private var speechBuffer: [[Float]] = []
    private var continuousSpeechMs: Int64 = 0
    private var lastFrameTime: TimeInterval = 0

    // Streaming state manager
    private var streamingStateManager: StreamingStateManager?
    private var lastProcessingTimeMs: Int64 = 0

    // Offline mode buffer
    private var offlineAudioBuffer: [[Float]] = []
    private var isInSpeech: Bool = false

    // Results accumulation
    private var recognitionResults: [[String: Any]] = []

    // Track last sent transcript
    private var lastSentTranscript: String = ""

    override init() {
        super.init()
    }

    override static func requiresMainQueueSetup() -> Bool {
        return true
    }

    override func supportedEvents() -> [String]! {
        return ["TranscriptUpdate", "StreamingTranscriptUpdate", "VADUpdate", "SpeakerUpdate", "STTError"]
    }

    // MARK: - Provider Detection

    @objc(getAvailableProviders:rejecter:)
    func getAvailableProviders(_ resolver: @escaping RCTPromiseResolveBlock,
                                rejecter: @escaping RCTPromiseRejectBlock) {
        var providers: [[String: Any]] = []

        // CPU is always available
        providers.append([
            "name": "cpu",
            "available": true,
            "description": "CPU inference (always available)"
        ])

        // CoreML available on iOS 11+
        let coreMLAvailable = true // iOS 11+ is baseline for RN
        providers.append([
            "name": "coreml",
            "available": coreMLAvailable,
            "description": "Apple CoreML (hardware accelerated)"
        ])

        // Check for Neural Engine (A11+ chip, iPhone X and later)
        let hasNeuralEngine = checkNeuralEngineAvailability()
        providers.append([
            "name": "neural_engine",
            "available": hasNeuralEngine,
            "description": hasNeuralEngine ?
                "Apple Neural Engine (A11+ chip)" :
                "Requires A11 chip or later (iPhone X+)"
        ])

        // Metal GPU
        let metalAvailable = MTLCreateSystemDefaultDevice() != nil
        providers.append([
            "name": "gpu",
            "available": metalAvailable,
            "description": metalAvailable ?
                "Metal GPU acceleration" :
                "Metal GPU not available"
        ])

        // Device info
        let result: [String: Any] = [
            "providers": providers,
            "device": getDeviceModel(),
            "manufacturer": "Apple",
            "iosVersion": UIDevice.current.systemVersion,
            "recommended": hasNeuralEngine ? "coreml" : (metalAvailable ? "gpu" : "cpu")
        ]

        resolver(result)
    }

    private func checkNeuralEngineAvailability() -> Bool {
        // Neural Engine is available on A11+ chips (iPhone X and later)
        var systemInfo = utsname()
        uname(&systemInfo)
        let machineMirror = Mirror(reflecting: systemInfo.machine)
        let identifier = machineMirror.children.reduce("") { identifier, element in
            guard let value = element.value as? Int8, value != 0 else { return identifier }
            return identifier + String(UnicodeScalar(UInt8(value)))
        }

        // iPhone X (iPhone10,x) and later have Neural Engine
        if identifier.hasPrefix("iPhone") {
            if let versionStr = identifier.replacingOccurrences(of: "iPhone", with: "").split(separator: ",").first,
               let version = Int(versionStr), version >= 10 {
                return true
            }
        }

        // iPad Pro 11" / 12.9" 3rd gen (iPad8,x) and later
        if identifier.hasPrefix("iPad") {
            if let versionStr = identifier.replacingOccurrences(of: "iPad", with: "").split(separator: ",").first,
               let version = Int(versionStr), version >= 8 {
                return true
            }
        }

        return false
    }

    private func getDeviceModel() -> String {
        var systemInfo = utsname()
        uname(&systemInfo)
        let machineMirror = Mirror(reflecting: systemInfo.machine)
        let identifier = machineMirror.children.reduce("") { identifier, element in
            guard let value = element.value as? Int8, value != 0 else { return identifier }
            return identifier + String(UnicodeScalar(UInt8(value)))
        }
        return identifier
    }

    // MARK: - Background Service (iOS uses background modes differently)

    @objc(startBackgroundService:rejecter:)
    func startBackgroundService(_ resolver: @escaping RCTPromiseResolveBlock,
                                 rejecter: @escaping RCTPromiseRejectBlock) {
        // iOS handles background audio via AVAudioSession category
        // The app should have "audio" background mode in Info.plist
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playAndRecord, mode: .default,
                                    options: [.defaultToSpeaker, .allowBluetooth, .mixWithOthers])
            try session.setActive(true)
            resolver(true)
        } catch {
            rejecter("BACKGROUND_ERROR", "Failed to configure background audio: \(error)", nil)
        }
    }

    @objc(stopBackgroundService:rejecter:)
    func stopBackgroundService(_ resolver: @escaping RCTPromiseResolveBlock,
                                rejecter: @escaping RCTPromiseRejectBlock) {
        // On iOS, we just need to deactivate the session when not in use
        resolver(true)
    }

    @objc(isBackgroundServiceRunning:rejecter:)
    func isBackgroundServiceRunning(_ resolver: @escaping RCTPromiseResolveBlock,
                                     rejecter: @escaping RCTPromiseRejectBlock) {
        // Check if audio session is active
        let session = AVAudioSession.sharedInstance()
        resolver(session.isOtherAudioPlaying || isRecording)
    }

    @objc(initializeSTT:channels:configJson:resolver:rejecter:)
    func initializeSTT(_ sampleRate: Double, channels: Int, configJson: String,
                       resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {

        guard let data = configJson.data(using: .utf8),
              let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            rejecter("CONFIG_ERROR", "Invalid configuration JSON", nil)
            return
        }

        self.sampleRate = config["sampleRate"] as? Int ?? 16000
        self.modelType = config["modelType"] as? String ?? "streaming"

        // Parse structured VAD config
        if let vadConfig = config["vadConfig"] as? [String: Any] {
            self.vadThreshold = Float(vadConfig["threshold"] as? Double ?? 0.5)
            self.vadMinSpeechMs = vadConfig["minSpeechDurationMs"] as? Int ?? 300
            self.vadMinSilenceMs = vadConfig["minSilenceDurationMs"] as? Int ?? 500
            self.vadMaxSpeechMs = vadConfig["maxSpeechDurationMs"] as? Int ?? 30000
            self.vadSpeechPaddingMs = vadConfig["speechPaddingMs"] as? Int ?? 100
            self.vadMode = vadConfig["mode"] as? String ?? "normal"
        }

        // Adjust threshold based on mode
        let effectiveThreshold: Float
        switch vadMode {
        case "aggressive":
            effectiveThreshold = min(vadThreshold + 0.15, 0.9)
        case "sensitive":
            effectiveThreshold = max(vadThreshold - 0.15, 0.1)
        default:
            effectiveThreshold = vadThreshold
        }
        self.vadThreshold = effectiveThreshold

        // Initialize streaming state manager for streaming mode
        if modelType == "streaming" {
            streamingStateManager = StreamingStateManager()
        }

        guard let encoderPath = config["encoderPath"] as? String,
              let decoderPath = config["decoderPath"] as? String,
              let tokensPath = config["tokensPath"] as? String else {
            rejecter("CONFIG_ERROR", "Missing required model paths (encoderPath, decoderPath, tokensPath)", nil)
            return
        }

        let joinerPath = config["joinerPath"] as? String ?? ""
        let modelArchitecture = config["modelArchitecture"] as? String ?? "transducer"
        let whisperLanguage = config["whisperLanguage"] as? String ?? "" // Empty = auto-detect
        let whisperTask = config["whisperTask"] as? String ?? "transcribe"

        // Initialize TEN-VAD
        vadHopSize = 160  // 10ms at 16kHz
        vadFrameBuffer = []
        tenVad = TenVadWrapper(hopSize: vadHopSize, threshold: vadThreshold)

        guard tenVad?.isInitialized == true else {
            rejecter("VAD_ERROR", "Failed to initialize TEN-VAD", nil)
            return
        }

        // Validate model files exist
        let fm = FileManager.default
        NSLog("[STTManager] Model files check (architecture: \(modelArchitecture)):")
        NSLog("[STTManager]   encoder: \(encoderPath) (\(fm.fileExists(atPath: encoderPath)))")
        NSLog("[STTManager]   decoder: \(decoderPath) (\(fm.fileExists(atPath: decoderPath)))")
        if modelArchitecture == "transducer" {
            NSLog("[STTManager]   joiner: \(joinerPath) (\(fm.fileExists(atPath: joinerPath)))")
        }
        NSLog("[STTManager]   tokens: \(tokensPath) (\(fm.fileExists(atPath: tokensPath)))")

        // Validate required files based on architecture
        var filesExist = fm.fileExists(atPath: encoderPath) && fm.fileExists(atPath: decoderPath) && fm.fileExists(atPath: tokensPath)
        if modelArchitecture == "transducer" {
            filesExist = filesExist && fm.fileExists(atPath: joinerPath)
        }

        guard filesExist else {
            rejecter("CONFIG_ERROR", "One or more model files not found", nil)
            return
        }

        let provider = config["provider"] as? String ?? "cpu"
        self.normalizeCase = config["normalizeCase"] as? String ?? "none"
        NSLog("[STTManager] normalizeCase: \(self.normalizeCase)")

        if modelType == "offline" {
            // Initialize offline recognizer
            var offlineConfig = SherpaOnnxOfflineRecognizerConfig()

            if modelArchitecture == "whisper" {
                // Whisper architecture: encoder-decoder only
                NSLog("[STTManager] Initializing Whisper offline recognizer (language='\(whisperLanguage)', task='\(whisperTask)')...")
                encoderPath.withCString { encoder in
                    decoderPath.withCString { decoder in
                        tokensPath.withCString { tokens in
                            whisperLanguage.withCString { language in
                                whisperTask.withCString { task in
                                    offlineConfig.model_config.whisper.encoder = encoder
                                    offlineConfig.model_config.whisper.decoder = decoder
                                    offlineConfig.model_config.whisper.language = language
                                    offlineConfig.model_config.whisper.task = task
                                    offlineConfig.model_config.whisper.tail_paddings = 1000
                                    offlineConfig.model_config.tokens = tokens
                                    offlineConfig.model_config.num_threads = 4
                                    offlineConfig.model_config.debug = 0

                                    "greedy_search".withCString { method in
                                        offlineConfig.decoding_method = method
                                        offlineRecognizer = SherpaOnnxCreateOfflineRecognizer(&offlineConfig)
                                    }
                                }
                            }
                        }
                    }
                }
            } else {
                // Transducer architecture: encoder-decoder-joiner
                NSLog("[STTManager] Initializing Transducer offline recognizer...")
                encoderPath.withCString { encoder in
                    decoderPath.withCString { decoder in
                        joinerPath.withCString { joiner in
                            tokensPath.withCString { tokens in
                                offlineConfig.model_config.transducer.encoder = encoder
                                offlineConfig.model_config.transducer.decoder = decoder
                                offlineConfig.model_config.transducer.joiner = joiner
                                offlineConfig.model_config.tokens = tokens
                                offlineConfig.model_config.num_threads = 4
                                offlineConfig.model_config.debug = 0

                                "greedy_search".withCString { method in
                                    offlineConfig.decoding_method = method
                                    offlineRecognizer = SherpaOnnxCreateOfflineRecognizer(&offlineConfig)
                                }
                            }
                        }
                    }
                }
            }

            guard offlineRecognizer != nil else {
                rejecter("INIT_ERROR", "Failed to create offline recognizer", nil)
                return
            }
        } else {
            // Streaming mode - only transducer supported
            if modelArchitecture == "whisper" {
                rejecter("CONFIG_ERROR", "Whisper models only support offline mode", nil)
                return
            }
            // Initialize online recognizer
            var recognizerConfig = SherpaOnnxOnlineRecognizerConfig()

            encoderPath.withCString { encoder in
                decoderPath.withCString { decoder in
                    joinerPath.withCString { joiner in
                        tokensPath.withCString { tokens in
                            recognizerConfig.model_config.transducer.encoder = encoder
                            recognizerConfig.model_config.transducer.decoder = decoder
                            recognizerConfig.model_config.transducer.joiner = joiner
                            recognizerConfig.model_config.tokens = tokens
                            recognizerConfig.model_config.num_threads = 4
                            recognizerConfig.model_config.debug = 0

                            recognizerConfig.enable_endpoint = 1
                            recognizerConfig.rule1_min_trailing_silence = STTConstants.defaultRule1MinTrailingSilence
                            recognizerConfig.rule2_min_trailing_silence = STTConstants.defaultRule2MinTrailingSilence
                            recognizerConfig.rule3_min_utterance_length = STTConstants.defaultRule3MinUtteranceLength

                            onlineRecognizer = SherpaOnnxCreateOnlineRecognizer(&recognizerConfig)
                        }
                    }
                }
            }

            guard onlineRecognizer != nil else {
                rejecter("INIT_ERROR", "Failed to create online recognizer", nil)
                return
            }
        }

        // Initialize denoiser if configured
        if let denoiserModelPath = config["denoiserModelPath"] as? String,
           !denoiserModelPath.isEmpty,
           fm.fileExists(atPath: denoiserModelPath) {
            var denoiserConfig = SherpaOnnxOfflineSpeechDenoiserConfig()

            denoiserModelPath.withCString { model in
                denoiserConfig.model_config.gtcrn.model = model
                denoiserConfig.model_config.num_threads = 2
                denoiserConfig.model_config.debug = 0

                denoiser = SherpaOnnxCreateOfflineSpeechDenoiser(&denoiserConfig)
            }

            if denoiser != nil {
                denoiserEnabled = true
                NSLog("STTManager: Denoiser initialized")
            }
        }

        // Initialize speaker diarization if configured
        if let diarizationModelPath = config["diarizationModelPath"] as? String,
           !diarizationModelPath.isEmpty {
            diarizationEnabled = true

            // Parse diarization config
            if let diarizationDict = config["diarization"] as? [String: Any] {
                diarizationConfig.speakerThreshold = Float(diarizationDict["speakerThreshold"] as? Double ?? 0.55)
                diarizationConfig.embeddingThreshold = Float(diarizationDict["embeddingThreshold"] as? Double ?? 0.75)
                diarizationConfig.minSpeechDurationMs = diarizationDict["minSpeechDurationMs"] as? Int ?? 800
                diarizationConfig.minEmbeddingUpdateMs = diarizationDict["minEmbeddingUpdateMs"] as? Int ?? 500
                diarizationConfig.maxSpeakers = diarizationDict["maxSpeakers"] as? Int ?? 10
                diarizationConfig.embeddingAverageWeight = Float(diarizationDict["embeddingAverageWeight"] as? Double ?? 0.2)
                diarizationConfig.onboardingSegments = diarizationDict["onboardingSegments"] as? Int ?? 3
                diarizationConfig.minSpeechForNewSpeaker = diarizationDict["minSpeechForNewSpeaker"] as? Int ?? 3000
            }

            NSLog("STTManager: Diarization config - speakerThreshold=\(diarizationConfig.speakerThreshold), embeddingThreshold=\(diarizationConfig.embeddingThreshold), maxSpeakers=\(diarizationConfig.maxSpeakers)")

            var extractorConfig = SherpaOnnxSpeakerEmbeddingExtractorConfig()
            diarizationModelPath.withCString { model in
                extractorConfig.model = model
                extractorConfig.num_threads = 1
                extractorConfig.debug = 0

                speakerExtractor = SherpaOnnxCreateSpeakerEmbeddingExtractor(&extractorConfig)
            }
        }

        resolver(nil)
    }

    @objc(startRecording:rejecter:)
    func startRecording(_ resolver: @escaping RCTPromiseResolveBlock,
                        rejecter: @escaping RCTPromiseRejectBlock) {

        guard !isRecording else {
            rejecter("ALREADY_RECORDING", "Recording is already in progress", nil)
            return
        }

        // Configure audio session
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setCategory(.playAndRecord, mode: .default,
                                    options: [.defaultToSpeaker, .allowBluetooth])
            try session.setActive(true)
        } catch {
            rejecter("AUDIO_SESSION_ERROR", "Failed to configure audio session: \(error)", nil)
            return
        }

        audioEngine = AVAudioEngine()
        guard let audioEngine = audioEngine else {
            rejecter("ENGINE_ERROR", "Failed to create audio engine", nil)
            return
        }

        let inputNode = audioEngine.inputNode
        let format = inputNode.outputFormat(forBus: 0)

        // Create recognizer stream for streaming mode
        if modelType == "streaming", let rec = onlineRecognizer {
            recognizerStream = SherpaOnnxCreateOnlineStream(rec)
        }

        recognitionResults.removeAll()
        speechBuffer.removeAll()
        offlineAudioBuffer.removeAll()
        vadFrameBuffer.removeAll()
        speakerProfiles.removeAll()
        currentSpeakerId = -1
        nextSpeakerId = 1
        lastConfidence = 0
        lastEmbeddingQuality = 0
        onboardingRemaining = 0
        candidateEmbeddings.removeAll()
        candidateSpeechMs = 0
        lastMatchedSpeakerId = -1
        vadState = "silence"
        isRecording = true
        isInSpeech = false
        lastSentTranscript = ""
        lastProcessingTimeMs = 0
        continuousSpeechMs = 0
        lastFrameTime = 0
        streamingStateManager?.reset()

        inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, time in
            guard let self = self, self.isRecording else { return }

            // Convert to int16 array for TEN-VAD
            guard let channelData = buffer.floatChannelData else { return }
            let frameLength = Int(buffer.frameLength)
            var floatSamples = Array(UnsafeBufferPointer(start: channelData[0], count: frameLength))

            // Resample if necessary
            if Int(format.sampleRate) != self.sampleRate {
                floatSamples = self.resample(floatSamples, from: Int(format.sampleRate), to: self.sampleRate)
            }

            // Capture audio if enabled (before any processing for raw audio)
            if self.audioCaptureEnabled {
                self.writeAudioCaptureSamples(floatSamples)
            }

            // Apply denoising if enabled (for streaming mode)
            if self.denoiserEnabled, let denoiser = self.denoiser, self.modelType == "streaming" {
                floatSamples = self.applyDenoiser(denoiser, samples: floatSamples)
            }

            // Convert to int16 for VAD
            let int16Samples = floatSamples.map { Int16(max(-32768, min(32767, $0 * 32768))) }

            // Process through TEN-VAD
            self.processVAD(int16Samples, floatSamples: floatSamples)

            // Streaming mode: process through STT in real-time
            if self.modelType == "streaming" {
                if self.vadState == "speech" || self.vadState == "speech_start" {
                    if let stream = self.recognizerStream, let rec = self.onlineRecognizer {
                        let startTime = DispatchTime.now()

                        floatSamples.withUnsafeBufferPointer { ptr in
                            SherpaOnnxOnlineStreamAcceptWaveform(stream, Int32(self.sampleRate), ptr.baseAddress, Int32(floatSamples.count))
                        }

                        while SherpaOnnxIsOnlineStreamReady(rec, stream) == 1 {
                            SherpaOnnxDecodeOnlineStream(rec, stream)
                        }

                        let processingTime = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
                        let processingTimeMs = Int64(processingTime / 1_000_000)
                        self.lastProcessingTimeMs = processingTimeMs

                        let result = SherpaOnnxGetOnlineStreamResult(rec, stream)
                        if let resultPtr = result, let text = String(cString: resultPtr.pointee.text, encoding: .utf8),
                           !text.isEmpty, text != self.lastSentTranscript {
                            self.lastSentTranscript = text
                            self.sendTranscriptUpdate(text: text, isFinal: false, processingTimeMs: processingTimeMs, confidence: 0)
                        }
                        SherpaOnnxDestroyOnlineRecognizerResult(result)
                    }
                }

                // Handle endpoint detection
                if let rec = self.onlineRecognizer, let stream = self.recognizerStream {
                    if SherpaOnnxOnlineStreamIsEndpoint(rec, stream) == 1 {
                        let result = SherpaOnnxGetOnlineStreamResult(rec, stream)
                        if let resultPtr = result, let text = String(cString: resultPtr.pointee.text, encoding: .utf8),
                           !text.isEmpty {
                            self.sendTranscriptUpdate(text: text, isFinal: true, processingTimeMs: self.lastProcessingTimeMs, confidence: 0)
                            self.addResult(text: text, isFinal: true, processingTimeMs: self.lastProcessingTimeMs, confidence: 0)
                        }
                        SherpaOnnxDestroyOnlineRecognizerResult(result)
                        SherpaOnnxOnlineStreamReset(rec, stream)
                        self.lastSentTranscript = ""
                        self.streamingStateManager?.reset()
                    }
                }
            }
        }

        do {
            try audioEngine.start()
            resolver(nil)
        } catch {
            isRecording = false
            rejecter("START_ERROR", "Failed to start audio engine: \(error)", nil)
        }
    }

    @objc(stopRecording:rejecter:)
    func stopRecording(_ resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {

        guard isRecording else {
            rejecter("NOT_RECORDING", "No recording in progress", nil)
            return
        }

        isRecording = false
        audioEngine?.stop()
        audioEngine?.inputNode.removeTap(onBus: 0)
        audioEngine = nil

        if let stream = recognizerStream {
            SherpaOnnxDestroyOnlineStream(stream)
            recognizerStream = nil
        }

        resolver(recognitionResults)
    }

    @objc(recognizeFile:resolver:rejecter:)
    func recognizeFile(_ filePath: String,
                       resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {

        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else { return }

            guard var samples = self.readWavFile(path: filePath) else {
                DispatchQueue.main.async {
                    rejecter("READ_ERROR", "Failed to read audio file", nil)
                }
                return
            }

            // Apply denoising if enabled
            if self.denoiserEnabled, let denoiser = self.denoiser {
                samples = self.applyDenoiser(denoiser, samples: samples)
            }

            let resultText: String
            let startTime = DispatchTime.now()

            if self.modelType == "offline", let rec = self.offlineRecognizer {
                // Use offline recognizer
                let stream = SherpaOnnxCreateOfflineStream(rec)

                samples.withUnsafeBufferPointer { ptr in
                    SherpaOnnxAcceptWaveformOffline(stream, Int32(self.sampleRate), ptr.baseAddress, Int32(samples.count))
                }

                SherpaOnnxDecodeOfflineStream(rec, stream)

                let result = SherpaOnnxGetOfflineStreamResult(stream)
                resultText = result.flatMap { String(cString: $0.pointee.text, encoding: .utf8) } ?? ""

                SherpaOnnxDestroyOfflineRecognizerResult(result)
                SherpaOnnxDestroyOfflineStream(stream)
            } else if let rec = self.onlineRecognizer {
                // Use online recognizer
                let stream = SherpaOnnxCreateOnlineStream(rec)

                samples.withUnsafeBufferPointer { ptr in
                    SherpaOnnxOnlineStreamAcceptWaveform(stream, Int32(self.sampleRate), ptr.baseAddress, Int32(samples.count))
                }

                SherpaOnnxOnlineStreamInputFinished(stream)

                while SherpaOnnxIsOnlineStreamReady(rec, stream) == 1 {
                    SherpaOnnxDecodeOnlineStream(rec, stream)
                }

                let result = SherpaOnnxGetOnlineStreamResult(rec, stream)
                resultText = result.flatMap { String(cString: $0.pointee.text, encoding: .utf8) } ?? ""

                SherpaOnnxDestroyOnlineRecognizerResult(result)
                SherpaOnnxDestroyOnlineStream(stream)
            } else {
                DispatchQueue.main.async {
                    rejecter("NOT_INITIALIZED", "Recognizer not initialized", nil)
                }
                return
            }

            let processingTime = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
            let processingTimeMs = Int64(processingTime / 1_000_000)
            let audioDurationMs = Int64(Double(samples.count) / Double(self.sampleRate) * 1000)
            let rtfx = processingTimeMs > 0 ? Float(audioDurationMs) / Float(processingTimeMs) : 0

            let resultDict: [String: Any] = [
                "text": resultText,
                "isFinal": true,
                "startTime": 0.0,
                "endTime": Double(samples.count) / Double(self.sampleRate),
                "processingTime": Double(processingTimeMs) / 1000.0,
                "audioDuration": Double(audioDurationMs) / 1000.0,
                "rtfx": rtfx,
                "confidence": 0.0
            ]

            DispatchQueue.main.async {
                resolver([resultDict])
            }
        }
    }

    // MARK: - Denoiser Methods

    @objc(setDenoiserEnabled:resolver:rejecter:)
    func setDenoiserEnabled(_ enabled: Bool,
                            resolver: @escaping RCTPromiseResolveBlock,
                            rejecter: @escaping RCTPromiseRejectBlock) {
        guard denoiser != nil else {
            rejecter("NO_DENOISER", "Denoiser not initialized", nil)
            return
        }
        denoiserEnabled = enabled
        NSLog("STTManager: Denoiser \(enabled ? "enabled" : "disabled")")
        resolver(enabled)
    }

    @objc(isDenoiserEnabled:rejecter:)
    func isDenoiserEnabled(_ resolver: @escaping RCTPromiseResolveBlock,
                            rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(denoiserEnabled)
    }

    private func applyDenoiser(_ denoiser: OpaquePointer, samples: [Float]) -> [Float] {
        let result = samples.withUnsafeBufferPointer { ptr -> OpaquePointer? in
            return SherpaOnnxOfflineSpeechDenoiserRun(denoiser, ptr.baseAddress, Int32(samples.count), Int32(sampleRate))
        }

        guard let denoisedAudio = result else { return samples }

        let numSamples = Int(SherpaOnnxOfflineSpeechDenoiserResultNumSamples(denoisedAudio))
        guard let samplesPtr = SherpaOnnxOfflineSpeechDenoiserResultSamples(denoisedAudio) else {
            SherpaOnnxDestroyOfflineSpeechDenoiserResult(denoisedAudio)
            return samples
        }

        let denoisedSamples = Array(UnsafeBufferPointer(start: samplesPtr, count: numSamples))
        SherpaOnnxDestroyOfflineSpeechDenoiserResult(denoisedAudio)

        return denoisedSamples
    }

    // MARK: - Punctuation Methods

    @objc(initializePunctuation:resolver:rejecter:)
    func initializePunctuation(_ configJson: String,
                               resolver: @escaping RCTPromiseResolveBlock,
                               rejecter: @escaping RCTPromiseRejectBlock) {
        guard let data = configJson.data(using: .utf8),
              let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let modelPath = config["modelPath"] as? String else {
            rejecter("CONFIG_ERROR", "Invalid punctuation configuration JSON", nil)
            return
        }

        let numThreads = config["numThreads"] as? Int ?? 2
        let provider = config["provider"] as? String ?? "cpu"

        // Validate model file exists
        let fm = FileManager.default
        guard fm.fileExists(atPath: modelPath) else {
            rejecter("CONFIG_ERROR", "Punctuation model file not found: \(modelPath)", nil)
            return
        }

        var modelConfig = SherpaOnnxOfflinePunctuationModelConfig(
            ct_transformer: toCPointer(modelPath),
            num_threads: Int32(numThreads),
            debug: 0,
            provider: toCPointer(provider)
        )

        var punctConfig = SherpaOnnxOfflinePunctuationConfig(model: modelConfig)

        punctuation = SherpaOnnxCreateOfflinePunctuation(&punctConfig)

        if punctuation != nil {
            punctuationEnabled = true
            NSLog("STTManager: Punctuation initialized successfully")
            resolver(true)
        } else {
            rejecter("PUNCTUATION_INIT_ERROR", "Failed to create punctuation model", nil)
        }
    }

    @objc(addPunctuation:resolver:rejecter:)
    func addPunctuation(_ text: String,
                        resolver: @escaping RCTPromiseResolveBlock,
                        rejecter: @escaping RCTPromiseRejectBlock) {
        guard let punct = punctuation else {
            rejecter("PUNCTUATION_ERROR", "Punctuation not initialized", nil)
            return
        }

        if text.isEmpty {
            resolver(text)
            return
        }

        let cText = SherpaOfflinePunctuationAddPunct(punct, toCPointer(text))
        if let cText = cText {
            let result = String(cString: cText)
            SherpaOfflinePunctuationFreeText(cText)
            resolver(result)
        } else {
            resolver(text)
        }
    }

    @objc(isPunctuationInitialized:rejecter:)
    func isPunctuationInitialized(_ resolver: @escaping RCTPromiseResolveBlock,
                                   rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(punctuation != nil)
    }

    @objc(setPunctuationEnabled:resolver:rejecter:)
    func setPunctuationEnabled(_ enabled: Bool,
                               resolver: @escaping RCTPromiseResolveBlock,
                               rejecter: @escaping RCTPromiseRejectBlock) {
        guard punctuation != nil else {
            rejecter("NO_PUNCTUATION", "Punctuation not initialized", nil)
            return
        }
        punctuationEnabled = enabled
        NSLog("STTManager: Punctuation \(enabled ? "enabled" : "disabled")")
        resolver(enabled)
    }

    @objc(isPunctuationEnabled:rejecter:)
    func isPunctuationEnabled(_ resolver: @escaping RCTPromiseResolveBlock,
                               rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(punctuationEnabled)
    }

    /// Helper to convert String to C pointer
    private func toCPointer(_ s: String) -> UnsafePointer<Int8>! {
        let cs = (s as NSString).utf8String
        return UnsafePointer<Int8>(cs)
    }

    // MARK: - Model Type

    @objc(getModelType:rejecter:)
    func getModelType(_ resolver: @escaping RCTPromiseResolveBlock,
                      rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(modelType)
    }

    @objc(isRecording:rejecter:)
    func isRecordingMethod(_ resolver: @escaping RCTPromiseResolveBlock,
                           rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(isRecording)
    }

    @objc(getSpeakerCount:rejecter:)
    func getSpeakerCount(_ resolver: @escaping RCTPromiseResolveBlock,
                         rejecter: @escaping RCTPromiseRejectBlock) {
        speakerProfilesLock.lock()
        let count = speakerProfiles.count
        speakerProfilesLock.unlock()
        resolver(count)
    }

    @objc(resetSpeakers:rejecter:)
    func resetSpeakers(_ resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {
        speakerProfilesLock.lock()
        speakerProfiles.removeAll()
        currentSpeakerId = -1
        nextSpeakerId = 1
        lastConfidence = 0
        lastEmbeddingQuality = 0
        onboardingRemaining = 0
        candidateEmbeddings.removeAll()
        candidateSpeechMs = 0
        lastMatchedSpeakerId = -1
        speakerProfilesLock.unlock()
        resolver(nil)
    }

    // MARK: - Speaker Management APIs

    @objc(mergeSpeakers:targetId:resolver:rejecter:)
    func mergeSpeakers(_ sourceId: Int, targetId: Int,
                       resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {
        speakerProfilesLock.lock()
        defer { speakerProfilesLock.unlock() }

        guard let sourceIndex = speakerProfiles.firstIndex(where: { $0.speakerId == sourceId }),
              let targetIndex = speakerProfiles.firstIndex(where: { $0.speakerId == targetId }) else {
            NSLog("STTManager: mergeSpeakers failed: source=\(sourceId), target=\(targetId)")
            resolver(false)
            return
        }

        let source = speakerProfiles[sourceIndex]
        var target = speakerProfiles[targetIndex]

        // Average embeddings (weighted by segment count)
        let totalSegments = source.numSegments + target.numSegments
        let sourceWeight = Float(source.numSegments) / Float(totalSegments)
        let targetWeight = Float(target.numSegments) / Float(totalSegments)

        var mergedEmbedding = [Float](repeating: 0, count: target.embedding.count)
        for i in 0..<target.embedding.count {
            mergedEmbedding[i] = targetWeight * target.embedding[i] + sourceWeight * source.embedding[i]
        }

        // Normalize result
        target.embedding = normalizeEmbedding(mergedEmbedding)

        // Merge stats
        target.numSegments = totalSegments
        target.totalSpeechDurationMs += source.totalSpeechDurationMs
        if source.lastSeenTimestamp > target.lastSeenTimestamp {
            target.lastSeenTimestamp = source.lastSeenTimestamp
        }

        // Update target in array
        speakerProfiles[targetIndex] = target

        // Remove source
        speakerProfiles.remove(at: sourceIndex)

        // Update current speaker if needed
        if currentSpeakerId == sourceId {
            currentSpeakerId = targetId
        }
        if lastMatchedSpeakerId == sourceId {
            lastMatchedSpeakerId = targetId
        }

        NSLog("STTManager: Merged Speaker \(sourceId) into Speaker \(targetId) (total: \(speakerProfiles.count))")
        resolver(true)
    }

    @objc(removeSpeaker:resolver:rejecter:)
    func removeSpeaker(_ speakerId: Int,
                       resolver: @escaping RCTPromiseResolveBlock,
                       rejecter: @escaping RCTPromiseRejectBlock) {
        speakerProfilesLock.lock()
        defer { speakerProfilesLock.unlock() }

        guard let index = speakerProfiles.firstIndex(where: { $0.speakerId == speakerId }) else {
            NSLog("STTManager: removeSpeaker failed: speaker \(speakerId) not found")
            resolver(false)
            return
        }

        speakerProfiles.remove(at: index)

        if currentSpeakerId == speakerId {
            currentSpeakerId = speakerProfiles.first?.speakerId ?? -1
        }
        if lastMatchedSpeakerId == speakerId {
            lastMatchedSpeakerId = speakerProfiles.first?.speakerId ?? -1
        }

        NSLog("STTManager: Removed Speaker \(speakerId) (total: \(speakerProfiles.count))")
        resolver(true)
    }

    @objc(getSpeakerProfiles:rejecter:)
    func getSpeakerProfiles(_ resolver: @escaping RCTPromiseResolveBlock,
                            rejecter: @escaping RCTPromiseRejectBlock) {
        speakerProfilesLock.lock()
        let profiles = speakerProfiles.map { profile -> [String: Any] in
            return [
                "speakerId": profile.speakerId,
                "status": profile.status,
                "segmentCount": profile.numSegments,
                "totalSpeechMs": profile.totalSpeechDurationMs,
                "lastSeenTimestamp": profile.lastSeenTimestamp
            ]
        }
        speakerProfilesLock.unlock()
        resolver(profiles)
    }

    // MARK: - Audio Capture (Debug)

    @objc(startAudioCapture:resolver:rejecter:)
    func startAudioCapture(_ filePath: String,
                           resolver: @escaping RCTPromiseResolveBlock,
                           rejecter: @escaping RCTPromiseRejectBlock) {
        if audioCaptureEnabled {
            rejecter("CAPTURE_ERROR", "Audio capture is already in progress", nil)
            return
        }

        do {
            // Create file and write WAV header placeholder
            let fileManager = FileManager.default
            let dirPath = (filePath as NSString).deletingLastPathComponent
            try fileManager.createDirectory(atPath: dirPath, withIntermediateDirectories: true)

            fileManager.createFile(atPath: filePath, contents: nil)
            let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath))

            // Write placeholder header (44 bytes)
            let header = createWavHeader(dataSize: 0)
            fileHandle.write(header)

            audioCaptureFileHandle = fileHandle
            audioCaptureFilePath = filePath
            audioCaptureSamplesWritten = 0
            audioCaptureEnabled = true

            NSLog("STTManager: Audio capture started: \(filePath)")
            resolver(true)
        } catch {
            rejecter("CAPTURE_ERROR", "Failed to start audio capture: \(error.localizedDescription)", error)
        }
    }

    @objc(stopAudioCapture:rejecter:)
    func stopAudioCapture(_ resolver: @escaping RCTPromiseResolveBlock,
                          rejecter: @escaping RCTPromiseRejectBlock) {
        if !audioCaptureEnabled {
            resolver(false)
            return
        }

        audioCaptureEnabled = false

        do {
            // Update header with actual data size
            if let fileHandle = audioCaptureFileHandle, let filePath = audioCaptureFilePath {
                let dataSize = Int(audioCaptureSamplesWritten * 2) // 16-bit = 2 bytes per sample

                // Seek to beginning and write updated header
                fileHandle.seek(toFileOffset: 0)
                let header = createWavHeader(dataSize: dataSize)
                fileHandle.write(header)

                fileHandle.closeFile()
                NSLog("STTManager: Audio capture stopped: \(audioCaptureSamplesWritten) samples written")
            }

            audioCaptureFileHandle = nil
            audioCaptureFilePath = nil
            audioCaptureSamplesWritten = 0

            resolver(true)
        } catch {
            rejecter("CAPTURE_ERROR", "Failed to stop audio capture: \(error.localizedDescription)", error)
        }
    }

    @objc(isAudioCaptureActive:rejecter:)
    func isAudioCaptureActive(_ resolver: @escaping RCTPromiseResolveBlock,
                              rejecter: @escaping RCTPromiseRejectBlock) {
        resolver(audioCaptureEnabled)
    }

    private func writeAudioCaptureSamples(_ samples: [Float]) {
        guard audioCaptureEnabled, let fileHandle = audioCaptureFileHandle else { return }

        var data = Data(capacity: samples.count * 2)
        for sample in samples {
            // Convert float [-1.0, 1.0] to 16-bit PCM
            let pcmValue = Int16(max(-32768, min(32767, sample * 32767)))
            var littleEndian = pcmValue.littleEndian
            data.append(Data(bytes: &littleEndian, count: 2))
        }

        fileHandle.seekToEndOfFile()
        fileHandle.write(data)
        audioCaptureSamplesWritten += Int64(samples.count)
    }

    private func createWavHeader(dataSize: Int) -> Data {
        var header = Data(capacity: 44)

        let byteRate = sampleRate * 1 * 16 / 8 // sampleRate * channels * bitsPerSample / 8
        let blockAlign = 1 * 16 / 8 // channels * bitsPerSample / 8

        // RIFF header
        header.append("RIFF".data(using: .ascii)!)
        header.append(UInt32(36 + dataSize).littleEndianData)
        header.append("WAVE".data(using: .ascii)!)

        // fmt subchunk
        header.append("fmt ".data(using: .ascii)!)
        header.append(UInt32(16).littleEndianData) // Subchunk1Size
        header.append(UInt16(1).littleEndianData) // AudioFormat (PCM)
        header.append(UInt16(1).littleEndianData) // NumChannels
        header.append(UInt32(sampleRate).littleEndianData) // SampleRate
        header.append(UInt32(byteRate).littleEndianData) // ByteRate
        header.append(UInt16(blockAlign).littleEndianData) // BlockAlign
        header.append(UInt16(16).littleEndianData) // BitsPerSample

        // data subchunk
        header.append("data".data(using: .ascii)!)
        header.append(UInt32(dataSize).littleEndianData)

        return header
    }

    @objc func deinitialize() {
        isRecording = false

        audioEngine?.stop()
        audioEngine?.inputNode.removeTap(onBus: 0)
        audioEngine = nil

        if let stream = recognizerStream {
            SherpaOnnxDestroyOnlineStream(stream)
            recognizerStream = nil
        }

        if let rec = onlineRecognizer {
            SherpaOnnxDestroyOnlineRecognizer(rec)
            onlineRecognizer = nil
        }

        if let rec = offlineRecognizer {
            SherpaOnnxDestroyOfflineRecognizer(rec)
            offlineRecognizer = nil
        }

        if let den = denoiser {
            SherpaOnnxDestroyOfflineSpeechDenoiser(den)
            denoiser = nil
        }
        denoiserEnabled = false

        if let punct = punctuation {
            SherpaOnnxDestroyOfflinePunctuation(punct)
            punctuation = nil
        }
        punctuationEnabled = false

        tenVad?.destroy()
        tenVad = nil

        if let extractor = speakerExtractor {
            SherpaOnnxDestroySpeakerEmbeddingExtractor(extractor)
            speakerExtractor = nil
        }

        // Thread-safe speaker profile cleanup
        speakerProfilesLock.lock()
        speakerProfiles.removeAll()
        currentSpeakerId = -1
        nextSpeakerId = 1
        lastConfidence = 0
        lastEmbeddingQuality = 0
        onboardingRemaining = 0
        candidateEmbeddings.removeAll()
        candidateSpeechMs = 0
        lastMatchedSpeakerId = -1
        speakerProfilesLock.unlock()

        offlineAudioBuffer.removeAll()
        streamingStateManager?.reset()
        streamingStateManager = nil
    }

    // MARK: - Private Methods

    private func processVAD(_ samples: [Int16], floatSamples: [Float]) {
        guard let vad = tenVad else { return }

        // Accumulate samples for offline mode
        if modelType == "offline" && isInSpeech {
            offlineAudioBuffer.append(floatSamples)
        }

        vadFrameBuffer.append(contentsOf: samples)

        // Process complete frames
        while vadFrameBuffer.count >= vadHopSize {
            let frame = Array(vadFrameBuffer.prefix(vadHopSize))
            vadFrameBuffer.removeFirst(vadHopSize)

            if let result = vad.process(samples: frame) {
                processVADResult(result, frame: frame, currentFloatSamples: floatSamples)
            }
        }
    }

    private func processVADResult(_ result: (probability: Float, isSpeech: Bool), frame: [Int16], currentFloatSamples: [Float]) {
        let currentTime = Date().timeIntervalSince1970
        let previousState = vadState

        // Convert frame to float for speech buffer
        let floatFrame = frame.map { Float($0) / 32768.0 }

        // Track frame timing for continuous speech duration
        let frameDurationMs: Int64 = lastFrameTime > 0 ? Int64((currentTime - lastFrameTime) * 1000) : 10
        lastFrameTime = currentTime

        if result.isSpeech {
            switch vadState {
            case "silence":
                vadState = "speech_start"
                speechStartTime = currentTime
                speechBuffer.removeAll()
                speechBuffer.append(floatFrame)
                continuousSpeechMs = 0

                // Offline mode: start accumulating
                if modelType == "offline" {
                    offlineAudioBuffer.removeAll()
                    offlineAudioBuffer.append(currentFloatSamples)
                    isInSpeech = true
                }

            case "speech_start":
                speechBuffer.append(floatFrame)
                continuousSpeechMs += frameDurationMs
                let duration = (currentTime - speechStartTime) * 1000
                if duration >= Double(vadMinSpeechMs) {
                    vadState = "speech"
                }

            case "speech":
                speechBuffer.append(floatFrame)
                silenceStartTime = 0
                continuousSpeechMs += frameDurationMs

                // Check for forced segment break due to max duration
                if continuousSpeechMs >= vadMaxSpeechMs {
                    NSLog("STTManager: Forced segment break at \(continuousSpeechMs)ms")

                    // Send forced break event
                    sendVADUpdate(state: "speech_end", probability: result.probability,
                                  speechMs: Int(continuousSpeechMs), silenceMs: 0)

                    // Offline mode: stop accumulating
                    if modelType == "offline" {
                        isInSpeech = false
                    }

                    // Process diarization on dedicated queue
                    if diarizationEnabled {
                        // Capture audio data before dispatching
                        let audioSnapshot = modelType == "offline" ? offlineAudioBuffer : speechBuffer
                        diarizationQueue.async { [weak self] in
                            self?.processDiarizationWithAudio(audioSnapshot)
                        }
                    }

                    // Process offline STT
                    if modelType == "offline" {
                        processOfflineSTT()
                    }

                    // Reset for new segment but stay in speech state
                    speechBuffer.removeAll()
                    speechStartTime = currentTime
                    continuousSpeechMs = 0

                    // For offline mode, start new buffer
                    if modelType == "offline" {
                        offlineAudioBuffer.removeAll()
                        isInSpeech = true
                    }
                }

            default:
                break
            }
        } else {
            switch vadState {
            case "speech_start":
                vadState = "silence"
                speechBuffer.removeAll()
                continuousSpeechMs = 0

                // Offline mode: cancel
                if modelType == "offline" {
                    offlineAudioBuffer.removeAll()
                    isInSpeech = false
                }

            case "speech":
                if silenceStartTime == 0 {
                    silenceStartTime = currentTime
                }
                let silenceDuration = (currentTime - silenceStartTime) * 1000
                if silenceDuration >= Double(vadMinSilenceMs) {
                    vadState = "speech_end"

                    // Send speech_end event
                    sendVADUpdate(state: "speech_end", probability: result.probability,
                                  speechMs: Int((currentTime - speechStartTime) * 1000),
                                  silenceMs: Int(silenceDuration))

                    // Offline mode: stop accumulating
                    if modelType == "offline" {
                        isInSpeech = false
                    }

                    // Process diarization on dedicated queue
                    if diarizationEnabled {
                        // Capture audio data before dispatching
                        let audioSnapshot = modelType == "offline" ? offlineAudioBuffer : speechBuffer
                        diarizationQueue.async { [weak self] in
                            self?.processDiarizationWithAudio(audioSnapshot)
                        }
                    }

                    // Process offline STT
                    if modelType == "offline" {
                        processOfflineSTT()
                    }

                    vadState = "silence"
                    speechBuffer.removeAll()
                    continuousSpeechMs = 0
                }

            default:
                continuousSpeechMs = 0
            }
        }

        if vadState != previousState && vadState != "speech_end" {
            let speechMs = vadState == "speech" ? Int((currentTime - speechStartTime) * 1000) : 0
            let silenceMs = silenceStartTime > 0 ? Int((currentTime - silenceStartTime) * 1000) : 0
            sendVADUpdate(state: vadState, probability: result.probability, speechMs: speechMs, silenceMs: silenceMs)
        }
    }

    private func processOfflineSTT() {
        guard let rec = offlineRecognizer else { return }

        // Concatenate all audio chunks
        var allSamples = offlineAudioBuffer.flatMap { $0 }
        offlineAudioBuffer.removeAll()

        guard !allSamples.isEmpty else { return }

        // Apply denoising to full buffer if enabled
        if denoiserEnabled, let denoiser = denoiser {
            allSamples = applyDenoiser(denoiser, samples: allSamples)
        }

        NSLog("STTManager: Processing offline STT: \(allSamples.count) samples")

        let startTime = DispatchTime.now()

        let stream = SherpaOnnxCreateOfflineStream(rec)

        allSamples.withUnsafeBufferPointer { ptr in
            SherpaOnnxAcceptWaveformOffline(stream, Int32(sampleRate), ptr.baseAddress, Int32(allSamples.count))
        }

        SherpaOnnxDecodeOfflineStream(rec, stream)

        let processingTime = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
        let processingTimeMs = Int64(processingTime / 1_000_000)

        let result = SherpaOnnxGetOfflineStreamResult(stream)
        if let resultPtr = result, let text = String(cString: resultPtr.pointee.text, encoding: .utf8),
           !text.isEmpty {
            NSLog("STTManager: Offline result: \(text) (\(processingTimeMs)ms)")
            sendTranscriptUpdate(text: text, isFinal: true, processingTimeMs: processingTimeMs, confidence: 0)
            addResult(text: text, isFinal: true, processingTimeMs: processingTimeMs, confidence: 0)
        }

        SherpaOnnxDestroyOfflineRecognizerResult(result)
        SherpaOnnxDestroyOfflineStream(stream)
    }

    /// Validate embedding quality. Returns quality score 0-1, or -1 if invalid.
    /// Note: Embeddings may not be unit-normalized depending on the model.
    private func validateEmbedding(_ embedding: [Float]) -> Float {
        guard !embedding.isEmpty else {
            NSLog("STTManager: Empty embedding")
            return -1
        }

        // Calculate norm using Accelerate
        var norm: Float = 0
        vDSP_dotpr(embedding, 1, embedding, 1, &norm, vDSP_Length(embedding.count))
        norm = sqrt(norm)

        // Check for NaN or Infinite values
        var hasValidValues = false
        for value in embedding {
            if value.isNaN || value.isInfinite {
                NSLog("STTManager: Embedding contains NaN or Infinite values")
                return -1
            }
            if value != 0 { hasValidValues = true }
        }

        // Check for near-zero norm (silent/invalid audio)
        if norm < 0.001 || !hasValidValues {
            NSLog("STTManager: Embedding norm too small: \(norm)")
            return -1
        }

        // Quality score based on variance of embedding values
        // Higher variance = more distinctive = higher quality
        var mean: Float = 0
        vDSP_meanv(embedding, 1, &mean, vDSP_Length(embedding.count))

        var variance: Float = 0
        var temp = [Float](repeating: 0, count: embedding.count)
        vDSP_vsub([Float](repeating: mean, count: embedding.count), 1, embedding, 1, &temp, 1, vDSP_Length(embedding.count))
        vDSP_dotpr(temp, 1, temp, 1, &variance, vDSP_Length(embedding.count))
        variance /= Float(embedding.count)

        // Normalize quality to 0-1 range (typical variance is 0.01-0.1 for good embeddings)
        let quality = min(variance * 10, 1.0)

        NSLog("STTManager: Embedding norm=\(String(format: "%.2f", norm)), variance=\(String(format: "%.4f", variance)), quality=\(String(format: "%.3f", quality))")
        return quality
    }

    /// Evict the least recently used speaker to make room for a new one
    private func evictLRUSpeaker() {
        guard !speakerProfiles.isEmpty else { return }

        // Find speaker with oldest lastSeenTimestamp that is NOT the current speaker
        var lruIndex = -1
        var oldestTimestamp: TimeInterval = .greatestFiniteMagnitude

        for (index, profile) in speakerProfiles.enumerated() {
            if profile.speakerId != currentSpeakerId && profile.lastSeenTimestamp < oldestTimestamp {
                oldestTimestamp = profile.lastSeenTimestamp
                lruIndex = index
            }
        }

        if lruIndex >= 0 {
            let evicted = speakerProfiles.remove(at: lruIndex)
            let agoMs = Int((Date().timeIntervalSince1970 - oldestTimestamp) * 1000)
            NSLog("STTManager: Evicted speaker \(evicted.speakerId) (last seen: \(agoMs)ms ago)")
        }
    }

    /// Process diarization with pre-captured audio data.
    /// Thread-safe: called from diarizationQueue, uses speakerProfilesLock.
    /// Uses defer blocks to ensure proper resource cleanup.
    private func processDiarizationWithAudio(_ audioSource: [[Float]]) {
        guard diarizationEnabled, let extractor = speakerExtractor else { return }

        let allSamples = audioSource.flatMap { $0 }
        let minSamples = (diarizationConfig.minSpeechDurationMs * sampleRate) / 1000

        guard allSamples.count >= minSamples else { return }

        // Calculate speech duration for this buffer
        let speechDurationMs = Int64(allSamples.count * 1000 / sampleRate)

        // Create stream and ensure cleanup with defer
        guard let stream = SherpaOnnxSpeakerEmbeddingExtractorCreateStream(extractor) else { return }
        defer {
            SherpaOnnxDestroyOnlineStream(stream)
        }

        allSamples.withUnsafeBufferPointer { ptr in
            SherpaOnnxOnlineStreamAcceptWaveform(stream, Int32(sampleRate), ptr.baseAddress, Int32(allSamples.count))
        }
        SherpaOnnxOnlineStreamInputFinished(stream)

        guard SherpaOnnxSpeakerEmbeddingExtractorIsReady(extractor, stream) == 1 else {
            return
        }

        guard let embeddingPtr = SherpaOnnxSpeakerEmbeddingExtractorComputeEmbedding(extractor, stream) else {
            return
        }
        // Ensure embedding cleanup with defer
        defer {
            SherpaOnnxSpeakerEmbeddingExtractorDestroyEmbedding(embeddingPtr)
        }

        let dim = Int(SherpaOnnxSpeakerEmbeddingExtractorDim(extractor))
        let embedding = Array(UnsafeBufferPointer(start: embeddingPtr, count: dim))

        // Validate embedding
        let quality = validateEmbedding(embedding)
        lastEmbeddingQuality = quality >= 0 ? quality : 0

        if quality < 0 {
            NSLog("STTManager: Invalid embedding, skipping")
            return
        }

        // Normalize embedding for comparison
        let normalizedEmbedding = normalizeEmbedding(embedding)

        // Lock for thread-safe speaker profile access
        speakerProfilesLock.lock()
        defer { speakerProfilesLock.unlock() }

        // Case 1: No speakers exist yet - create Speaker 1 and start onboarding
        if speakerProfiles.isEmpty {
            let newId = nextSpeakerId
            nextSpeakerId += 1
            speakerProfiles.append(SpeakerProfile(
                speakerId: newId,
                embedding: normalizedEmbedding,
                numSegments: 1,
                status: "pending",
                totalSpeechDurationMs: speechDurationMs,
                lastSeenTimestamp: Date().timeIntervalSince1970
            ))
            currentSpeakerId = newId

            // Initialize onboarding counter (remaining = total - 1 because we just used one)
            onboardingRemaining = diarizationConfig.onboardingSegments - 1
            lastMatchedSpeakerId = newId
            lastConfidence = 1.0

            NSLog("STTManager: Created Speaker \(newId), onboarding started (\(onboardingRemaining) remaining)")
            sendSpeakerUpdate(speakerId: newId, status: "pending", justConfirmed: false)
            // Resources cleaned up by defer
            return
        }

        // Case 2: Onboarding mode - always assign to Speaker 1
        if onboardingRemaining > 0 {
            if let speaker1Index = speakerProfiles.indices.first {
                speakerProfiles[speaker1Index].numSegments += 1
                speakerProfiles[speaker1Index].totalSpeechDurationMs += speechDurationMs
                speakerProfiles[speaker1Index].lastSeenTimestamp = Date().timeIntervalSince1970

                // Update embedding with running average during onboarding
                let alpha = diarizationConfig.embeddingAverageWeight
                for i in 0..<speakerProfiles[speaker1Index].embedding.count {
                    speakerProfiles[speaker1Index].embedding[i] =
                        (1 - alpha) * speakerProfiles[speaker1Index].embedding[i] + alpha * normalizedEmbedding[i]
                }
                // Re-normalize after averaging
                speakerProfiles[speaker1Index].embedding = normalizeEmbedding(speakerProfiles[speaker1Index].embedding)

                onboardingRemaining -= 1
                lastMatchedSpeakerId = speakerProfiles[speaker1Index].speakerId
                currentSpeakerId = speakerProfiles[speaker1Index].speakerId
                lastConfidence = 1.0

                let justConfirmed = speakerProfiles[speaker1Index].numSegments == 2
                if justConfirmed {
                    speakerProfiles[speaker1Index].status = "confirmed"
                }

                NSLog("STTManager: Onboarding: assigned to Speaker \(speakerProfiles[speaker1Index].speakerId) (\(onboardingRemaining) remaining)")
                sendSpeakerUpdate(
                    speakerId: speakerProfiles[speaker1Index].speakerId,
                    status: speakerProfiles[speaker1Index].status,
                    justConfirmed: justConfirmed
                )
                // Resources cleaned up by defer
                return
            }
        }

        // Case 3: Normal matching logic
        var bestMatch = -1
        var bestSimilarity: Float = 0

        for (index, profile) in speakerProfiles.enumerated() {
            let similarity = cosineSimilarity(normalizedEmbedding, profile.embedding)
            NSLog("STTManager: vs Speaker \(profile.speakerId) similarity = \(String(format: "%.3f", similarity))")
            if similarity > bestSimilarity && similarity >= diarizationConfig.speakerThreshold {
                bestSimilarity = similarity
                bestMatch = index
            }
        }

        lastConfidence = bestSimilarity

        NSLog("STTManager: bestMatch=\(bestMatch), similarity=\(String(format: "%.3f", bestSimilarity)), speakerThreshold=\(diarizationConfig.speakerThreshold), embeddingThreshold=\(diarizationConfig.embeddingThreshold), speakers=\(speakerProfiles.count)")

        if bestMatch >= 0 {
            // Match found - update existing speaker
            speakerProfiles[bestMatch].numSegments += 1
            speakerProfiles[bestMatch].totalSpeechDurationMs += speechDurationMs
            speakerProfiles[bestMatch].lastSeenTimestamp = Date().timeIntervalSince1970
            currentSpeakerId = speakerProfiles[bestMatch].speakerId

            // Clear candidate buffer - we matched an existing speaker
            candidateEmbeddings.removeAll()
            candidateSpeechMs = 0
            lastMatchedSpeakerId = speakerProfiles[bestMatch].speakerId

            // Only update embedding if similarity exceeds stricter threshold
            // AND speech duration is sufficient
            let shouldUpdateEmbedding = bestSimilarity >= diarizationConfig.embeddingThreshold
                && speechDurationMs >= Int64(diarizationConfig.minEmbeddingUpdateMs)

            if shouldUpdateEmbedding {
                // Update embedding with weighted running average (using normalized embedding)
                let alpha = diarizationConfig.embeddingAverageWeight
                for i in 0..<speakerProfiles[bestMatch].embedding.count {
                    speakerProfiles[bestMatch].embedding[i] =
                        (1 - alpha) * speakerProfiles[bestMatch].embedding[i] + alpha * normalizedEmbedding[i]
                }
                // Re-normalize after averaging
                speakerProfiles[bestMatch].embedding = normalizeEmbedding(speakerProfiles[bestMatch].embedding)
                NSLog("STTManager: Updated embedding for speaker \(speakerProfiles[bestMatch].speakerId)")
            } else {
                NSLog("STTManager: Skipped embedding update (similarity=\(String(format: "%.3f", bestSimilarity)), threshold=\(diarizationConfig.embeddingThreshold), duration=\(speechDurationMs)ms)")
            }

            let justConfirmed = speakerProfiles[bestMatch].numSegments == 2
            if justConfirmed {
                speakerProfiles[bestMatch].status = "confirmed"
            }

            sendSpeakerUpdate(
                speakerId: speakerProfiles[bestMatch].speakerId,
                status: speakerProfiles[bestMatch].status,
                justConfirmed: justConfirmed
            )
        } else {
            // No match found - buffer candidate for new speaker
            candidateEmbeddings.append(normalizedEmbedding)
            candidateSpeechMs += speechDurationMs

            NSLog("STTManager: Buffering candidate: \(candidateSpeechMs)ms / \(diarizationConfig.minSpeechForNewSpeaker)ms required")

            if candidateSpeechMs >= Int64(diarizationConfig.minSpeechForNewSpeaker) {
                // Enough evidence accumulated - create new speaker from averaged embeddings
                let averagedEmbedding = averageEmbeddings(candidateEmbeddings)
                candidateEmbeddings.removeAll()
                candidateSpeechMs = 0

                // Check if we need to evict a speaker (LRU)
                if speakerProfiles.count >= diarizationConfig.maxSpeakers {
                    evictLRUSpeaker()
                }

                // Create new speaker with averaged embedding
                let newId = nextSpeakerId
                nextSpeakerId += 1
                speakerProfiles.append(SpeakerProfile(
                    speakerId: newId,
                    embedding: averagedEmbedding,
                    numSegments: 1,
                    status: "pending",
                    totalSpeechDurationMs: speechDurationMs,
                    lastSeenTimestamp: Date().timeIntervalSince1970
                ))
                currentSpeakerId = newId
                lastMatchedSpeakerId = newId

                NSLog("STTManager: Created Speaker \(newId) from buffered segments (total: \(speakerProfiles.count))")
                sendSpeakerUpdate(speakerId: newId, status: "pending", justConfirmed: false)
            } else {
                // Not enough evidence yet - return last matched speaker
                let fallbackSpeakerId = lastMatchedSpeakerId > 0 ? lastMatchedSpeakerId : (speakerProfiles.first?.speakerId ?? -1)
                currentSpeakerId = fallbackSpeakerId
                NSLog("STTManager: Returning fallback speaker \(fallbackSpeakerId) while buffering")
            }
        }
        // Resources cleaned up by defer (embeddingPtr and stream)
    }

    /// Average multiple embeddings into one
    private func averageEmbeddings(_ embeddings: [[Float]]) -> [Float] {
        guard !embeddings.isEmpty else { return [] }
        guard embeddings.count > 1 else { return embeddings[0] }

        let dim = embeddings[0].count
        var result = [Float](repeating: 0, count: dim)

        for embedding in embeddings {
            for i in 0..<dim {
                result[i] += embedding[i]
            }
        }

        let count = Float(embeddings.count)
        for i in 0..<dim {
            result[i] /= count
        }

        // Normalize the averaged embedding
        return normalizeEmbedding(result)
    }

    /// L2 normalize an embedding to unit length
    private func normalizeEmbedding(_ embedding: [Float]) -> [Float] {
        var norm: Float = 0
        vDSP_dotpr(embedding, 1, embedding, 1, &norm, vDSP_Length(embedding.count))
        norm = sqrt(norm)

        guard norm > 0.001 else { return embedding }

        var normalized = [Float](repeating: 0, count: embedding.count)
        var divisor = norm
        vDSP_vsdiv(embedding, 1, &divisor, &normalized, 1, vDSP_Length(embedding.count))
        return normalized
    }

    /// Calculate cosine similarity between two L2-normalized embeddings
    /// For normalized vectors, this is simply the dot product
    private func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
        guard a.count == b.count else { return 0 }

        var dot: Float = 0
        vDSP_dotpr(a, 1, b, 1, &dot, vDSP_Length(a.count))
        return dot
    }

    private func resample(_ samples: [Float], from srcRate: Int, to dstRate: Int) -> [Float] {
        let ratio = Float(dstRate) / Float(srcRate)
        let outCount = Int(Float(samples.count) * ratio)
        var output = [Float](repeating: 0, count: outCount)

        for i in 0..<outCount {
            let srcIdx = Float(i) / ratio
            let srcIdxInt = Int(srcIdx)
            let frac = srcIdx - Float(srcIdxInt)

            if srcIdxInt + 1 < samples.count {
                output[i] = samples[srcIdxInt] * (1 - frac) + samples[srcIdxInt + 1] * frac
            } else {
                output[i] = samples[min(srcIdxInt, samples.count - 1)]
            }
        }

        return output
    }

    private func readWavFile(path: String) -> [Float]? {
        guard let data = FileManager.default.contents(atPath: path) else { return nil }
        guard data.count > 44 else { return nil }

        // Skip 44-byte WAV header
        let pcmData = data.subdata(in: 44..<data.count)
        let sampleCount = pcmData.count / 2

        var samples = [Float](repeating: 0, count: sampleCount)
        pcmData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
            let int16Ptr = ptr.bindMemory(to: Int16.self)
            for i in 0..<sampleCount {
                samples[i] = Float(int16Ptr[i]) / 32768.0
            }
        }

        return samples
    }

    private func normalizeText(_ text: String) -> String {
        switch normalizeCase {
        case "lowercase":
            return text.lowercased()
        case "uppercase":
            return text.uppercased()
        default:
            return text
        }
    }

    private func sendTranscriptUpdate(text: String, isFinal: Bool, processingTimeMs: Int64 = 0, confidence: Float = 0) {
        let normalizedText = normalizeText(text)
        let endTime = Date().timeIntervalSince1970
        let audioDurationMs = Int64((endTime - speechStartTime) * 1000)
        let rtfx = processingTimeMs > 0 ? Float(audioDurationMs) / Float(processingTimeMs) : 0

        var body: [String: Any] = [
            "text": normalizedText,
            "isFinal": isFinal,
            "startTime": speechStartTime,
            "endTime": endTime,
            "processingTime": Double(processingTimeMs) / 1000.0,
            "audioDuration": Double(audioDurationMs) / 1000.0,
            "rtfx": rtfx,
            "confidence": confidence
        ]

        if currentSpeakerId > 0 {
            body["speakerId"] = currentSpeakerId
        }

        sendEvent(withName: "TranscriptUpdate", body: body)

        // Also send streaming update if in streaming mode
        if modelType == "streaming", let manager = streamingStateManager {
            let update = isFinal
                ? manager.confirmCurrent(processingTimeMs: processingTimeMs)
                : manager.updateState(newText: normalizedText, confidence: confidence, audioLengthMs: audioDurationMs, processingTimeMs: processingTimeMs)

            sendEvent(withName: "StreamingTranscriptUpdate", body: [
                "volatile": update.volatile,
                "confirmed": update.confirmed,
                "fullText": update.fullText,
                "isFinal": isFinal,
                "confidence": update.confidence,
                "processingTime": Double(update.processingTimeMs) / 1000.0,
                "rtfx": update.processingTimeMs > 0 ? Float(update.audioDurationMs) / Float(update.processingTimeMs) : 0
            ])
        }
    }

    private func sendVADUpdate(state: String, probability: Float, speechMs: Int, silenceMs: Int) {
        sendEvent(withName: "VADUpdate", body: [
            "state": state,
            "speechProbability": probability,
            "speechDurationMs": speechMs,
            "silenceDurationMs": silenceMs
        ])
    }

    private func sendSpeakerUpdate(speakerId: Int, status: String, justConfirmed: Bool) {
        sendEvent(withName: "SpeakerUpdate", body: [
            "speakerId": speakerId,
            "status": status,
            "justConfirmed": justConfirmed,
            "totalSpeakers": speakerProfiles.count,
            "confidence": lastConfidence,
            "embeddingQuality": lastEmbeddingQuality
        ])
    }

    private func addResult(text: String, isFinal: Bool, processingTimeMs: Int64 = 0, confidence: Float = 0) {
        let normalizedText = normalizeText(text)
        let endTime = Date().timeIntervalSince1970
        let audioDurationMs = Int64((endTime - speechStartTime) * 1000)
        let rtfx = processingTimeMs > 0 ? Float(audioDurationMs) / Float(processingTimeMs) : 0

        var result: [String: Any] = [
            "text": normalizedText,
            "isFinal": isFinal,
            "startTime": speechStartTime,
            "endTime": endTime,
            "processingTime": Double(processingTimeMs) / 1000.0,
            "audioDuration": Double(audioDurationMs) / 1000.0,
            "rtfx": rtfx,
            "confidence": confidence
        ]

        if currentSpeakerId > 0 {
            result["speakerId"] = currentSpeakerId
        }

        recognitionResults.append(result)
    }
}

// MARK: - Extensions for WAV header creation

extension UInt32 {
    var littleEndianData: Data {
        var value = self.littleEndian
        return Data(bytes: &value, count: 4)
    }
}

extension UInt16 {
    var littleEndianData: Data {
        var value = self.littleEndian
        return Data(bytes: &value, count: 2)
    }
}
