package com.ariacockpit import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager import android.media.AudioTrack import android.os.Build import android.util.Base64 import android.util.Log 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.LinkedBlockingQueue /** * Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM mit Pre-Roll. * * Pre-Roll: AudioTrack wird zwar direkt gebaut und gefuttert, aber play() * wird erst aufgerufen wenn PREROLL_SECONDS Audio im Buffer ist. So hat * der Stream Zeit einen Vorrat aufzubauen — wenn XTTS mit RTF>1 rendert * (langsamer als Echtzeit), laeuft der Buffer trotzdem nicht leer. * * Flow: * JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play()) * JS: writeChunk(base64) → dekodiert, queued, Writer schreibt * Writer: spielt los sobald PREROLL erreicht ist * JS: end() → wartet bis Queue leer, schließt * JS: stop() → Hart stoppen (Cancel) */ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { private const val TAG = "PcmStreamPlayer" // Fallback wenn JS keinen Wert uebergibt. private const val DEFAULT_PREROLL_SECONDS = 3.5 // 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk. // Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer // unnoetig ist und bei kurzen Saetzen sogar stoeren kann. private const val MIN_PREROLL_SECONDS = 0.0 private const val MAX_PREROLL_SECONDS = 10.0 // Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die // ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz). private const val LEADING_SILENCE_SECONDS = 0.3 // Stille am Ende — puffert das Hardware-Flushen damit die letzten // echten Samples garantiert ausgespielt werden bevor stop() kommt. private const val TRAILING_SILENCE_SECONDS = 0.3 } override fun getName() = "PcmStreamPlayer" private var track: AudioTrack? = null private val queue = LinkedBlockingQueue() private var writerThread: Thread? = null @Volatile private var writerShouldStop = false @Volatile private var endRequested = false @Volatile private var prerollBytes: Int = 0 @Volatile private var playbackStarted = false @Volatile private var bytesBuffered: Long = 0 @Volatile private var streamBytesPerFrame: Int = 2 // mono s16le default // ── Lifecycle ── @ReactMethod fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) { try { // Alte Session beenden falls vorhanden stopInternal() // Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe). val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) { prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS) } else { DEFAULT_PREROLL_SECONDS } val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO val encoding = AudioFormat.ENCODING_PCM_16BIT val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding) val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes val prerollTarget = (bytesPerSecond * prerollSec).toInt() // Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12 // mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter // Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block // nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt. val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8) prerollBytes = prerollTarget bytesBuffered = 0 playbackStarted = false streamBytesPerFrame = channels * 2 // s16 = 2 bytes per sample val newTrack = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANT) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(), ) .setAudioFormat( AudioFormat.Builder() .setSampleRate(sampleRate) .setChannelMask(channelConfig) .setEncoding(encoding) .build(), ) .setBufferSizeInBytes(bufferSize) .setTransferMode(AudioTrack.MODE_STREAM) .build() // Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s // Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind — // bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen. // 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms newTrack.setStartThresholdInFrames(startFrames) Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)") } catch (e: Exception) { Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}") } } track = newTrack queue.clear() writerShouldStop = false endRequested = false writerThread = Thread({ val t = track ?: return@Thread try { // Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren. val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE if (leadingBytes > 0) { val silence = ByteArray(leadingBytes) var silOff = 0 while (silOff < silence.size && !writerShouldStop) { val w = t.write(silence, silOff, silence.size - silOff) if (w <= 0) break silOff += w } bytesBuffered += silence.size } // Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen, // nicht erst bei Ankunft des ersten echten Chunks. Android's // AudioTrack haelt den Play-State und wartet auf neue Samples. // So verschluckt es keine Worte wenn der erste Chunk erst // nach play()-Startup-Latenz eintrifft. if (prerollBytes == 0 && !playbackStarted) { try { t.play() playbackStarted = true Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)") } catch (e: Exception) { Log.w(TAG, "play() sofort failed: ${e.message}") } } // Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr // reinkommt, brechen wir ab (Bridge-Crash, verlorener final). // 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei // Modell-Wechsel oder kalter GPU) nicht den Stream abreissen. var idleMs = 0L val maxIdleMs = 120_000L // Zielpufferfuellung — unter diesem Wasserstand fuettern wir // Stille rein damit AudioTrack nicht underrunt waehrend die // Bridge den naechsten Satz rendert. Spotify/YouTube reagieren // sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille. val underrunGuardFrames = sampleRate / 10 // ~100ms val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill mainLoop@ while (!writerShouldStop) { val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) if (data == null) { if (endRequested) { // Falls play() noch gar nicht lief (Stream ohne data // ueberhaupt — sehr seltene Edge-Case): jetzt anstossen // damit das finally{}-Wait nicht endlos blockt. if (!playbackStarted) { try { t.play(); playbackStarted = true } catch (_: Exception) {} } break@mainLoop } // Underrun-Schutz: Stille reinfuettern wenn der AudioTrack- // Puffer leerzulaufen droht. Spotify resumed sonst nach // ~10s Pause auf eigene Faust, obwohl wir den Fokus halten. if (playbackStarted) { val framesWritten = bytesBuffered / streamBytesPerFrame val framesPlayed = t.playbackHeadPosition.toLong() val framesInBuffer = framesWritten - framesPlayed if (framesInBuffer < underrunGuardFrames) { val fillBytes = silenceFillFrames * streamBytesPerFrame val silence = ByteArray(fillBytes) var silOff = 0 while (silOff < silence.size && !writerShouldStop) { val w = t.write(silence, silOff, silence.size - silOff) if (w <= 0) break silOff += w } bytesBuffered += silence.size } } idleMs += 50L if (idleMs >= maxIdleMs) { Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet") break@mainLoop } continue@mainLoop } idleMs = 0L // play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig // Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn // play() erst gerufen wird nachdem der Buffer komplett gefuellt // ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend // der Track schon spielt — Underrun-Schutz fuettert ggf. Stille. if (!playbackStarted) { try { t.play() playbackStarted = true Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)") } catch (e: Exception) { Log.w(TAG, "play() failed: ${e.message}") } } var offset = 0 while (offset < data.size && !writerShouldStop) { val written = t.write(data, offset, data.size - offset) if (written <= 0) break offset += written } bytesBuffered += data.size } // Trailing-Silence damit die letzten echten Samples garantiert // durch das Hardware-Buffering kommen bevor stop() sie abschneidet val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE if (trailingBytes > 0 && !writerShouldStop) { val silence = ByteArray(trailingBytes) var silOff = 0 while (silOff < silence.size && !writerShouldStop) { val w = t.write(silence, silOff, silence.size - silOff) if (w <= 0) break silOff += w } bytesBuffered += silence.size } } catch (e: Exception) { Log.w(TAG, "Writer-Thread Fehler: ${e.message}") } finally { // Warten bis alle geschriebenen Samples tatsaechlich abgespielt sind, // sonst cuttet t.release() die letzten Sekunden ab. try { val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt() var lastPos = -1 var stalledCount = 0 var retried = false while (!writerShouldStop) { val pos = t.playbackHeadPosition if (pos >= totalFrames) break if (pos == lastPos) { stalledCount++ // Nach 500ms Stillstand: AudioTrack-Quirk auf manchen // Geraeten (OnePlus A12) — play() nochmal anstossen. if (stalledCount == 10 && pos == 0 && !retried) { retried = true Log.w(TAG, "playback nicht angefahren — retry play()") try { t.play() } catch (e: Exception) { Log.w(TAG, "retry play() failed: ${e.message}") } } if (stalledCount > 40) { Log.w(TAG, "playback stalled at $pos/$totalFrames — give up") break } } else { stalledCount = 0 lastPos = pos } Thread.sleep(50) } Log.i(TAG, "Playback fertig: frames=$totalFrames pos=${t.playbackHeadPosition}") } catch (_: Exception) {} try { t.stop() } catch (_: Exception) {} try { t.release() } catch (_: Exception) {} // RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt). // JS released erst JETZT den AudioFocus — sonst spielt Spotify // beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer). try { val params = Arguments.createMap() reactApplicationContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit("PcmPlaybackFinished", params) } catch (e: Exception) { Log.w(TAG, "PlaybackFinished emit failed: ${e.message}") } } }, "PcmStreamWriter").apply { start() } Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)") promise.resolve(true) } catch (e: Exception) { Log.e(TAG, "start fehlgeschlagen", e) promise.reject("START_FAILED", e.message, e) } } @ReactMethod fun writeChunk(base64Pcm: String, promise: Promise) { try { if (base64Pcm.isEmpty()) { promise.resolve(true) return } val bytes = Base64.decode(base64Pcm, Base64.DEFAULT) queue.put(bytes) promise.resolve(true) } catch (e: Exception) { promise.reject("WRITE_FAILED", e.message, e) } } /** Signalisiert: keine weiteren Chunks. Writer spielt aus, dann stoppt. * Das Promise resolved erst wenn der Writer-Thread fertig ist — * wichtig damit der Aufrufer den AudioFocus erst NACH dem letzten * abgespielten Sample wieder freigibt (sonst dreht Spotify hoch * waehrend das Pre-Roll noch ausspielt). */ @ReactMethod fun end(promise: Promise) { endRequested = true val t = writerThread if (t == null || !t.isAlive) { promise.resolve(true) return } // Im Hintergrund auf den Writer warten — kein Threading-Block fuer JS-Bridge Thread({ try { t.join(15_000) // hartes Cap, falls Writer haengt } catch (_: InterruptedException) {} promise.resolve(true) }, "PcmStreamEndWaiter").start() } /** Harter Stop (Cancel) — Queue verwerfen. */ @ReactMethod fun stop(promise: Promise) { stopInternal() promise.resolve(true) } @ReactMethod fun addListener(eventName: String) {} @ReactMethod fun removeListeners(count: Int) {} private fun stopInternal() { writerShouldStop = true endRequested = true queue.clear() writerThread?.interrupt() writerThread = null val t = track if (t != null) { // pause() + flush() vor stop() — sonst spielt der Hardware-Buffer // (200-500ms PCM-Samples) noch hörbar weiter, nachdem der User // den Mute-Button gedrückt hat. Stefan-Bug-Report: "wenn ich auf // den Mund halten Button klicke während ARIA redet stoppt sie nicht". try { t.pause() } catch (_: Exception) {} try { t.flush() } catch (_: Exception) {} try { t.stop() } catch (_: Exception) {} try { t.release() } catch (_: Exception) {} } track = null } override fun onCatalystInstanceDestroy() { stopInternal() super.onCatalystInstanceDestroy() } }