70f4ff480e
Stefan-Bug-Report: wenn ich in der App auf den Mund-halten-Button klicke waehrend ARIA redet, stoppt sie nicht. Ursache: stopInternal() rief nur AudioTrack.stop() + release(). Das stoppt zwar den Track, aber der bereits in den Hardware-Buffer geschriebene PCM-Audio (200-500ms je nach Geraet) spielt noch hoerbar weiter. Fuer den User klang das so als wuerde der Button nichts tun. Fix in 2 Zeilen: AudioTrack.pause() + AudioTrack.flush() vor stop(). flush() verwirft den Hardware-Buffer-Inhalt, dadurch ist die Wiedergabe wirklich sofort still. pause() davor weil flush() laut Android-Docs nur in non-playing state safe ist. Native module ist kompiliert in app/build/tmp/kotlin-classes — APK muss neu gebaut werden damit der Fix greift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
381 lines
19 KiB
Kotlin
381 lines
19 KiB
Kotlin
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<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
|
|
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()
|
|
}
|
|
}
|