diff --git a/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt b/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt index ee84268..bcc71ed 100644 --- a/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt @@ -13,22 +13,26 @@ import com.facebook.react.bridge.ReactMethod import java.util.concurrent.LinkedBlockingQueue /** - * Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM. + * 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 und startet Writer-Thread - * JS: writeChunk(base64) → dekodiert, queued, Writer schreibt non-blocking - * JS: end() → wartet bis Queue leer, schließt AudioTrack - * JS: stop() → Hart stoppen, Queue leeren (Cancel) - * - * Vorteil gegenüber Sound-File-Queue: - * - Keine Gap zwischen Chunks (AudioTrack puffert intern) - * - Erste Samples beginnen zu spielen sobald der erste Chunk da ist - * - Kein WAV-Header-Parsing pro Chunk + * 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" + // Sekunden Audio die VOR play()-Start gepuffert sein muessen. + // 2.5s Vorrat = genug um XTTS-Render-Pausen zwischen Chunks zu puffern. + private const val PREROLL_SECONDS = 2.5 } override fun getName() = "PcmStreamPlayer" @@ -38,6 +42,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex 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 // ── Lifecycle ── @@ -50,10 +57,13 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex 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) - // Grosszuegiger Buffer: 32x MinSize — tolerant gegen Netzwerk-Jitter und - // bursty XTTS-Delivery (Render dauert 1-3s, dann kommen alle Samples - // auf einmal). Bei 24kHz mono s16 entspricht 128KB ca. 2.7 Sekunden. - val bufferSize = (minBuf * 32).coerceAtLeast(128 * 1024) + val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes + // Buffer muss mindestens PREROLL + etwas Spielraum fassen. + val prerollTarget = (bytesPerSecond * PREROLL_SECONDS).toInt() + val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2) + prerollBytes = prerollTarget + bytesBuffered = 0 + playbackStarted = false val newTrack = AudioTrack.Builder() .setAudioAttributes( @@ -73,7 +83,7 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex .setTransferMode(AudioTrack.MODE_STREAM) .build() - newTrack.play() + // AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht. track = newTrack queue.clear() writerShouldStop = false @@ -84,15 +94,35 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex try { while (!writerShouldStop) { val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run { - if (endRequested) return@Thread + if (endRequested) { + // Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen + if (!playbackStarted) { + try { t.play() } catch (_: Exception) {} + playbackStarted = true + } + return@Thread + } null } ?: continue + + // Pre-Roll Check: play() erst wenn genug gepuffert + if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { + try { + t.play() + playbackStarted = true + Log.i(TAG, "Playback gestartet nach Pre-Roll ${bytesBuffered + data.size} Bytes") + } 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 } } catch (e: Exception) { Log.w(TAG, "Writer-Thread Fehler: ${e.message}") @@ -102,7 +132,7 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex } }, "PcmStreamWriter").apply { start() } - Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B") + Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B") promise.resolve(true) } catch (e: Exception) { Log.e(TAG, "start fehlgeschlagen", e)