package com.sherpaonnxofflinestt.managers import android.Manifest import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.media.audiofx.AcousticEchoCanceler import androidx.core.app.ActivityCompat import com.facebook.react.bridge.ReactApplicationContext import kotlin.concurrent.thread /** * Listener interface for audio data events */ interface AudioDataListener { /** * Called when new audio data is available * @param samples Raw audio samples (Short) * @param floatSamples Floating point samples */ fun onAudioData(samples: ShortArray, floatSamples: FloatArray) } /** * Manages AudioRecord for real-time audio capture */ class AudioRecordingManager( private val reactContext: ReactApplicationContext, private val listener: AudioDataListener ) { private var audioRecord: AudioRecord? = null private var recordingThread: Thread? = null private var echoCanceler: AcousticEchoCanceler? = null var isRecording: Boolean = false private set var sampleRate: Int = 16000 // Enable AEC by default for VoIP scenarios var aecEnabled: Boolean = true // Audio source: VOICE_COMMUNICATION (7) for VoIP with AEC, VOICE_RECOGNITION (6) for pure STT // Default to VOICE_COMMUNICATION for better echo cancellation in VoIP scenarios var audioSource: Int = MediaRecorder.AudioSource.VOICE_COMMUNICATION // Chunk size: 80ms of audio at 16kHz = 1280 samples private val chunkSizeMs: Int = 80 private val chunkSize: Int get() = sampleRate * chunkSizeMs / 1000 /** * Enable Acoustic Echo Cancellation for the current AudioRecord session */ private fun enableAEC() { val record = audioRecord ?: return if (!AcousticEchoCanceler.isAvailable()) { android.util.Log.w("AudioRecordingManager", "AcousticEchoCanceler not available on this device") return } try { val sessionId = record.audioSessionId echoCanceler = AcousticEchoCanceler.create(sessionId) echoCanceler?.let { aec -> if (!aec.enabled) { aec.enabled = true } android.util.Log.i("AudioRecordingManager", "AEC enabled for session $sessionId") } ?: run { android.util.Log.w("AudioRecordingManager", "Failed to create AcousticEchoCanceler") } } catch (e: Exception) { android.util.Log.e("AudioRecordingManager", "Error enabling AEC: ${e.message}", e) } } /** * Release the echo canceler */ private fun releaseAEC() { try { echoCanceler?.release() echoCanceler = null android.util.Log.i("AudioRecordingManager", "AEC released") } catch (e: Exception) { android.util.Log.e("AudioRecordingManager", "Error releasing AEC: ${e.message}", e) } } /** * Check if microphone permission is granted */ fun hasPermission(): Boolean { return ActivityCompat.checkSelfPermission( reactContext, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED } /** * Start audio recording * @return true if recording started successfully */ fun startRecording(): Boolean { if (isRecording) { android.util.Log.w("AudioRecordingManager", "Already recording") return false } if (!hasPermission()) { android.util.Log.e("AudioRecordingManager", "Microphone permission not granted") return false } return try { val minBufferSize = AudioRecord.getMinBufferSize( sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) // Use larger buffer for stable recording val recordBufferSize = maxOf(minBufferSize * 2, sampleRate / 2) // Create AudioRecord with configurable audio source // VOICE_COMMUNICATION (7): VoIP with AEC support // VOICE_RECOGNITION (6): Pure STT without VoIP optimizations audioRecord = AudioRecord( audioSource, sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, recordBufferSize ) android.util.Log.i("AudioRecordingManager", "AudioRecord created with source=$audioSource") // Enable Acoustic Echo Cancellation if available and requested if (aecEnabled) { enableAEC() } isRecording = true recordingThread = thread(name = "AudioRecordingThread") { val buffer = ShortArray(chunkSize) audioRecord?.startRecording() android.util.Log.i("AudioRecordingManager", "Recording started (sampleRate=$sampleRate, chunkSize=$chunkSize)") while (isRecording) { val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 if (read > 0) { val samples = buffer.copyOf(read) val floatSamples = FloatArray(read) { samples[it] / 32768.0f } listener.onAudioData(samples, floatSamples) } } audioRecord?.stop() android.util.Log.i("AudioRecordingManager", "Recording stopped") } true } catch (e: Exception) { android.util.Log.e("AudioRecordingManager", "Failed to start recording: ${e.message}", e) isRecording = false false } } /** * Stop audio recording * @param timeout Maximum time to wait for thread to finish (ms) */ fun stopRecording(timeout: Long = 2000) { if (!isRecording) { android.util.Log.w("AudioRecordingManager", "Not recording") return } isRecording = false try { recordingThread?.join(timeout) recordingThread = null // Release AEC before releasing AudioRecord releaseAEC() audioRecord?.release() audioRecord = null android.util.Log.i("AudioRecordingManager", "Recording stopped and released") } catch (e: Exception) { android.util.Log.e("AudioRecordingManager", "Error stopping recording: ${e.message}", e) } } /** * Release all resources */ fun release() { stopRecording() } }