From a538f99bf02c133b9ef40328d0379eb23ba46e4a Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 21 Oct 2026 00:13:01 +0200 Subject: [PATCH] Expose viewer wake signals and complete audio independently on Android --- .../main/java/com/utun/chat/data/NativeLib.kt | 12 +++++-- .../com/utun/chat/data/StreamAudioMixer.kt | 5 ++- .../jni_bridge/android_stream_jni.c | 34 ++++++++++++++++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index e6e6dcb4..dd689a53 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -215,10 +215,14 @@ object NativeLib { fun streamTxStats(id: Long): LongArray? = nativeStreamTxStats(id) fun streamSetAudioEnabled(id: Long, enabled: Boolean): Boolean = nativeStreamSetAudioEnabled(id, enabled) fun streamStop(id: Long) { nativeStreamStop(id) } - fun streamPull(id: Long, positionUs: Long = -1, wantVideo: Boolean = true, audioAheadUs: Long = 80000): ByteArray? = - nativeStreamPull(id, positionUs, wantVideo, audioAheadUs) + fun streamPull(id: Long, positionUs: Long = -1, video: Int = 1, audioAheadUs: Long = 80000, records: Int = 1): ByteArray? = + nativeStreamPull(id, positionUs, video, audioAheadUs, records) fun streamStatus(id: Long): LongArray? = nativeStreamStatus(id) fun streamResync(id: Long): Boolean = nativeStreamResync(id) + external fun streamSignalCreate(id: Long): Long + external fun streamSignalNotify(handle: Long) + external fun streamSignalWait(handle: Long, timeoutMs: Int): Int + external fun streamSignalClose(handle: Long) fun streamPlaybackCreate(id: Long, pts: Long, audio: Boolean): Long = nativeStreamPlaybackCreate(id, pts, audio) fun streamPlaybackFeed(handle: Long, pts: Long, packet: ByteArray): Int = nativeStreamPlaybackFeed(handle, pts, packet) fun streamPlaybackPull(handle: Long, frames: Int, limit: Long): ShortArray? = nativeStreamPlaybackPull(handle, frames, limit) @@ -227,6 +231,7 @@ object NativeLib { nativeStreamPlaybackUpdate(handle, now, played, ready, guard, starved) fun streamPlaybackFinish(handle: Long) { nativeStreamPlaybackFinish(handle) } fun streamPlaybackEmpty(handle: Long): Boolean = nativeStreamPlaybackEmpty(handle) + fun streamPlaybackNeedsInput(handle: Long): Boolean = nativeStreamPlaybackNeedsInput(handle) fun streamPlaybackClose(handle: Long) { nativeStreamPlaybackClose(handle) } fun streamOpusCreate(encode: Boolean): Long = nativeStreamOpusCreate(encode) fun streamOpusEncode(handle: Long, pcm: ShortArray, compressor: RadioCompressorSettings): ByteArray? = @@ -241,7 +246,7 @@ object NativeLib { private external fun nativeStreamTxStats(id: Long): LongArray? private external fun nativeStreamSetAudioEnabled(id: Long, enabled: Boolean): Boolean private external fun nativeStreamStop(id: Long) - private external fun nativeStreamPull(id: Long, position: Long, wantVideo: Boolean, audioAhead: Long): ByteArray? + private external fun nativeStreamPull(id: Long, position: Long, video: Int, audioAhead: Long, records: Int): ByteArray? private external fun nativeStreamStatus(id: Long): LongArray? private external fun nativeStreamResync(id: Long): Boolean private external fun nativeStreamPlaybackCreate(id: Long, pts: Long, audio: Boolean): Long @@ -252,6 +257,7 @@ object NativeLib { guard: Long, starved: Boolean): LongArray? private external fun nativeStreamPlaybackFinish(handle: Long) private external fun nativeStreamPlaybackEmpty(handle: Long): Boolean + private external fun nativeStreamPlaybackNeedsInput(handle: Long): Boolean private external fun nativeStreamPlaybackClose(handle: Long) private external fun nativeStreamOpusCreate(encode: Boolean): Long private external fun nativeStreamOpusEncode(handle: Long, pcm: ShortArray, compressor: Boolean, maxGainDb: Int, riseRate: Int): ByteArray? diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioMixer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioMixer.kt index b87ed90d..5013fdd6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioMixer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioMixer.kt @@ -76,7 +76,7 @@ internal object StreamAudioMixer { /** Независимые Opus/SoundTouch, темп и PTS. Video worker и mixer сериализуют только свой DSP. * Каждый 10ms блок общего выхода отражён в своей шкале, включая тишину при starvation/hold. * Reset создаёт новый Input с новой origin; AudioTrack и остальные источники продолжают играть. */ -internal class StreamMixedPlayback(val id: Long, pts: Long, private val audioEnabled: Boolean) { +internal class StreamMixedPlayback(val id: Long, pts: Long, private val audioEnabled: Boolean, private val wake: () -> Unit) { private val lock = Any() private var handle = NativeLib.streamPlaybackCreate(id, pts, audioEnabled).also { check(it != 0L) } private val timeline = StreamMixTimeline() @@ -91,6 +91,7 @@ internal class StreamMixedPlayback(val id: Long, pts: Long, private val audioEna val leadUs: Long get() = if (audioEnabled) StreamAudioMixer.output.leadUs else 0 val empty: Boolean get() = synchronized(lock) { handle == 0L || NativeLib.streamPlaybackEmpty(handle) } + val needsInput: Boolean get() = synchronized(lock) { handle != 0L && NativeLib.streamPlaybackNeedsInput(handle) } val drained: Boolean get() = synchronized(lock) { !audioEnabled || (ended && (endFrame?.let { timeline.played(StreamAudioMixer.output.played) >= it } ?: (timeline.frames == 0L && NativeLib.streamPlaybackEmpty(handle)))) @@ -123,9 +124,11 @@ internal class StreamMixedPlayback(val id: Long, pts: Long, private val audioEna if (ended && endFrame == null && NativeLib.streamPlaybackEmpty(handle)) endFrame = timeline.frames + count if (count < buffer.frames) NativeLib.streamPlaybackSilence(handle, buffer.frames - count) timeline.append(firstOutputFrame, buffer.frames) + wake() true } catch (e: Exception) { outputFailure = e.message ?: "Audio mixing failed" + wake() LogManager.addLog("ERROR", "stream", "mixer input failed id=$id: ${e.message}") false } diff --git a/tools/chatgui-android/jni_bridge/android_stream_jni.c b/tools/chatgui-android/jni_bridge/android_stream_jni.c index 43de0118..38208e75 100644 --- a/tools/chatgui-android/jni_bridge/android_stream_jni.c +++ b/tools/chatgui-android/jni_bridge/android_stream_jni.c @@ -79,9 +79,9 @@ JNIEXPORT jlongArray JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamTxSta } JNIEXPORT jbyteArray JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPull( - JNIEnv *env, jobject obj, jlong id, jlong position, jboolean want_video, jlong audio_ahead) { + JNIEnv *env, jobject obj, jlong id, jlong position, jint video, jlong audio_ahead, jint records) { (void)obj; - if (android_stream_read_request((uint64_t)id, position, want_video != JNI_FALSE, audio_ahead) < 0) return NULL; + if (android_stream_read_request((uint64_t)id, position, (unsigned)video, audio_ahead, (unsigned)records) < 0) return NULL; size_t length = 0; uint8_t *data = android_stream_pull((uint64_t)id, &length); if (!data) return NULL; jbyteArray output = (*env)->NewByteArray(env, (jsize)length); @@ -106,12 +106,26 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamResync( (void)env; (void)obj; return android_stream_resync((uint64_t)id) == 0; } +JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_streamSignalCreate(JNIEnv *env, jobject obj, jlong id) { + (void)env; (void)obj; return (jlong)(intptr_t)android_stream_signal_create((uint64_t)id); +} +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_streamSignalNotify(JNIEnv *env, jobject obj, jlong handle) { + (void)env; (void)obj; android_stream_signal_notify((struct android_stream_signal *)(intptr_t)handle); +} +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_streamSignalWait(JNIEnv *env, jobject obj, jlong handle, jint ms) { + (void)env; (void)obj; return android_stream_signal_wait((struct android_stream_signal *)(intptr_t)handle, (unsigned)ms); +} +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_streamSignalClose(JNIEnv *env, jobject obj, jlong handle) { + (void)env; (void)obj; android_stream_signal_destroy((struct android_stream_signal *)(intptr_t)handle); +} + struct android_stream_playback { struct stream_playback_clock clock; struct stream_playback_state state; struct stream_playback_audio *audio; int64_t position, time_us; int finished; + uint64_t end_frame; }; JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlaybackCreate( @@ -145,6 +159,8 @@ JNIEXPORT jshortArray JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlay if (limit < 0) return NULL; int16_t pcm[8192 * 2]; int count = stream_playback_audio_pull(p->audio, pcm, (unsigned)frames, limit); + if (p->finished && !p->end_frame && stream_playback_audio_empty(p->audio)) + p->end_frame = stream_playback_audio_written(p->audio) + 1; /* 0 — конец ещё не поставлен устройству. */ if (count <= 0) return NULL; jshortArray output = (*env)->NewShortArray(env, count * 2); if (output) (*env)->SetShortArrayRegion(env, output, 0, count * 2, pcm); @@ -157,11 +173,14 @@ JNIEXPORT jlongArray JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlayb (void)obj; struct android_stream_playback *p = (struct android_stream_playback *)(intptr_t)handle; if (!p || now < 0 || played < 0 || ready < 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI playback clock invalid input"); return NULL; } int64_t pending = 0; - if (p->audio) { - p->position = stream_playback_audio_position(p->audio, (uint64_t)played); + int audio_done = p->finished && p->end_frame && (uint64_t)played >= p->end_frame - 1; + if (p->audio && !audio_done) { + int64_t position = stream_playback_audio_position(p->audio, (uint64_t)played); + if (position > p->position) p->position = position; pending = stream_playback_audio_pending_gain(p->audio, (uint64_t)played); if (!p->finished && stream_playback_audio_starved(p->audio, (uint64_t)played)) starved = JNI_TRUE; - } else if (p->time_us >= 0 && !starved) { + } + if ((!p->audio || audio_done) && p->time_us >= 0 && !starved) { int64_t elapsed = now - p->time_us; if (elapsed < 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI playback wall clock regressed"); return NULL; } int64_t proposed = p->position + elapsed * p->clock.tempo_milli / 1000; @@ -194,6 +213,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlaybackFin if (p) { p->finished = 1; stream_playback_audio_finish(p->audio); } } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlaybackNeedsInput(JNIEnv *env, jobject obj, jlong handle) { + (void)env; (void)obj; struct android_stream_playback *p = (struct android_stream_playback *)(intptr_t)handle; + return p && stream_playback_audio_needs_input(p->audio); +} + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlaybackEmpty(JNIEnv *env, jobject obj, jlong handle) { (void)env; (void)obj; struct android_stream_playback *p = (struct android_stream_playback *)(intptr_t)handle; return !p || stream_playback_audio_empty(p->audio);