diff --git a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt index 1b484d0..7b1f9f4 100644 --- a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt +++ b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt @@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication { add(ApkInstallerPackage()) add(AudioFocusPackage()) add(PcmStreamPlayerPackage()) + add(PcmStreamRecorderPackage()) add(OpenWakeWordPackage()) add(PhoneCallPackage()) add(BackgroundAudioPackage()) diff --git a/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderModule.kt b/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderModule.kt new file mode 100644 index 0000000..893d05d --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderModule.kt @@ -0,0 +1,246 @@ +package com.ariacockpit + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import android.os.PowerManager +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.util.concurrent.atomic.AtomicBoolean + +/** + * PCM-Streaming-Recorder fuer die Streaming-Whisper-Bridge. + * + * Oeffnet AudioRecord (16 kHz mono s16le, VOICE_COMMUNICATION-Source mit + * automatischer AEC + NS) und feuert ~200ms-Chunks als base64-Event + * "PcmStreamChunk" an die JS-Bridge. + * + * audio.ts schickt die Chunks via RVS direkt an die whisper-bridge die + * dort einen ML-Endpointer laufen laesst — kein dB-VAD-Tuning mehr. + * + * Mic-Ownership: dieser Recorder DARF nicht gleichzeitig mit + * OpenWakeWord laufen — beide wollen AudioRecord vom MIC. Caller + * muss OpenWakeWord.stop() vor start() hier aufrufen und nach stop() + * hier wieder OpenWakeWord.start() — genau wie's audio.ts ohnehin + * macht. + * + * Events: + * "PcmStreamChunk" { pcm: base64-s16le, seq: N, ts: epochMs } + * "PcmStreamError" { error: string } + */ +class PcmStreamRecorderModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "PcmStreamRecorder" + + companion object { + private const val TAG = "PcmStreamRecorder" + private const val SAMPLE_RATE = 16000 + // 200ms-Chunks: gross genug fuer wenig RVS-Overhead, klein genug damit + // der Endpointer im Whisper-Bridge granular sieht. 200ms ist auch das + // Whisper-VAD-Frame-Hop — passt also zu downstream. + private const val CHUNK_SAMPLES = 3200 // 200ms @ 16 kHz + private const val BYTES_PER_SAMPLE = 2 // s16 + private const val CHUNK_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE + } + + private var audioRecord: AudioRecord? = null + private val running = AtomicBoolean(false) + private var captureThread: Thread? = null + + private var aec: AcousticEchoCanceler? = null + private var ns: NoiseSuppressor? = null + private var agc: AutomaticGainControl? = null + + // PARTIAL_WAKE_LOCK damit der JS-Bridge-Loop weiterlaeuft auch wenn das + // Display aus ist — sonst sammeln sich zwar Chunks in der nativen Queue + // an, aber emit() landet nicht zeitnah in JS und der Whisper-Bridge + // bekommt die Audio-Chunks erst beim App-Foreground-Resume. + private var wakeLock: PowerManager.WakeLock? = null + + private var seq: Long = 0L + + @ReactMethod + fun start(promise: Promise) { + if (running.get()) { + promise.resolve(true) + return + } + val perm = ContextCompat.checkSelfPermission( + reactApplicationContext, Manifest.permission.RECORD_AUDIO + ) + if (perm != PackageManager.PERMISSION_GRANTED) { + promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt") + return + } + + try { + val minBuf = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ).coerceAtLeast(CHUNK_BYTES * 4) // 4x Chunk-Size als Sicherheit + + val record = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + minBuf, + ) + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt? OpenWakeWord noch aktiv?)") + return + } + audioRecord = record + + // AEC/NS/AGC explizit anschalten — manche Geraete liefern's via + // VOICE_COMMUNICATION zwar mit, aber Belt-and-Suspenders. + try { + if (AcousticEchoCanceler.isAvailable()) { + aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") } + try { + if (NoiseSuppressor.isAvailable()) { + ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") } + try { + if (AutomaticGainControl.isAvailable()) { + agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") } + + seq = 0L + running.set(true) + record.startRecording() + + try { + val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "AriaCockpit:PcmStreamRecord").apply { + setReferenceCounted(false) + acquire(8 * 60 * 60 * 1000L) // 8h Cap + } + } catch (e: Exception) { + Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}") + } + + captureThread = Thread({ captureLoop() }, "PcmStreamRecorderCapture").apply { + isDaemon = true + start() + } + + Log.i(TAG, "Recording gestartet (16kHz mono s16le, ${CHUNK_SAMPLES} samples/chunk)") + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "start fehlgeschlagen", e) + running.set(false) + audioRecord?.release() + audioRecord = null + releaseAudioEffects() + releaseWakeLock() + promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e) + } + } + + @ReactMethod + fun stop(promise: Promise) { + running.set(false) + try { + captureThread?.join(1500) + } catch (_: InterruptedException) {} + captureThread = null + try { audioRecord?.stop() } catch (_: Exception) {} + try { audioRecord?.release() } catch (_: Exception) {} + audioRecord = null + releaseAudioEffects() + releaseWakeLock() + Log.i(TAG, "Recording gestoppt (seq=$seq Chunks gesendet)") + promise.resolve(true) + } + + @ReactMethod + fun isRecording(promise: Promise) { + promise.resolve(running.get()) + } + + private fun captureLoop() { + val buffer = ByteArray(CHUNK_BYTES) + val rec = audioRecord ?: return + try { + while (running.get()) { + var offset = 0 + // Solange lesen bis ein voller 200ms-Chunk zusammen ist. + // AudioRecord.read kann weniger als angefordert liefern. + while (offset < CHUNK_BYTES && running.get()) { + val n = rec.read(buffer, offset, CHUNK_BYTES - offset) + if (n <= 0) { + if (!running.get()) break + // Fehlerzustand — kurze Pause, dann weiter probieren + Thread.sleep(5) + continue + } + offset += n + } + if (offset < CHUNK_BYTES) break + + val b64 = Base64.encodeToString(buffer, Base64.NO_WRAP) + val ts = System.currentTimeMillis() + val params = Arguments.createMap().apply { + putString("pcm", b64) + // putLong existiert nicht in WritableMap — putDouble fuer ts/seq. + putDouble("seq", seq.toDouble()) + putDouble("ts", ts.toDouble()) + } + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("PcmStreamChunk", params) + seq++ + } + } catch (e: Exception) { + Log.e(TAG, "captureLoop crashed", e) + try { + val err = Arguments.createMap().apply { + putString("error", e.message ?: "unknown") + } + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("PcmStreamError", err) + } catch (_: Exception) {} + } + } + + private fun releaseAudioEffects() { + try { aec?.release() } catch (_: Exception) {} + try { ns?.release() } catch (_: Exception) {} + try { agc?.release() } catch (_: Exception) {} + aec = null; ns = null; agc = null + } + + private fun releaseWakeLock() { + try { + if (wakeLock?.isHeld == true) wakeLock?.release() + } catch (_: Exception) {} + wakeLock = null + } + + // Damit RCTEventEmitter den Listener-Lifecycle nicht crasht + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} +} diff --git a/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderPackage.kt b/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderPackage.kt new file mode 100644 index 0000000..4e611d2 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/PcmStreamRecorderPackage.kt @@ -0,0 +1,16 @@ +package com.ariacockpit + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PcmStreamRecorderPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(PcmStreamRecorderModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +}