diff --git a/tools/chatgui-android/audio-probe/build.gradle.kts b/tools/chatgui-android/audio-probe/build.gradle.kts new file mode 100644 index 00000000..f2c46977 --- /dev/null +++ b/tools/chatgui-android/audio-probe/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { id("com.android.application") } + +android { + namespace = "com.utun.audioprobe" + compileSdk = 36 + ndkVersion = "29.0.14206865" + defaultConfig { + applicationId = "com.utun.audioprobe" + minSdk = 28 + targetSdk = 36 + versionCode = 1 + versionName = "1" + ndk { abiFilters += "arm64-v8a" } + externalNativeBuild { cmake { arguments("-DANDROID_STL=c++_static") } } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") } } +} diff --git a/tools/chatgui-android/audio-probe/src/main/AndroidManifest.xml b/tools/chatgui-android/audio-probe/src/main/AndroidManifest.xml new file mode 100644 index 00000000..64915f63 --- /dev/null +++ b/tools/chatgui-android/audio-probe/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tools/chatgui-android/audio-probe/src/main/cpp/CMakeLists.txt b/tools/chatgui-android/audio-probe/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..e767e49c --- /dev/null +++ b/tools/chatgui-android/audio-probe/src/main/cpp/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.22) +project(call_audio_probe LANGUAGES C CXX) +get_filename_component(UTUN_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../.." ABSOLUTE) +add_library(call_audio_probe SHARED probe.cpp "${UTUN_ROOT}/lib/debug_config.c") +target_include_directories(call_audio_probe PRIVATE "${UTUN_ROOT}/lib") +target_compile_features(call_audio_probe PRIVATE cxx_std_17) +target_compile_options(call_audio_probe PRIVATE -Wall -Wextra -fvisibility=hidden -ffunction-sections -fdata-sections) +target_link_options(call_audio_probe PRIVATE -Wl,--gc-sections) +target_link_libraries(call_audio_probe aaudio log dl) diff --git a/tools/chatgui-android/audio-probe/src/main/cpp/probe.cpp b/tools/chatgui-android/audio-probe/src/main/cpp/probe.cpp new file mode 100644 index 00000000..f2f5ac65 --- /dev/null +++ b/tools/chatgui-android/audio-probe/src/main/cpp/probe.cpp @@ -0,0 +1,118 @@ +#include "debug_config.h" +#include +#include +#include +#include +#include +#include +#include + +/* Один worker Java владеет stream; callbacks только заполняют тишину и публикуют счётчики. */ +struct Probe { + AAudioStream* stream = nullptr; + int channels = 0; + std::atomic error{0}, callbacks{0}, callbackFrames{0}; + int previousError = 0; +}; +static Probe probe; + +static void log_hook(int level, const char*, const char* message) { + int priority = level == DEBUG_LEVEL_ERROR ? ANDROID_LOG_ERROR : level == DEBUG_LEVEL_WARN ? ANDROID_LOG_WARN : ANDROID_LOG_INFO; + __android_log_write(priority, "AudioProbe", message); +} + +extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM*, void*) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_enable_console(0); debug_set_log_hook(log_hook); + return JNI_VERSION_1_6; +} + +static aaudio_data_callback_result_t data_callback(AAudioStream*, void* context, void* output, int32_t frames) { + auto* p = static_cast(context); + std::memset(output, 0, size_t(frames) * p->channels * sizeof(int16_t)); + p->callbackFrames.store(frames, std::memory_order_relaxed); + p->callbacks.fetch_add(1, std::memory_order_relaxed); + return AAUDIO_CALLBACK_RESULT_CONTINUE; +} + +static void error_callback(AAudioStream*, void* context, aaudio_result_t error) { + static_cast(context)->error.store(error, std::memory_order_relaxed); +} + +static bool check_result(const char* operation, aaudio_result_t result) { + if (result >= 0) return true; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "probe operation=%s result=%d reason=%s", operation, result, AAudio_convertResultToText(result)); + return false; +} + +/* Только после выхода callback: requestStop/close выполняет worker, не callback ошибки. */ +extern "C" JNIEXPORT void JNICALL Java_com_utun_audioprobe_ProbeActivity_closeNative(JNIEnv*, jclass) { + if (!probe.stream) return; + check_result("stop", AAudioStream_requestStop(probe.stream)); + check_result("close", AAudioStream_close(probe.stream)); + probe.stream = nullptr; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "probe closed"); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_com_utun_audioprobe_ProbeActivity_openNative( + JNIEnv*, jclass, jboolean exclusive, jint channels) { + if (probe.stream || (channels != 1 && channels != 2)) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "probe invalid open stream=%p channels=%d", probe.stream, channels); + return JNI_FALSE; + } + probe.channels = channels; probe.error = 0; probe.callbacks = 0; probe.callbackFrames = 0; probe.previousError = 0; + AAudioStreamBuilder* builder = nullptr; + if (!check_result("builder", AAudio_createStreamBuilder(&builder))) return JNI_FALSE; + AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT); + AAudioStreamBuilder_setUsage(builder, AAUDIO_USAGE_VOICE_COMMUNICATION); + AAudioStreamBuilder_setContentType(builder, AAUDIO_CONTENT_TYPE_SPEECH); + AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16); + AAudioStreamBuilder_setSampleRate(builder, 48000); + AAudioStreamBuilder_setChannelCount(builder, channels); + AAudioStreamBuilder_setSharingMode(builder, exclusive ? AAUDIO_SHARING_MODE_EXCLUSIVE : AAUDIO_SHARING_MODE_SHARED); + AAudioStreamBuilder_setPerformanceMode(builder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); + AAudioStreamBuilder_setDataCallback(builder, data_callback, &probe); + AAudioStreamBuilder_setErrorCallback(builder, error_callback, &probe); + aaudio_result_t opened = AAudioStreamBuilder_openStream(builder, &probe.stream); + check_result("delete builder", AAudioStreamBuilder_delete(builder)); + if (!check_result("open", opened)) return JNI_FALSE; + if (AAudioStream_getFormat(probe.stream) != AAUDIO_FORMAT_PCM_I16 || AAudioStream_getChannelCount(probe.stream) != channels) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "probe unexpected format/channel count"); + check_result("close invalid", AAudioStream_close(probe.stream)); probe.stream = nullptr; + return JNI_FALSE; + } + int burst = AAudioStream_getFramesPerBurst(probe.stream); + int rate = AAudioStream_getSampleRate(probe.stream); + int wanted = std::max(rate / 50, burst * 2); + int actual = AAudioStream_setBufferSizeInFrames(probe.stream, wanted); + check_result("buffer", actual); + // Экспорт доступен не во всех версиях NDK; неизвестность не выдаём за отказ MMAP. + auto is_mmap = reinterpret_cast(dlsym(RTLD_DEFAULT, "AAudioStream_isMMapUsed")); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "probe opened exclusiveRequested=%d sharingActual=%d channels=%d rate=%d " + "performance=%d mmap=%d burst=%d bufferRequested=%d bufferActual=%d capacity=%d device=%d", + exclusive, AAudioStream_getSharingMode(probe.stream), channels, rate, + AAudioStream_getPerformanceMode(probe.stream), is_mmap ? int(is_mmap(probe.stream)) : -1, + burst, wanted, actual, AAudioStream_getBufferCapacityInFrames(probe.stream), AAudioStream_getDeviceId(probe.stream)); + if (!check_result("start", AAudioStream_requestStart(probe.stream))) { + check_result("close failed start", AAudioStream_close(probe.stream)); probe.stream = nullptr; + return JNI_FALSE; + } + return JNI_TRUE; +} + +/* Timestamp опрашивается вне realtime callback. Java одинаково агрегирует AAudio и AudioTrack. */ +extern "C" JNIEXPORT void JNICALL Java_com_utun_audioprobe_ProbeActivity_sampleNative(JNIEnv* env, jclass, jlongArray output) { + if (!probe.stream || env->GetArrayLength(output) != 10) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "probe invalid sample"); return; + } + auto* s = probe.stream; + int64_t frame = -1, stamp = -1; + int64_t written = AAudioStream_getFramesWritten(s); + int result = AAudioStream_getTimestamp(s, CLOCK_MONOTONIC, &frame, &stamp); + int error = probe.error.load(std::memory_order_relaxed); + if (error && error != probe.previousError) { + check_result("callback", error); probe.previousError = error; + } + jlong values[10] = {written, frame, stamp, result, AAudioStream_getSampleRate(s), AAudioStream_getXRunCount(s), + AAudioStream_getDeviceId(s), error, probe.callbacks.load(), probe.callbackFrames.load()}; + env->SetLongArrayRegion(output, 0, 10, values); +} diff --git a/tools/chatgui-android/audio-probe/src/main/java/com/utun/audioprobe/ProbeActivity.java b/tools/chatgui-android/audio-probe/src/main/java/com/utun/audioprobe/ProbeActivity.java new file mode 100644 index 00000000..56a02fb3 --- /dev/null +++ b/tools/chatgui-android/audio-probe/src/main/java/com/utun/audioprobe/ProbeActivity.java @@ -0,0 +1,247 @@ +package com.utun.audioprobe; + +import android.Manifest; +import android.app.Activity; +import android.content.pm.PackageManager; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTimestamp; +import android.media.AudioTrack; +import android.media.MediaRecorder; +import android.os.Build; +import android.os.Bundle; +import android.os.SystemClock; +import android.util.Log; +import android.view.WindowManager; +import android.widget.TextView; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** Изолированная проба: только тишина на выходе, захват сразу отбрасывается. Основное приложение не вызывается. */ +public final class ProbeActivity extends Activity { + private static final String TAG = "AudioProbe"; + static { System.loadLibrary("call_audio_probe"); } + private static native boolean openNative(boolean exclusive, int channels); + private static native void sampleNative(long[] out); + private static native void closeNative(); + private volatile boolean cancelled; + private AudioManager audio; + private TextView status; + private final AtomicInteger captureError = new AtomicInteger(); + private final AtomicLong captured = new AtomicLong(); + + @Override public void onCreate(Bundle saved) { + super.onCreate(saved); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + status = new TextView(this); status.setTextSize(18); status.setPadding(30, 100, 30, 30); setContentView(status); + status.setText("uTun audio probe\nSilent output; microphone data discarded.\nResults: adb logcat -s AudioProbe"); + audio = getSystemService(AudioManager.class); + setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "RECORD_AUDIO permission required; install debug APK with adb install -g"); + status.append("\nMicrophone permission missing."); return; + } + new Thread(this::runProbe, "audio-probe-owner").start(); + } + + @Override protected void onStop() { cancelled = true; super.onStop(); } + + private void show(String text) { + Log.i(TAG, text); + runOnUiThread(() -> { if (!isDestroyed()) status.setText(text + "\nResults: adb logcat -s AudioProbe"); }); + } + + private void runProbe() { + int previousMode = audio.getMode(); + if (previousMode != AudioManager.MODE_NORMAL) { + Log.e(TAG, "probe refused: audio mode=" + previousMode + "; finish other calls first"); return; + } + AudioRecord record = null; + Thread capture = null; + try { + audio.setMode(AudioManager.MODE_IN_COMMUNICATION); + int min = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); + if (min <= 0) throw new IllegalStateException("record minimum=" + min); + record = new AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, 48000, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, Math.max(min * 2, 3840)); + if (record.getState() != AudioRecord.STATE_INITIALIZED) throw new IllegalStateException("record not initialized"); + record.startRecording(); + if (record.getRecordingState() != AudioRecord.RECORDSTATE_RECORDING) throw new IllegalStateException("record not started"); + AudioRecord input = record; + capture = new Thread(() -> { + short[] discard = new short[960]; + while (!cancelled) { + int n = input.read(discard, 0, discard.length, AudioRecord.READ_BLOCKING); + if (n <= 0) { + if (!cancelled) { captureError.set(n == 0 ? -1 : n); Log.e(TAG, "capture read=" + n); } + break; + } + captured.addAndGet(n); + } + }, "audio-probe-capture"); + capture.start(); + String api = getIntent().getStringExtra("api"); + int channels = getIntent().getIntExtra("channels", 0); + if (api == null || api.equals("track")) trial("track-mono", 0, 1); + if (api == null || api.equals("aaudio")) { + for (int c : new int[]{1, 2}) { + if (channels != 0 && channels != c) continue; + for (int mode : new int[]{1, 2}) trial("aaudio-" + (mode == 1 ? "shared" : "exclusive") + "-ch" + c, mode, c); + } + } + show("probe complete captured=" + captured.get() + " captureError=" + captureError.get() + " cancelled=" + cancelled); + } catch (Exception e) { + Log.e(TAG, "probe failed", e); show("probe failed: " + e); + } finally { + cancelled = true; + if (record != null) { + try { record.stop(); } catch (Exception e) { Log.e(TAG, "capture stop failed", e); } + if (capture != null) { + try { capture.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); Log.e(TAG, "capture join", e); } + } + record.release(); + } + if (Build.VERSION.SDK_INT >= 31) audio.clearCommunicationDevice(); + else audio.setSpeakerphoneOn(false); + audio.setMode(previousMode); + Log.i(TAG, "probe resources released mode=" + audio.getMode()); + } + } + + private void route(int type) throws InterruptedException { + if (cancelled) return; + if (Build.VERSION.SDK_INT >= 31) { + AudioDeviceInfo wanted = null; + for (AudioDeviceInfo d : audio.getAvailableCommunicationDevices()) if (d.getType() == type) wanted = d; + if (wanted == null || !audio.setCommunicationDevice(wanted)) throw new IllegalStateException("route unavailable type=" + type); + long deadline = SystemClock.elapsedRealtime() + 2000; + while (!cancelled && SystemClock.elapsedRealtime() < deadline) { + AudioDeviceInfo actual = audio.getCommunicationDevice(); + if (actual != null && actual.getId() == wanted.getId()) { + Log.i(TAG, "route confirmed type=" + type + " id=" + actual.getId() + " mode=" + audio.getMode()); + Thread.sleep(500); return; + } + Thread.sleep(50); + } + throw new IllegalStateException("route confirmation timeout type=" + type); + } + audio.setSpeakerphoneOn(type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + Thread.sleep(500); + } + + private interface Output extends AutoCloseable { + void sample(long[] out); + void close(); + } + + private Output open(int mode, int channels) { + if (mode == 0) return new TrackOutput(); + if (!openNative(mode == 2, channels)) throw new IllegalStateException("AAudio open rejected"); + return new Output() { + public void sample(long[] out) { sampleNative(out); } + public void close() { closeNative(); } + }; + } + + private void trial(String name, int mode, int channels) throws InterruptedException { + if (cancelled) return; + show("trial " + name); + try { + route(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + try (Output output = open(mode, channels)) { + measure(output, name + "/speaker-new"); + route(AudioDeviceInfo.TYPE_BUILTIN_EARPIECE); + measure(output, name + "/earpiece-existing"); + } + if (!cancelled) try (Output output = open(mode, channels)) { measure(output, name + "/earpiece-new"); } + } catch (RuntimeException e) { Log.e(TAG, "trial failed name=" + name, e); } + } + + private void measure(Output output, String label) throws InterruptedException { + long start = System.nanoTime(), report = start, captureStart = captured.get(); + long duration = Math.max(4, Math.min(30, getIntent().getIntExtra("seconds", 8))) * 1_000_000_000L; + long[] row = new long[10]; + long low = Long.MAX_VALUE, high = Long.MIN_VALUE, sum = 0; + int count = 0, invalid = 0; + long underStart = -1, previousUnder = -1; + while (!cancelled && System.nanoTime() - start < duration) { + output.sample(row); + long now = System.nanoTime(); + if (underStart < 0) underStart = row[5]; + if (previousUnder >= 0 && row[5] > previousUnder) Log.w(TAG, "underrun label=" + label + " delta=" + (row[5] - previousUnder)); + previousUnder = row[5]; + if (row[7] != 0 || captureError.get() != 0) { + Log.e(TAG, "stream failed label=" + label + " output=" + row[7] + " capture=" + captureError.get()); break; + } + long delay = row[4] > 0 ? (row[2] - now) / 1000 + (row[0] - row[1]) * 1_000_000 / row[4] : -1; + boolean valid = row[3] == 0 && row[1] >= 0 && row[1] <= row[0] && row[2] > 0 && + now - row[2] >= -200_000_000 && now - row[2] <= 500_000_000 && delay >= 0 && delay <= 2_000_000; + if (now - start >= 2_000_000_000L) { + if (valid) { low = Math.min(low, delay); high = Math.max(high, delay); sum += delay; count++; } + else invalid++; + } + if (now - report >= 1_000_000_000L) { + Log.i(TAG, "sample label=" + label + " outputUs=" + (valid ? delay : -1) + " written=" + row[0] + + " stamp=" + row[1] + "@" + row[2] + " status=" + row[3] + " underruns=" + row[5] + + " device=" + row[6] + " callbacks=" + row[8] + " callbackFrames=" + row[9] + " captureFrames=" + captured.get()); + report = now; + } + Thread.sleep(50); + } + Log.i(TAG, "RESULT label=" + label + " samples=" + count + " invalid=" + invalid + " outputUs=" + + (count == 0 ? "unknown" : low + "/" + sum / count + "/" + high) + " underrunDelta=" + (row[5] - underStart) + + " captureFrames=" + (captured.get() - captureStart) + " streamError=" + row[7]); + } + + private final class TrackOutput implements Output { + private final AudioTrack track; + private final Thread writer; + private final AtomicLong written = new AtomicLong(); + private final AtomicInteger error = new AtomicInteger(); + private final AudioTimestamp stamp = new AudioTimestamp(); + private volatile boolean running = true; + TrackOutput() { + int min = AudioTrack.getMinBufferSize(48000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT); + if (min <= 0) throw new IllegalStateException("track minimum=" + min); + track = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build()) + .setAudioFormat(new AudioFormat.Builder().setSampleRate(48000).setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build()) + .setBufferSizeInBytes(Math.max(min * 2, 3840)).setTransferMode(AudioTrack.MODE_STREAM) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY).build(); + int actual = track.setBufferSizeInFrames(960); + if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(actual); + Log.i(TAG, "track opened requested=1 performance=" + track.getPerformanceMode() + " buffer=" + actual); + track.play(); + writer = new Thread(() -> { + short[] silence = new short[960]; + while (running && !cancelled) { + int n = track.write(silence, 0, silence.length, AudioTrack.WRITE_BLOCKING); + if (n <= 0) { + if (running && !cancelled) { error.set(n == 0 ? -1 : n); Log.e(TAG, "track write=" + n); } + break; + } + written.addAndGet(n); + } + }, "audio-probe-track"); + writer.start(); + } + public void sample(long[] out) { + out[0] = written.get(); + boolean ok = track.getTimestamp(stamp); + out[1] = ok ? stamp.framePosition : -1; out[2] = ok ? stamp.nanoTime : -1; out[3] = ok ? 0 : -1; + out[4] = 48000; out[5] = track.getUnderrunCount(); + AudioDeviceInfo route = track.getRoutedDevice(); out[6] = route == null ? -1 : route.getId(); + out[7] = error.get(); out[8] = -1; out[9] = 960; + } + public void close() { + running = false; track.stop(); + try { writer.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); Log.e(TAG, "track join", e); } + track.release(); + } + } +} diff --git a/tools/chatgui-android/settings.gradle.kts b/tools/chatgui-android/settings.gradle.kts index e74324cc..c9e01aad 100644 --- a/tools/chatgui-android/settings.gradle.kts +++ b/tools/chatgui-android/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { rootProject.name = "chatgui-android" include(":app") +include(":audio-probe")