From 248e7c9ae45c3b769bfdf9e60ab89758df866b3b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 25 Apr 2026 01:11:23 +0200 Subject: [PATCH] fix: preroll=0 wirklich sofort + Trailing-Silence gegen Wort-Cutoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Bugs die zusammen dafuer sorgen dass Worte "verschluckt" werden: 1) play() wurde bei preroll=0 erst beim ersten echten Chunk aufgerufen — nicht schon nach der Leading-Silence. Dadurch musste AudioTrack gleichzeitig Startup UND Audio abspielen, die Hardware-Anfahr-Latenz schluckt die ersten Samples. Fix: Bei prerollBytes==0 direkt nach dem silence-write play() rufen. AudioTrack haelt den Play-State und wartet auf mehr Samples — die naechsten Chunks kommen in den bereits laufenden Stream rein. 2) Nach letztem Chunk ging der Writer via return@Thread in den finally- Block. Der wartete zwar auf playbackHeadPosition >= totalFrames, aber Android's Hardware-Pipeline puffert oft noch ein paar Samples nach — stop() kam, Samples futsch. Fix: 300ms TRAILING_SILENCE am Ende schreiben. playbackHeadPosition erreicht echt bis zum Ende der echten Samples bevor die Stille abspielt. Loop umgeschrieben auf mainLoop-Label (break statt return@Thread) damit Trailing-Silence garantiert laeuft. LEADING_SILENCE auf 300ms erhoeht fuer bessere AudioTrack-Warmup-Toleranz. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/ariacockpit/PcmStreamPlayerModule.kt | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) 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 1ae7538..f84a32d 100644 --- a/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/PcmStreamPlayerModule.kt @@ -39,7 +39,10 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex 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.2 + 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" @@ -109,9 +112,9 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex val t = track ?: return@Thread try { // Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren. - val silenceBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE - if (silenceBytes > 0) { - val silence = ByteArray(silenceBytes) + 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) @@ -120,8 +123,23 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex } bytesBuffered += silence.size } - while (!writerShouldStop) { - val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) ?: run { + // 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}") + } + } + mainLoop@ while (!writerShouldStop) { + val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS) + if (data == null) { if (endRequested) { // Falls wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen if (!playbackStarted) { @@ -133,10 +151,10 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex Log.w(TAG, "play() fallback failed: ${e.message}") } } - return@Thread + break@mainLoop } - null - } ?: continue + continue@mainLoop + } // Pre-Roll Check: play() erst wenn genug gepuffert if (!playbackStarted && bytesBuffered + data.size >= prerollBytes) { @@ -157,6 +175,19 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex } 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 {