f2e643d1fb
Wenn die Bridge zwischen zwei Saetzen rendert (1-2s pro Satz auf der Gamebox-RTX 3060), kommen keine neuen PCM-Chunks rein und der AudioTrack- Buffer laeuft leer. Spotify hat eine eigene Heuristik die nach ~10s "stummer Lücke" eigenmaechtig die Wiedergabe wiederaufnimmt — auch wenn wir den AudioFocus formal noch halten. Fix: Writer-Thread fuettert Stille rein wenn der Puffer unter ~100ms faellt (~50ms pro Refill-Tick alle 50ms). AudioTrack bleibt damit durchgehend aktiv, andere Apps respektieren weiterhin den Fokus. Bonus: 30s-Idle-Cutoff falls die Bridge crashed und kein final-Marker mehr kommt — sonst wuerde der Writer-Thread ewig Stille fuettern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
331 lines
16 KiB
Kotlin
331 lines
16 KiB
Kotlin
package com.ariacockpit
|
|
|
|
import android.media.AudioAttributes
|
|
import android.media.AudioFormat
|
|
import android.media.AudioManager
|
|
import android.media.AudioTrack
|
|
import android.util.Base64
|
|
import android.util.Log
|
|
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 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<ByteArray>()
|
|
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
|
|
// Buffer muss mindestens PREROLL + etwas Spielraum fassen.
|
|
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
|
val bufferSize = (minBuf * 32).coerceAtLeast(prerollTarget * 2)
|
|
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()
|
|
|
|
// AudioTrack erstellen — play() wird erst aufgerufen wenn Pre-Roll erreicht.
|
|
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 30s nichts mehr
|
|
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
|
var idleMs = 0L
|
|
val maxIdleMs = 30_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 wir vor Pre-Roll enden (kurzer Text): trotzdem abspielen
|
|
if (!playbackStarted) {
|
|
try {
|
|
t.play()
|
|
playbackStarted = true
|
|
Log.i(TAG, "Playback gestartet VOR Pre-Roll (kurzer Text, ${bytesBuffered}B gepuffert)")
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "play() fallback failed: ${e.message}")
|
|
}
|
|
}
|
|
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
|
|
|
|
// 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
|
|
}
|
|
// 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
|
|
while (!writerShouldStop) {
|
|
val pos = t.playbackHeadPosition
|
|
if (pos >= totalFrames) break
|
|
// Safety: wenn Position 2s nicht mehr vorwaerts → AudioTrack hing
|
|
if (pos == lastPos) {
|
|
stalledCount++
|
|
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) {}
|
|
}
|
|
}, "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)
|
|
}
|
|
|
|
private fun stopInternal() {
|
|
writerShouldStop = true
|
|
endRequested = true
|
|
queue.clear()
|
|
writerThread?.interrupt()
|
|
writerThread = null
|
|
val t = track
|
|
if (t != null) {
|
|
try { t.stop() } catch (_: Exception) {}
|
|
try { t.release() } catch (_: Exception) {}
|
|
}
|
|
track = null
|
|
}
|
|
|
|
override fun onCatalystInstanceDestroy() {
|
|
stopInternal()
|
|
super.onCatalystInstanceDestroy()
|
|
}
|
|
}
|