package com.sherpaonnxofflinestt import com.facebook.react.bridge.* import android.content.Intent import android.os.Build import android.app.ActivityManager import android.content.Context import androidx.core.content.ContextCompat import kotlin.concurrent.thread import org.json.JSONObject import java.io.File import java.io.FileInputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicReference import android.media.audiofx.AcousticEchoCanceler import android.media.MediaRecorder import com.sherpaonnxofflinestt.managers.* import com.sherpaonnxofflinestt.models.DiarizationConfig import com.sherpaonnxofflinestt.models.VADState import com.sherpaonnxofflinestt.utils.STTLogger import com.sherpaonnxofflinestt.utils.WavFileWriter /** * React Native module for speech-to-text with sherpa-onnx * * This module orchestrates: * - Audio recording (AudioRecordingManager) * - Voice activity detection (VADManager) * - Speech recognition (STTRecognitionManager) * - Speech denoising (DenoiserManager) * - Speaker diarization (DiarizationManager) * - Event emission (EventEmissionManager) * - Streaming state management (StreamingStateManager) */ class STTManagerModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), AudioDataListener, VADListenerExtended, STTRecognitionListener { // Managers private lateinit var eventManager: EventEmissionManager private lateinit var audioManager: AudioRecordingManager private lateinit var vadManager: VADManager private var denoiserManager: DenoiserManager? = null private var diarizationManager: DiarizationManager? = null private var pyannoteManager: PyannoteSegmentationManager? = null private var sttManager: STTRecognitionManager? = null private var streamingStateManager: StreamingStateManager? = null private var punctuationManager: PunctuationManager? = null // Diarization mode: "embedding" (default) or "pyannote" private var diarizationMode: String = "embedding" // Text normalization: "none", "lowercase", "uppercase" private var normalizeCase: String = "none" // State private var sampleRate: Int = STTConstants.DEFAULT_SAMPLE_RATE private var backgroundModeEnabled: Boolean = false private var speechStartTime: Long = 0 private var lastSentTranscript: String = "" private var lastProcessingTimeMs: Long = 0 // Offline mode buffer (thread-safe) private val offlineAudioBuffer = java.util.Collections.synchronizedList(mutableListOf()) @Volatile private var isInSpeech: Boolean = false // Results accumulation (thread-safe) private val recognitionResults = java.util.Collections.synchronizedList(mutableListOf()) // Audio capture for debugging private var rawWavFileWriter: WavFileWriter? = null // Raw microphone audio private var processedWavFileWriter: WavFileWriter? = null // After denoiser, before STT private var rawCaptureEnabled: Boolean = false private var processedCaptureEnabled: Boolean = false override fun getName(): String = "STTManager" init { eventManager = EventEmissionManager(reactContext) audioManager = AudioRecordingManager(reactContext, this) vadManager = VADManager(eventManager, this) } /** * Apply text case normalization based on config setting */ private fun normalizeText(text: String): String { return when (normalizeCase) { "lowercase" -> text.lowercase() "uppercase" -> text.uppercase() else -> text } } // ===================== // Provider Detection // ===================== @ReactMethod fun getAvailableProviders(promise: Promise) { try { val providers = Arguments.createArray() // CPU is always available val cpuProvider = Arguments.createMap().apply { putString("name", "cpu") putBoolean("available", true) putString("description", "CPU inference (always available)") } providers.pushMap(cpuProvider) // NNAPI available on Android 8.1+ (API 27+) val nnapiAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 val nnapiProvider = Arguments.createMap().apply { putString("name", "nnapi") putBoolean("available", nnapiAvailable) putString("description", if (nnapiAvailable) "Android Neural Networks API (hardware accelerated)" else "Requires Android 8.1+ (API 27+)") putInt("minApiLevel", 27) putInt("currentApiLevel", Build.VERSION.SDK_INT) } providers.pushMap(nnapiProvider) // GPU support check (OpenGL ES 3.1+) val activityManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager val configInfo = activityManager?.deviceConfigurationInfo val glEsVersion = configInfo?.glEsVersion ?: "0.0" val gpuAvailable = (configInfo?.reqGlEsVersion ?: 0) >= 0x00030001 val gpuProvider = Arguments.createMap().apply { putString("name", "gpu") putBoolean("available", gpuAvailable) putString("description", if (gpuAvailable) "GPU inference via OpenGL ES" else "Requires OpenGL ES 3.1+") putString("glEsVersion", glEsVersion) } providers.pushMap(gpuProvider) // Build result val result = Arguments.createMap().apply { putArray("providers", providers) putString("device", Build.MODEL) putString("manufacturer", Build.MANUFACTURER) putInt("apiLevel", Build.VERSION.SDK_INT) putString("androidVersion", Build.VERSION.RELEASE) putString("recommended", when { nnapiAvailable -> "nnapi" gpuAvailable -> "gpu" else -> "cpu" }) } promise.resolve(result) } catch (e: Exception) { android.util.Log.e("STTManager", "Failed to detect providers: ${e.message}", e) promise.reject("PROVIDER_ERROR", "Failed to detect providers: ${e.message}", e) } } // ===================== // Background Service // ===================== @ReactMethod fun startBackgroundService(promise: Promise) { try { val intent = Intent(reactContext, STTForegroundService::class.java).apply { action = STTForegroundService.ACTION_START } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ContextCompat.startForegroundService(reactContext, intent) } else { reactContext.startService(intent) } thread { var attempts = 0 val maxAttempts = 50 while (!STTForegroundService.isRunning && attempts < maxAttempts) { Thread.sleep(100) attempts++ } if (STTForegroundService.isRunning) { backgroundModeEnabled = true android.util.Log.i("STTManager", "Background service started") promise.resolve(true) } else { promise.reject("SERVICE_ERROR", "Background service failed to start") } } } catch (e: Exception) { promise.reject("SERVICE_ERROR", "Failed to start background service: ${e.message}", e) } } @ReactMethod fun stopBackgroundService(promise: Promise) { try { val intent = Intent(reactContext, STTForegroundService::class.java).apply { action = STTForegroundService.ACTION_STOP } reactContext.startService(intent) backgroundModeEnabled = false promise.resolve(true) } catch (e: Exception) { promise.reject("SERVICE_ERROR", "Failed to stop background service: ${e.message}", e) } } @ReactMethod fun isBackgroundServiceRunning(promise: Promise) { promise.resolve(STTForegroundService.isRunning) } // ===================== // Initialization // ===================== @ReactMethod fun initializeSTT(sampleRate: Int, channels: Int, configJson: String, promise: Promise) { thread { try { val startTime = System.currentTimeMillis() STTLogger.i("STTManager", "initializeSTT called with config: $configJson") val config = JSONObject(configJson) this.sampleRate = config.optInt("sampleRate", STTConstants.DEFAULT_SAMPLE_RATE) audioManager.sampleRate = this.sampleRate // Parse structured VAD config (using STTConstants for defaults) val vadConfig = config.optJSONObject("vadConfig") val vadThreshold = vadConfig?.optDouble("threshold", STTConstants.DEFAULT_VAD_THRESHOLD.toDouble())?.toFloat() ?: STTConstants.DEFAULT_VAD_THRESHOLD val vadMinSpeechMs = vadConfig?.optInt("minSpeechDurationMs", STTConstants.DEFAULT_MIN_SPEECH_MS) ?: STTConstants.DEFAULT_MIN_SPEECH_MS val vadMinSilenceMs = vadConfig?.optInt("minSilenceDurationMs", STTConstants.DEFAULT_MIN_SILENCE_MS) ?: STTConstants.DEFAULT_MIN_SILENCE_MS val vadMaxSpeechMs = vadConfig?.optInt("maxSpeechDurationMs", STTConstants.DEFAULT_MAX_SPEECH_MS) ?: STTConstants.DEFAULT_MAX_SPEECH_MS val vadSpeechPaddingMs = vadConfig?.optInt("speechPaddingMs", STTConstants.DEFAULT_SPEECH_PADDING_MS) ?: STTConstants.DEFAULT_SPEECH_PADDING_MS val vadMode = vadConfig?.optString("mode", "normal") ?: "normal" val encoderPath = config.getString("encoderPath") val decoderPath = config.getString("decoderPath") val joinerPath = config.optString("joinerPath", "") val tokensPath = config.getString("tokensPath") val modelType = config.optString("modelType", "streaming") val modelArchitecture = config.optString("modelArchitecture", "transducer") val whisperLanguage = config.optString("whisperLanguage", "") // Empty = auto-detect val whisperTask = config.optString("whisperTask", "transcribe") val provider = config.optString("provider", STTConstants.DEFAULT_PROVIDER) val normalizeCase = config.optString("normalizeCase", "none") val denoiserModelPath = config.optString("denoiserModelPath", "") val diarizationModelPath = config.optString("diarizationModelPath", null) // Parse diarization config (using STTConstants for defaults) val diarizationConfigJson = config.optJSONObject("diarization") val diarizationConfig = DiarizationConfig( speakerThreshold = diarizationConfigJson?.optDouble("speakerThreshold", STTConstants.DEFAULT_SPEAKER_THRESHOLD.toDouble())?.toFloat() ?: STTConstants.DEFAULT_SPEAKER_THRESHOLD, embeddingThreshold = diarizationConfigJson?.optDouble("embeddingThreshold", STTConstants.DEFAULT_EMBEDDING_THRESHOLD.toDouble())?.toFloat() ?: STTConstants.DEFAULT_EMBEDDING_THRESHOLD, minSpeechDurationMs = diarizationConfigJson?.optInt("minSpeechDurationMs", STTConstants.DEFAULT_MIN_SPEECH_DURATION_MS) ?: STTConstants.DEFAULT_MIN_SPEECH_DURATION_MS, minEmbeddingUpdateMs = diarizationConfigJson?.optInt("minEmbeddingUpdateMs", STTConstants.DEFAULT_MIN_EMBEDDING_UPDATE_MS) ?: STTConstants.DEFAULT_MIN_EMBEDDING_UPDATE_MS, maxSpeakers = diarizationConfigJson?.optInt("maxSpeakers", STTConstants.DEFAULT_MAX_SPEAKERS) ?: STTConstants.DEFAULT_MAX_SPEAKERS, embeddingAverageWeight = diarizationConfigJson?.optDouble("embeddingAverageWeight", STTConstants.DEFAULT_EMBEDDING_AVERAGE_WEIGHT.toDouble())?.toFloat() ?: STTConstants.DEFAULT_EMBEDDING_AVERAGE_WEIGHT, onboardingSegments = diarizationConfigJson?.optInt("onboardingSegments", STTConstants.DEFAULT_ONBOARDING_SEGMENTS) ?: STTConstants.DEFAULT_ONBOARDING_SEGMENTS, minSpeechForNewSpeaker = diarizationConfigJson?.optInt("minSpeechForNewSpeaker", STTConstants.DEFAULT_MIN_SPEECH_FOR_NEW_SPEAKER) ?: STTConstants.DEFAULT_MIN_SPEECH_FOR_NEW_SPEAKER ) STTLogger.i("STTManager", "Model type: $modelType, architecture: $modelArchitecture, provider: $provider, VAD mode: $vadMode, normalizeCase: $normalizeCase") // Store normalizeCase setting this.normalizeCase = normalizeCase // Initialize VAD with structured config vadManager.mode = vadMode vadManager.initialize(hopSize = 160, threshold = vadThreshold) vadManager.vadMinSpeechMs = vadMinSpeechMs vadManager.vadMinSilenceMs = vadMinSilenceMs vadManager.maxSpeechDurationMs = vadMaxSpeechMs vadManager.speechPaddingMs = vadSpeechPaddingMs // Initialize streaming state manager for streaming mode if (modelType == "streaming") { streamingStateManager = StreamingStateManager() } // Parallel initialization for STT, denoiser, and diarization val latch = CountDownLatch(3) val sttError = AtomicReference(null) // Thread 1: STT model (slowest) thread { try { sttManager = STTRecognitionManager(this) sttManager?.sampleRate = this.sampleRate val success = sttManager?.initialize( encoderPath = encoderPath, decoderPath = decoderPath, joinerPath = joinerPath, tokensPath = tokensPath, modelType = modelType, modelArchitecture = modelArchitecture, whisperLanguage = whisperLanguage, whisperTask = whisperTask, provider = provider, numThreads = 4 ) ?: false if (!success) { sttError.set(Exception("Failed to initialize STT")) } } catch (e: Exception) { sttError.set(e) } finally { latch.countDown() } } // Thread 2: Denoiser (fast) thread { try { if (denoiserModelPath.isNotEmpty() && File(denoiserModelPath).exists()) { denoiserManager = DenoiserManager() denoiserManager?.initialize(denoiserModelPath) } } catch (e: Exception) { android.util.Log.w("STTManager", "Denoiser init failed: ${e.message}") } finally { latch.countDown() } } // Thread 3: Diarization (fast) thread { try { android.util.Log.i("STTManager", "[DIAR] Diarization init starting, modelPath=$diarizationModelPath") if (diarizationModelPath.isNullOrEmpty()) { android.util.Log.w("STTManager", "[DIAR] Diarization model path is null or empty, skipping") } else if (!File(diarizationModelPath).exists()) { android.util.Log.w("STTManager", "[DIAR] Diarization model file does not exist: $diarizationModelPath") } else { diarizationManager = DiarizationManager(eventManager) val success = diarizationManager?.initialize( modelPath = diarizationModelPath, diarizationConfig = diarizationConfig ) ?: false android.util.Log.i("STTManager", "[DIAR] Diarization init result: success=$success, isEnabled=${diarizationManager?.isEnabled}") } } catch (e: Throwable) { android.util.Log.e("STTManager", "[DIAR] Diarization init failed: ${e.javaClass.name}: ${e.message}", e) } finally { latch.countDown() } } latch.await() val totalTime = System.currentTimeMillis() - startTime android.util.Log.i("STTManager", "Initialization complete in ${totalTime}ms") if (sttError.get() != null) { throw sttError.get()!! } promise.resolve(null) } catch (e: Exception) { android.util.Log.e("STTManager", "Initialization failed: ${e.message}", e) promise.reject("INIT_ERROR", "Failed to initialize STT: ${e.message}", e) } } } // ===================== // Recording Control // ===================== @ReactMethod fun startRecording(promise: Promise) { if (audioManager.isRecording) { promise.reject("ALREADY_RECORDING", "Recording is already in progress") return } if (!audioManager.hasPermission()) { promise.reject("PERMISSION_DENIED", "Microphone permission not granted") return } try { // Reset state recognitionResults.clear() offlineAudioBuffer.clear() diarizationManager?.resetSpeakers() isInSpeech = false vadManager.reset() lastSentTranscript = "" lastProcessingTimeMs = 0 streamingStateManager?.reset() // Create streaming session if needed if (sttManager?.isStreaming() == true) { sttManager?.createStream() } // Start recording if (audioManager.startRecording()) { promise.resolve(null) } else { promise.reject("RECORDING_ERROR", "Failed to start recording") } } catch (e: Exception) { promise.reject("RECORDING_ERROR", "Failed to start recording: ${e.message}", e) } } @ReactMethod fun stopRecording(promise: Promise) { if (!audioManager.isRecording) { promise.reject("NOT_RECORDING", "No recording in progress") return } try { audioManager.stopRecording() // Flush pending streaming results before releasing the stream if (sttManager?.isStreaming() == true && isInSpeech) { val flushedText = sttManager?.flushStreamingResult() if (!flushedText.isNullOrEmpty()) { val normalizedText = normalizeText(flushedText) val endTime = System.currentTimeMillis() val speakerId = diarizationManager?.getCurrentSpeakerId() ?: -1 android.util.Log.i("STTManager", "[FLUSH] Flushing pending streaming text: \"$normalizedText\"") // Send final transcript event eventManager.sendTranscriptUpdate( text = normalizedText, isFinal = true, startTime = speechStartTime, endTime = endTime, speakerId = speakerId, processingTimeMs = 0, confidence = 0f ) // Add to results recognitionResults.add(eventManager.createResultMap( text = normalizedText, isFinal = true, startTime = speechStartTime, endTime = endTime, speakerId = speakerId, processingTimeMs = 0, confidence = 0f )) } } sttManager?.releaseStream() isInSpeech = false val resultsArray = Arguments.createArray() recognitionResults.forEach { resultsArray.pushMap(it) } promise.resolve(resultsArray) } catch (e: Exception) { promise.reject("STOP_ERROR", "Failed to stop recording: ${e.message}", e) } } // ===================== // AudioDataListener // ===================== override fun onAudioData(samples: ShortArray, floatSamples: FloatArray) { // Capture RAW audio (before any processing) if (rawCaptureEnabled) { rawWavFileWriter?.write(floatSamples) } val stt = sttManager ?: return if (stt.isStreaming()) { // Streaming mode: denoise + real-time STT var processedSamples = floatSamples var processedShorts = samples if (denoiserManager?.isEnabled == true) { processedSamples = denoiserManager!!.process(floatSamples, sampleRate) processedShorts = ShortArray(processedSamples.size) { (processedSamples[it] * 32768.0f).toInt().coerceIn(-32768, 32767).toShort() } } // Capture PROCESSED audio (after denoiser, before STT) if (processedCaptureEnabled) { processedWavFileWriter?.write(processedSamples) } // Process through VAD vadManager.processAudio(processedShorts, processedSamples, isInSpeech) // Stream to recognizer val result = stt.processStreamingAudio(processedSamples, lastSentTranscript) if (result != null && result.text.isNotEmpty()) { lastSentTranscript = result.text } else if (result != null && result.text.isEmpty()) { // Endpoint was detected, reset lastSentTranscript = "" } } else { // Offline mode: VAD-triggered batch processing // For offline mode, capture processed audio when speech ends (in onSpeechEnd) vadManager.processAudio(samples, floatSamples, isInSpeech) } } // ===================== // VADListener // ===================== override fun onSpeechStart() { // Prevent duplicate speech_start events from clearing the buffer if (isInSpeech) { android.util.Log.w("STTManager", "[VAD DEBUG] IGNORING duplicate SPEECH_START (already in speech)") return } speechStartTime = System.currentTimeMillis() isInSpeech = true android.util.Log.i("STTManager", "[VAD DEBUG] ===== SPEECH START =====") // Always clear diarization buffer at speech start diarizationManager?.clearBuffer() if (sttManager?.isOffline() == true) { offlineAudioBuffer.clear() android.util.Log.i("STTManager", "[VAD DEBUG] Cleared offline buffer (offline mode)") } } override fun onSpeechContinue(floatSamples: FloatArray) { // Always add to diarization buffer (works for both offline and streaming) val diarEnabled = diarizationManager?.isEnabled ?: false android.util.Log.d("STTManager", "[DIAR DEBUG] onSpeechContinue: ${floatSamples.size} samples, diarEnabled=$diarEnabled") diarizationManager?.addAudioChunk(floatSamples) if (sttManager?.isOffline() == true) { offlineAudioBuffer.add(floatSamples) // Log periodically (every 10 chunks to avoid spam) if (offlineAudioBuffer.size % 10 == 0) { val totalSamples = offlineAudioBuffer.sumOf { it.size } val durationMs = (totalSamples.toFloat() / sampleRate * 1000).toLong() android.util.Log.i("STTManager", "[VAD DEBUG] Speech continue: ${offlineAudioBuffer.size} chunks, ${totalSamples} samples, ${durationMs}ms") } } } override fun onSpeechEnd(speechBuffer: List) { isInSpeech = false val speechDurationMs = System.currentTimeMillis() - speechStartTime val totalSamples = offlineAudioBuffer.sumOf { it.size } val audioDurationMs = (totalSamples.toFloat() / sampleRate * 1000).toLong() android.util.Log.i("STTManager", "[VAD DEBUG] ===== SPEECH END =====") android.util.Log.i("STTManager", "[VAD DEBUG] Wall clock duration: ${speechDurationMs}ms") android.util.Log.i("STTManager", "[VAD DEBUG] Offline audio buffer: ${offlineAudioBuffer.size} chunks, ${totalSamples} samples, ${audioDurationMs}ms") android.util.Log.i("STTManager", "[VAD DEBUG] Diarization enabled: ${diarizationManager?.isEnabled}, isInitialized: ${diarizationManager?.isInitialized()}") // Process diarization first android.util.Log.i("STTManager", "[DIAR DEBUG] Calling processBuffer...") val speakerId = diarizationManager?.processBuffer(sampleRate) ?: -1 android.util.Log.i("STTManager", "[DIAR DEBUG] processBuffer returned speakerId=$speakerId") // Process offline STT if (sttManager?.isOffline() == true) { android.util.Log.i("STTManager", "[VAD DEBUG] Triggering offline STT processing...") processOfflineSTT() } } override fun onForcedSegmentBreak(speechBuffer: List) { // Handle forced segment break due to max speech duration android.util.Log.i("STTManager", "Forced segment break with ${speechBuffer.size} chunks") // Process diarization diarizationManager?.processBuffer(sampleRate) // Process offline STT if in offline mode if (sttManager?.isOffline() == true) { processOfflineSTT() offlineAudioBuffer.clear() } // Always clear diarization buffer for the next segment diarizationManager?.clearBuffer() } override fun onStateChange(state: VADState, probability: Float, speechMs: Int, silenceMs: Int) { // State change notifications are handled by VADManager via EventEmissionManager } // ===================== // STTRecognitionListener // ===================== override fun onPartialResult(text: String, processingTimeMs: Long, confidence: Float) { val normalizedText = normalizeText(text) lastProcessingTimeMs = processingTimeMs val endTime = System.currentTimeMillis() val audioDurationMs = endTime - speechStartTime // Update streaming state and emit streaming update event val streamingUpdate = streamingStateManager?.updateState( newText = normalizedText, confidence = confidence, audioLengthMs = audioDurationMs, processingTimeMs = processingTimeMs ) if (streamingUpdate != null) { eventManager.sendStreamingUpdate( volatileText = streamingUpdate.volatile, confirmedText = streamingUpdate.confirmed, fullText = streamingUpdate.fullText, isFinal = false, confidence = streamingUpdate.confidence, processingTimeMs = streamingUpdate.processingTimeMs, audioDurationMs = streamingUpdate.audioDurationMs ) } // Also send standard transcript update eventManager.sendTranscriptUpdate( text = normalizedText, isFinal = false, startTime = speechStartTime, endTime = endTime, speakerId = diarizationManager?.getCurrentSpeakerId() ?: -1, processingTimeMs = processingTimeMs, confidence = confidence ) } override fun onFinalResult(text: String, processingTimeMs: Long, confidence: Float) { val normalizedText = normalizeText(text) android.util.Log.i("STTManager", "[EVENT DEBUG] onFinalResult called with: \"$normalizedText\"") lastProcessingTimeMs = processingTimeMs val speakerId = diarizationManager?.getCurrentSpeakerId() ?: -1 val endTime = System.currentTimeMillis() android.util.Log.i("STTManager", "[EVENT DEBUG] speakerId=$speakerId, endTime=$endTime") // Confirm streaming state val streamingUpdate = streamingStateManager?.confirmCurrent(processingTimeMs) if (streamingUpdate != null) { eventManager.sendStreamingUpdate( volatileText = streamingUpdate.volatile, confirmedText = streamingUpdate.confirmed, fullText = streamingUpdate.fullText, isFinal = true, confidence = streamingUpdate.confidence, processingTimeMs = streamingUpdate.processingTimeMs, audioDurationMs = streamingUpdate.audioDurationMs ) } android.util.Log.i("STTManager", "[EVENT DEBUG] Sending transcript update event...") eventManager.sendTranscriptUpdate( text = normalizedText, isFinal = true, startTime = speechStartTime, endTime = endTime, speakerId = speakerId, processingTimeMs = processingTimeMs, confidence = confidence ) android.util.Log.i("STTManager", "[EVENT DEBUG] Transcript update event sent") // Add to accumulated results recognitionResults.add(eventManager.createResultMap( text = normalizedText, isFinal = true, startTime = speechStartTime, endTime = endTime, speakerId = speakerId, processingTimeMs = processingTimeMs, confidence = confidence )) lastSentTranscript = "" } override fun onEndpoint() { lastSentTranscript = "" // Reset streaming state manager on endpoint streamingStateManager?.reset() } override fun onError(code: String, message: String) { eventManager.sendError(code, message) } // ===================== // Offline STT Processing // ===================== private fun processOfflineSTT() { val stt = sttManager ?: return val totalSamples = offlineAudioBuffer.sumOf { it.size } if (totalSamples == 0) { android.util.Log.w("STTManager", "[STT DEBUG] processOfflineSTT: Empty buffer, skipping") return } // Combine all chunks into single buffer var allSamples = FloatArray(totalSamples) var offset = 0 offlineAudioBuffer.forEach { buffer -> buffer.copyInto(allSamples, offset) offset += buffer.size } offlineAudioBuffer.clear() // Calculate audio statistics BEFORE denoiser val durationMs = (totalSamples.toFloat() / sampleRate * 1000).toLong() var minSample = Float.MAX_VALUE var maxSample = Float.MIN_VALUE var sumAbsSamples = 0.0 for (sample in allSamples) { if (sample < minSample) minSample = sample if (sample > maxSample) maxSample = sample sumAbsSamples += kotlin.math.abs(sample.toDouble()) } val meanAbsSample = sumAbsSamples / allSamples.size android.util.Log.i("STTManager", "[STT DEBUG] ===== PROCESSING OFFLINE STT =====") android.util.Log.i("STTManager", "[STT DEBUG] Buffer: ${allSamples.size} samples = ${durationMs}ms") android.util.Log.i("STTManager", "[STT DEBUG] Audio BEFORE denoiser: min=${"%.4f".format(minSample)}, max=${"%.4f".format(maxSample)}, meanAbs=${"%.4f".format(meanAbsSample)}") android.util.Log.i("STTManager", "[STT DEBUG] Denoiser enabled: ${denoiserManager?.isEnabled == true}") // Apply denoising to full buffer if (denoiserManager?.isEnabled == true) { val denoiseStart = System.currentTimeMillis() allSamples = denoiserManager!!.process(allSamples, sampleRate) val denoiseTime = System.currentTimeMillis() - denoiseStart // Calculate audio statistics AFTER denoiser var minDenoise = Float.MAX_VALUE var maxDenoise = Float.MIN_VALUE var sumAbsDenoise = 0.0 for (sample in allSamples) { if (sample < minDenoise) minDenoise = sample if (sample > maxDenoise) maxDenoise = sample sumAbsDenoise += kotlin.math.abs(sample.toDouble()) } val meanAbsDenoise = sumAbsDenoise / allSamples.size android.util.Log.i("STTManager", "[STT DEBUG] Audio AFTER denoiser: min=${"%.4f".format(minDenoise)}, max=${"%.4f".format(maxDenoise)}, meanAbs=${"%.4f".format(meanAbsDenoise)}") android.util.Log.i("STTManager", "[STT DEBUG] Denoiser took ${denoiseTime}ms") } // Capture PROCESSED audio (after denoiser, before normalization) - for offline mode if (processedCaptureEnabled) { processedWavFileWriter?.write(allSamples) android.util.Log.i("STTManager", "[STT DEBUG] Captured processed audio to file") } // Check if audio is too quiet after denoising - likely just noise, skip STT val peakAmplitude = allSamples.maxOfOrNull { kotlin.math.abs(it) } ?: 0f if (peakAmplitude < STTConstants.MIN_PEAK_FOR_STT) { android.util.Log.w("STTManager", "[STT DEBUG] Audio too quiet after denoising (peak=${"%.4f".format(peakAmplitude)} < ${STTConstants.MIN_PEAK_FOR_STT})") android.util.Log.w("STTManager", "[STT DEBUG] Skipping STT to avoid hallucinations from noise") android.util.Log.i("STTManager", "[STT DEBUG] ===== END PROCESSING (skipped) =====") return } // Normalize audio amplitude to improve STT recognition of quiet speech if (peakAmplitude < STTConstants.TARGET_PEAK_AMPLITUDE) { val gain = STTConstants.TARGET_PEAK_AMPLITUDE / peakAmplitude android.util.Log.i("STTManager", "[STT DEBUG] Normalizing audio: peak=${"%.4f".format(peakAmplitude)}, gain=${"%.2f".format(gain)}x") for (i in allSamples.indices) { allSamples[i] = (allSamples[i] * gain).coerceIn(-1f, 1f) } // Log normalized stats var minNorm = Float.MAX_VALUE var maxNorm = Float.MIN_VALUE var sumAbsNorm = 0.0 for (sample in allSamples) { if (sample < minNorm) minNorm = sample if (sample > maxNorm) maxNorm = sample sumAbsNorm += kotlin.math.abs(sample.toDouble()) } val meanAbsNorm = sumAbsNorm / allSamples.size android.util.Log.i("STTManager", "[STT DEBUG] Audio AFTER normalization: min=${"%.4f".format(minNorm)}, max=${"%.4f".format(maxNorm)}, meanAbs=${"%.4f".format(meanAbsNorm)}") } else { android.util.Log.i("STTManager", "[STT DEBUG] No normalization needed (peak=${"%.4f".format(peakAmplitude)} >= ${STTConstants.TARGET_PEAK_AMPLITUDE})") } // processOfflineSamples calls listener.onFinalResult() which handles transcript sending android.util.Log.i("STTManager", "[STT DEBUG] Sending ${allSamples.size} samples to STT model...") val result = stt.processOfflineSamples(allSamples) if (result == null) { android.util.Log.w("STTManager", "[STT DEBUG] !!!!! STT RETURNED NULL RESULT !!!!!") android.util.Log.w("STTManager", "[STT DEBUG] This could mean: empty transcription, model error, or insufficient audio") } else { android.util.Log.i("STTManager", "[STT DEBUG] STT result: \"${result.text}\" (${result.processingTimeMs}ms)") } android.util.Log.i("STTManager", "[STT DEBUG] ===== END PROCESSING =====") } // ===================== // File Recognition // ===================== @ReactMethod fun recognizeFile(filePath: String, promise: Promise) { thread { try { val file = File(filePath) if (!file.exists()) { reactContext.runOnUiQueueThread { promise.reject("FILE_NOT_FOUND", "Audio file not found: $filePath") } return@thread } val samples = readWavFile(filePath) if (samples == null) { reactContext.runOnUiQueueThread { promise.reject("READ_ERROR", "Failed to read audio file") } return@thread } val result = sttManager?.processOfflineSamples(samples) val audioDurationMs = (samples.size.toDouble() / sampleRate * 1000).toLong() val processingTimeMs = result?.processingTimeMs ?: 0L val rtfx = if (processingTimeMs > 0) audioDurationMs.toFloat() / processingTimeMs else 0f val resultMap = Arguments.createMap().apply { putString("text", result?.text ?: "") putBoolean("isFinal", true) putDouble("startTime", 0.0) putDouble("endTime", samples.size.toDouble() / sampleRate) putDouble("confidence", (result?.confidence ?: 0f).toDouble()) putDouble("processingTime", processingTimeMs / 1000.0) putDouble("audioDuration", audioDurationMs / 1000.0) putDouble("rtfx", rtfx.toDouble()) } val resultsArray = Arguments.createArray() resultsArray.pushMap(resultMap) reactContext.runOnUiQueueThread { promise.resolve(resultsArray) } } catch (e: Exception) { reactContext.runOnUiQueueThread { promise.reject("RECOGNIZE_ERROR", "Failed to recognize file: ${e.message}", e) } } } } private fun readWavFile(path: String): FloatArray? { return try { val file = File(path) val fis = FileInputStream(file) val buffer = ByteArray(file.length().toInt()) fis.read(buffer) fis.close() val headerSize = 44 val dataSize = buffer.size - headerSize val samples = FloatArray(dataSize / 2) val byteBuffer = ByteBuffer.wrap(buffer, headerSize, dataSize) byteBuffer.order(ByteOrder.LITTLE_ENDIAN) for (i in samples.indices) { samples[i] = byteBuffer.short / 32768.0f } samples } catch (e: Exception) { null } } // ===================== // Status & Config // ===================== @ReactMethod fun getModelType(promise: Promise) { promise.resolve(sttManager?.getModelType() ?: "unknown") } @ReactMethod fun isRecording(promise: Promise) { promise.resolve(audioManager.isRecording) } @ReactMethod fun getSpeakerCount(promise: Promise) { promise.resolve(diarizationManager?.getSpeakerCount() ?: 0) } @ReactMethod fun resetSpeakers(promise: Promise) { diarizationManager?.resetSpeakers() promise.resolve(null) } @ReactMethod fun mergeSpeakers(sourceId: Int, targetId: Int, promise: Promise) { val result = diarizationManager?.mergeSpeakers(sourceId, targetId) ?: false promise.resolve(result) } @ReactMethod fun removeSpeaker(speakerId: Int, promise: Promise) { val result = diarizationManager?.removeSpeaker(speakerId) ?: false promise.resolve(result) } @ReactMethod fun getSpeakerProfiles(promise: Promise) { val profiles = diarizationManager?.getSpeakerProfiles() ?: emptyList() val array = Arguments.createArray() profiles.forEach { profile -> val map = Arguments.createMap() map.putInt("speakerId", profile["speakerId"] as Int) map.putString("status", profile["status"] as String) map.putInt("segmentCount", profile["segmentCount"] as Int) map.putDouble("totalSpeechMs", (profile["totalSpeechMs"] as Long).toDouble()) map.putDouble("lastSeenTimestamp", (profile["lastSeenTimestamp"] as Long).toDouble()) array.pushMap(map) } promise.resolve(array) } // ===================== // Pyannote Segmentation // ===================== /** * Initialize pyannote-based speaker segmentation. * This provides more accurate speaker change detection using the pyannote model. * * @param configJson JSON configuration with: * - segmentationModelPath: Path to pyannote model.onnx * - embeddingModelPath: Path to speaker embedding model * - numSpeakers: Expected number of speakers (0 for auto-detection) * - clusteringThreshold: Distance threshold for clustering (default 0.5) * - minDurationOn: Min speech duration in seconds (default 0.5) * - minDurationOff: Min silence duration in seconds (default 0.3) * - provider: ONNX provider ("cpu", "nnapi", "gpu") */ @ReactMethod fun initializePyannoteSegmentation(configJson: String, promise: Promise) { thread { try { val config = JSONObject(configJson) val segmentationModelPath = config.getString("segmentationModelPath") val embeddingModelPath = config.getString("embeddingModelPath") val pyannoteConfig = PyannoteSegmentationConfig( segmentationModelPath = segmentationModelPath, embeddingModelPath = embeddingModelPath, numSpeakers = config.optInt("numSpeakers", 0), clusteringThreshold = config.optDouble("clusteringThreshold", STTConstants.DEFAULT_CLUSTERING_THRESHOLD.toDouble()).toFloat(), minDurationOn = config.optDouble("minDurationOn", STTConstants.DEFAULT_MIN_DURATION_ON.toDouble()).toFloat(), minDurationOff = config.optDouble("minDurationOff", STTConstants.DEFAULT_MIN_DURATION_OFF.toDouble()).toFloat(), numThreads = config.optInt("numThreads", STTConstants.DEFAULT_AUXILIARY_NUM_THREADS), provider = config.optString("provider", STTConstants.DEFAULT_PROVIDER) ) pyannoteManager = PyannoteSegmentationManager(eventManager) val success = pyannoteManager?.initialize( segmentationModelPath = segmentationModelPath, embeddingModelPath = embeddingModelPath, segmentationConfig = pyannoteConfig ) ?: false if (success) { android.util.Log.i("STTManager", "Pyannote segmentation initialized successfully") promise.resolve(true) } else { promise.reject("PYANNOTE_INIT_ERROR", "Failed to initialize pyannote segmentation") } } catch (e: Exception) { android.util.Log.e("STTManager", "Pyannote init failed: ${e.message}", e) promise.reject("PYANNOTE_INIT_ERROR", "Failed to initialize pyannote: ${e.message}", e) } } } /** * Set the diarization mode. * @param mode "embedding" (default) or "pyannote" */ @ReactMethod fun setDiarizationMode(mode: String, promise: Promise) { when (mode) { "embedding" -> { if (diarizationManager?.isInitialized() != true) { promise.reject("DIARIZATION_ERROR", "Embedding diarization not initialized") return } diarizationMode = "embedding" android.util.Log.i("STTManager", "Diarization mode set to: embedding") promise.resolve(true) } "pyannote" -> { if (pyannoteManager?.isInitialized() != true) { promise.reject("DIARIZATION_ERROR", "Pyannote segmentation not initialized") return } diarizationMode = "pyannote" android.util.Log.i("STTManager", "Diarization mode set to: pyannote") promise.resolve(true) } else -> { promise.reject("DIARIZATION_ERROR", "Invalid mode: $mode. Use 'embedding' or 'pyannote'") } } } /** * Get the current diarization mode. */ @ReactMethod fun getDiarizationMode(promise: Promise) { promise.resolve(diarizationMode) } /** * Process audio file with pyannote diarization. * Returns detailed speaker segments with timestamps. * * @param filePath Path to WAV audio file */ @ReactMethod fun diarizeFile(filePath: String, promise: Promise) { thread { try { val pyannote = pyannoteManager if (pyannote == null || !pyannote.isInitialized()) { reactContext.runOnUiQueueThread { promise.reject("PYANNOTE_ERROR", "Pyannote segmentation not initialized") } return@thread } val samples = readWavFile(filePath) if (samples == null) { reactContext.runOnUiQueueThread { promise.reject("READ_ERROR", "Failed to read audio file") } return@thread } val segments = pyannote.process(samples, sampleRate) if (segments == null) { reactContext.runOnUiQueueThread { promise.reject("PROCESS_ERROR", "Failed to process audio with pyannote") } return@thread } // Convert to React Native array val resultArray = Arguments.createArray() segments.forEach { segment -> val segmentMap = Arguments.createMap().apply { putInt("speakerId", segment.speakerId) putDouble("startTime", segment.startTimeMs / 1000.0) putDouble("endTime", segment.endTimeMs / 1000.0) putDouble("durationMs", (segment.endTimeMs - segment.startTimeMs).toDouble()) } resultArray.pushMap(segmentMap) } // Also include summary val speakers = segments.map { it.speakerId }.distinct().sorted() val resultMap = Arguments.createMap().apply { putArray("segments", resultArray) putInt("speakerCount", speakers.size) val speakersArray = Arguments.createArray() speakers.forEach { speakersArray.pushInt(it) } putArray("speakers", speakersArray) putDouble("audioDuration", samples.size.toDouble() / sampleRate) } reactContext.runOnUiQueueThread { promise.resolve(resultMap) } } catch (e: Exception) { reactContext.runOnUiQueueThread { promise.reject("DIARIZE_ERROR", "Failed to diarize file: ${e.message}", e) } } } } /** * Set expected number of speakers for pyannote diarization. * Use -1 for auto-detection (uses clustering threshold). */ @ReactMethod fun setPyannoteNumSpeakers(numSpeakers: Int, promise: Promise) { val pyannote = pyannoteManager if (pyannote == null || !pyannote.isInitialized()) { promise.reject("PYANNOTE_ERROR", "Pyannote segmentation not initialized") return } pyannote.setNumSpeakers(numSpeakers) promise.resolve(true) } /** * Set clustering threshold for pyannote auto speaker detection. * Smaller values = more clusters (more speakers detected). * Only used when numSpeakers = -1. */ @ReactMethod fun setPyannoteClusteringThreshold(threshold: Double, promise: Promise) { val pyannote = pyannoteManager if (pyannote == null || !pyannote.isInitialized()) { promise.reject("PYANNOTE_ERROR", "Pyannote segmentation not initialized") return } pyannote.setClusteringThreshold(threshold.toFloat()) promise.resolve(true) } /** * Check if pyannote segmentation is initialized. */ @ReactMethod fun isPyannoteInitialized(promise: Promise) { promise.resolve(pyannoteManager?.isInitialized() ?: false) } // ===================== // Audio Capture (Debug) // ===================== @ReactMethod fun startAudioCapture(filePath: String, captureType: String, promise: Promise) { try { when (captureType) { "raw" -> { if (rawCaptureEnabled) { promise.reject("CAPTURE_ERROR", "Raw audio capture is already in progress") return } rawWavFileWriter = WavFileWriter(filePath, sampleRate, 1, 16) if (rawWavFileWriter?.start() == true) { rawCaptureEnabled = true android.util.Log.i("STTManager", "Raw audio capture started: $filePath") promise.resolve(true) } else { promise.reject("CAPTURE_ERROR", "Failed to start raw audio capture") } } "processed" -> { if (processedCaptureEnabled) { promise.reject("CAPTURE_ERROR", "Processed audio capture is already in progress") return } processedWavFileWriter = WavFileWriter(filePath, sampleRate, 1, 16) if (processedWavFileWriter?.start() == true) { processedCaptureEnabled = true android.util.Log.i("STTManager", "Processed audio capture started: $filePath") promise.resolve(true) } else { promise.reject("CAPTURE_ERROR", "Failed to start processed audio capture") } } else -> { promise.reject("CAPTURE_ERROR", "Invalid capture type: $captureType. Use 'raw' or 'processed'") } } } catch (e: Exception) { promise.reject("CAPTURE_ERROR", e.message) } } @ReactMethod fun stopAudioCapture(captureType: String, promise: Promise) { try { when (captureType) { "raw" -> { if (!rawCaptureEnabled) { promise.resolve(false) return } rawCaptureEnabled = false val success = rawWavFileWriter?.close() ?: false rawWavFileWriter = null android.util.Log.i("STTManager", "Raw audio capture stopped") promise.resolve(success) } "processed" -> { if (!processedCaptureEnabled) { promise.resolve(false) return } processedCaptureEnabled = false val success = processedWavFileWriter?.close() ?: false processedWavFileWriter = null android.util.Log.i("STTManager", "Processed audio capture stopped") promise.resolve(success) } "both" -> { var rawSuccess = true var processedSuccess = true if (rawCaptureEnabled) { rawCaptureEnabled = false rawSuccess = rawWavFileWriter?.close() ?: false rawWavFileWriter = null } if (processedCaptureEnabled) { processedCaptureEnabled = false processedSuccess = processedWavFileWriter?.close() ?: false processedWavFileWriter = null } android.util.Log.i("STTManager", "All audio capture stopped") promise.resolve(rawSuccess && processedSuccess) } else -> { promise.reject("CAPTURE_ERROR", "Invalid capture type: $captureType") } } } catch (e: Exception) { promise.reject("CAPTURE_ERROR", e.message) } } @ReactMethod fun stopAllAudioCapture(promise: Promise) { try { var success = true if (rawCaptureEnabled) { rawCaptureEnabled = false success = success && (rawWavFileWriter?.close() ?: false) rawWavFileWriter = null } if (processedCaptureEnabled) { processedCaptureEnabled = false success = success && (processedWavFileWriter?.close() ?: false) processedWavFileWriter = null } android.util.Log.i("STTManager", "All audio capture stopped") promise.resolve(success) } catch (e: Exception) { promise.reject("CAPTURE_ERROR", e.message) } } @ReactMethod fun isAudioCaptureActive(captureType: String, promise: Promise) { when (captureType) { "raw" -> promise.resolve(rawCaptureEnabled) "processed" -> promise.resolve(processedCaptureEnabled) "any" -> promise.resolve(rawCaptureEnabled || processedCaptureEnabled) else -> promise.resolve(rawCaptureEnabled || processedCaptureEnabled) } } @ReactMethod fun getAudioCaptureStatus(promise: Promise) { val result = Arguments.createMap().apply { putBoolean("rawEnabled", rawCaptureEnabled) putBoolean("processedEnabled", processedCaptureEnabled) } promise.resolve(result) } @ReactMethod fun stopAudioCaptureLegacy(promise: Promise) { // Legacy method for backward compatibility - stops all captures try { var success = true if (rawCaptureEnabled) { rawCaptureEnabled = false success = success && (rawWavFileWriter?.close() ?: false) rawWavFileWriter = null } if (processedCaptureEnabled) { processedCaptureEnabled = false success = success && (processedWavFileWriter?.close() ?: false) processedWavFileWriter = null } promise.resolve(success) } catch (e: Exception) { promise.reject("CAPTURE_ERROR", e.message) } } @ReactMethod fun isAudioCaptureActiveLegacy(promise: Promise) { promise.resolve(rawCaptureEnabled || processedCaptureEnabled) } // Keep old method signatures for compatibility but redirect @ReactMethod fun startAudioCaptureLegacy(filePath: String, promise: Promise) { startAudioCapture(filePath, "raw", promise) } @ReactMethod fun getCaptureDirectory(promise: Promise) { val cacheDir = reactContext.cacheDir promise.resolve(cacheDir.absolutePath) } @ReactMethod fun listCapturedFiles(promise: Promise) { try { val cacheDir = reactContext.cacheDir val wavFiles = cacheDir.listFiles { file -> file.isFile && file.name.startsWith("capture_") && file.name.endsWith(".wav") }?.sortedByDescending { it.lastModified() } ?: emptyList() val result = Arguments.createArray() for (file in wavFiles) { val fileInfo = Arguments.createMap().apply { putString("path", file.absolutePath) putString("name", file.name) putDouble("size", file.length().toDouble()) putDouble("lastModified", file.lastModified().toDouble()) } result.pushMap(fileInfo) } promise.resolve(result) } catch (e: Exception) { promise.reject("LIST_ERROR", e.message) } } @ReactMethod fun deleteCapturedFile(filePath: String, promise: Promise) { try { val file = File(filePath) if (file.exists() && file.name.startsWith("capture_") && file.name.endsWith(".wav")) { val deleted = file.delete() promise.resolve(deleted) } else { promise.resolve(false) } } catch (e: Exception) { promise.reject("DELETE_ERROR", e.message) } } @ReactMethod fun setDenoiserEnabled(enabled: Boolean, promise: Promise) { val result = denoiserManager?.setEnabled(enabled) if (result != null) { promise.resolve(result) } else { promise.reject("NO_DENOISER", "Denoiser not initialized") } } @ReactMethod fun isDenoiserEnabled(promise: Promise) { promise.resolve(denoiserManager?.isEnabled ?: false) } // ===================== // Echo Cancellation (AEC) // ===================== /** * Check if Acoustic Echo Cancellation is available on this device. */ @ReactMethod fun isAecAvailable(promise: Promise) { promise.resolve(AcousticEchoCanceler.isAvailable()) } /** * Enable or disable Acoustic Echo Cancellation. * AEC helps prevent feedback when playing audio through speakers * while recording from the microphone. * * Note: AEC is enabled by default. Changes take effect on next recording start. */ @ReactMethod fun setAecEnabled(enabled: Boolean, promise: Promise) { audioManager.aecEnabled = enabled android.util.Log.i("STTManager", "AEC enabled set to: $enabled (will apply on next recording)") promise.resolve(true) } /** * Check if AEC is currently enabled. */ @ReactMethod fun isAecEnabled(promise: Promise) { promise.resolve(audioManager.aecEnabled) } /** * Set the audio source for recording. * @param source Audio source constant: * - "voice_communication" (7): VoIP with AEC support (default) * - "voice_recognition" (6): Pure STT without VoIP optimizations * - "mic" (1): Default microphone * * Note: Changes take effect on next recording start. */ @ReactMethod fun setAudioSource(source: String, promise: Promise) { val audioSourceValue = when (source.lowercase()) { "voice_communication", "voip" -> MediaRecorder.AudioSource.VOICE_COMMUNICATION "voice_recognition", "recognition" -> MediaRecorder.AudioSource.VOICE_RECOGNITION "mic", "default" -> MediaRecorder.AudioSource.MIC else -> { promise.reject("INVALID_SOURCE", "Invalid audio source: $source. Use 'voice_communication', 'voice_recognition', or 'mic'") return } } audioManager.audioSource = audioSourceValue android.util.Log.i("STTManager", "Audio source set to: $source ($audioSourceValue)") promise.resolve(true) } /** * Get the current audio source. * @returns Audio source name: "voice_communication", "voice_recognition", or "mic" */ @ReactMethod fun getAudioSource(promise: Promise) { val sourceName = when (audioManager.audioSource) { MediaRecorder.AudioSource.VOICE_COMMUNICATION -> "voice_communication" MediaRecorder.AudioSource.VOICE_RECOGNITION -> "voice_recognition" MediaRecorder.AudioSource.MIC -> "mic" else -> "unknown" } promise.resolve(sourceName) } // ===================== // Punctuation // ===================== /** * Initialize the punctuation model. * * @param configJson JSON configuration with: * - modelPath: Path to CT-Transformer ONNX model * - numThreads: Number of threads (default 2) * - provider: ONNX provider ("cpu", "nnapi", "gpu") */ @ReactMethod fun initializePunctuation(configJson: String, promise: Promise) { thread { try { val config = JSONObject(configJson) val modelPath = config.getString("modelPath") val numThreads = config.optInt("numThreads", 2) val provider = config.optString("provider", "cpu") punctuationManager = PunctuationManager() val success = punctuationManager?.initialize( modelPath = modelPath, numThreads = numThreads, provider = provider ) ?: false if (success) { android.util.Log.i("STTManager", "Punctuation initialized successfully") promise.resolve(true) } else { promise.reject("PUNCTUATION_INIT_ERROR", "Failed to initialize punctuation") } } catch (e: Exception) { android.util.Log.e("STTManager", "Punctuation init failed: ${e.message}", e) promise.reject("PUNCTUATION_INIT_ERROR", "Failed to initialize punctuation: ${e.message}", e) } } } /** * Add punctuation to the given text. */ @ReactMethod fun addPunctuation(text: String, promise: Promise) { val punct = punctuationManager if (punct == null || !punct.isInitialized()) { promise.reject("PUNCTUATION_ERROR", "Punctuation not initialized") return } thread { try { val result = punct.addPunctuation(text) promise.resolve(result) } catch (e: Exception) { promise.reject("PUNCTUATION_ERROR", "Failed to add punctuation: ${e.message}", e) } } } /** * Check if punctuation is initialized. */ @ReactMethod fun isPunctuationInitialized(promise: Promise) { promise.resolve(punctuationManager?.isInitialized() ?: false) } /** * Enable or disable punctuation processing. */ @ReactMethod fun setPunctuationEnabled(enabled: Boolean, promise: Promise) { val result = punctuationManager?.setEnabled(enabled) if (result != null) { promise.resolve(result) } else { promise.reject("NO_PUNCTUATION", "Punctuation not initialized") } } /** * Check if punctuation is currently enabled. */ @ReactMethod fun isPunctuationEnabled(promise: Promise) { promise.resolve(punctuationManager?.isEnabled ?: false) } // ===================== // Cleanup // ===================== @ReactMethod fun deinitialize() { audioManager.release() vadManager.destroy() sttManager?.release() denoiserManager?.release() diarizationManager?.release() pyannoteManager?.release() punctuationManager?.release() streamingStateManager?.reset() sttManager = null denoiserManager = null diarizationManager = null pyannoteManager = null punctuationManager = null streamingStateManager = null diarizationMode = "embedding" // Reset all state variables to allow clean re-initialization isInSpeech = false lastSentTranscript = "" lastProcessingTimeMs = 0 speechStartTime = 0 normalizeCase = "none" recognitionResults.clear() offlineAudioBuffer.clear() } }