From 4b86e3ac6c96326e0d639eaa29d33955d553bdaf Mon Sep 17 00:00:00 2001 From: superturtlee <160681686+superturtlee@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:49:52 +0800 Subject: [PATCH 1/3] app: resolve clickpad left/right buttons Single-button touchpads report every physical press as a generic primary button. Use the slowest contact to identify the pressing finger and select the left or right half of the pad. Latch that choice until release so finger drift cannot strand a button state. Preserve the existing captured touchpad gesture and pointer-capture paths while forwarding the resolved Linux button events. Co-authored-by: lfdevs --- .../java/com/anland/termux/MainActivity.java | 36 ++++++++++++-- .../main/java/com/anland/termux/Touchpad.java | 49 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/anland/termux/MainActivity.java b/app/src/main/java/com/anland/termux/MainActivity.java index 9df37f1..113f764 100644 --- a/app/src/main/java/com/anland/termux/MainActivity.java +++ b/app/src/main/java/com/anland/termux/MainActivity.java @@ -128,6 +128,9 @@ public class MainActivity extends Activity private final float[] mCapturedTouchpadResolvedDelta = new float[2]; private final SparseArray mButtonDragLastX = new SparseArray<>(); private final SparseArray mButtonDragLastY = new SparseArray<>(); + // Single-button clickpads report every physical press as BUTTON_PRIMARY. Keep + // the resolved Linux button latched for the duration of that press. + private int mLastTouchpadButtonPressed = 0; private String mDisplayCutoutMode = DisplayCutoutMode.HIDE_ALL; private boolean mPipTransitionPending = false; private boolean mVirtualKeyboardVisibleBeforePip = false; @@ -1527,10 +1530,12 @@ private boolean handleCapturedPointerEvent(MotionEvent event) { sendCapturedScrollAxes(event, -1); } - if (action == MotionEvent.ACTION_CANCEL) + if (action == MotionEvent.ACTION_CANCEL) { + mLastTouchpadButtonPressed = 0; releaseAllMouseButtons(); - else - updateMouseButtonStateFromEvent(event); + } else { + updateTouchpadButtonStateFromEvent(event); + } return true; } @@ -1828,6 +1833,7 @@ private void resetCapturedTouchpadGesture() { mCapturedTouchpadLastCentroidY = 0f; mButtonDragLastX.clear(); mButtonDragLastY.clear(); + mLastTouchpadButtonPressed = 0; } private int capturedPointerViewWidth() { @@ -1950,6 +1956,30 @@ private void updateMouseButtonStateFromEvent(MotionEvent event) { updateMouseButtonState(effectiveButtonState(event)); } + /** Resolve clickpad primary presses to left/right from the slowest contact. */ + private void updateTouchpadButtonStateFromEvent(MotionEvent event) { + int action = event.getActionMasked(); + int buttonState = event.getButtonState(); + if (action == MotionEvent.ACTION_BUTTON_PRESS) { + int button = mCapturedTouchpad != null + ? mCapturedTouchpad.clickpadButton(event) : 0x110; + mLastTouchpadButtonPressed = button == 0x111 + ? MotionEvent.BUTTON_SECONDARY : MotionEvent.BUTTON_PRIMARY; + buttonState &= ~(MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY); + buttonState |= mLastTouchpadButtonPressed; + } else if (action == MotionEvent.ACTION_BUTTON_RELEASE) { + // Release the button chosen at press time even if the finger drifted. + buttonState &= ~(MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY); + mLastTouchpadButtonPressed = 0; + } else if (mLastTouchpadButtonPressed != 0) { + buttonState &= ~(MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY); + buttonState |= mLastTouchpadButtonPressed; + } else { + buttonState = effectiveButtonState(event); + } + updateMouseButtonState(buttonState); + } + private void updateMouseButtons(MotionEvent event) { if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) releaseAllMouseButtons(); diff --git a/app/src/main/java/com/anland/termux/Touchpad.java b/app/src/main/java/com/anland/termux/Touchpad.java index 5e5a962..8f5a377 100644 --- a/app/src/main/java/com/anland/termux/Touchpad.java +++ b/app/src/main/java/com/anland/termux/Touchpad.java @@ -54,6 +54,9 @@ interface Output { private float startX1, startY1; private float lastX2, lastY2; private long downTime1; + // Kept across cancel() so a physical clickpad press can inspect the contact + // positions that were maintained by the preceding gesture events. + private boolean clickpadBaselineValid = false; private final float touchSlop; private boolean isSingleTapCandidate = false; @@ -230,6 +233,46 @@ void cancel() { lastTapY = 0f; } + /** + * Resolve a clickpad's generic primary button to Linux left/right. The contact + * that moved least is treated as the pressing finger and its horizontal + * position selects the half of the pad. Coordinates are compared in the same + * output space used by gesture recognition. + */ + int clickpadButton(MotionEvent event) { + if (!clickpadBaselineValid || event.getPointerCount() <= 0) + return 0x110; // BTN_LEFT + int limit = Math.min(event.getPointerCount(), 2); + int slowestIndex = 0; + float slowestSpeed = Float.MAX_VALUE; + for (int i = 0; i < limit; i++) { + float lastX = i == 0 ? lastX1 : lastX2; + float lastY = i == 0 ? lastY1 : lastY2; + float curX = toOutputX(event.getX(i)); + float curY = toOutputY(event.getY(i)); + float dx = curX - lastX; + float dy = curY - lastY; + float speed = dx * dx + dy * dy; + if (speed < slowestSpeed) { + slowestSpeed = speed; + slowestIndex = i; + } + } + float curX = toOutputX(event.getX(slowestIndex)); + float midpoint = outputWidth > 0 ? outputWidth / 2f : curX; + return curX < midpoint ? 0x110 : 0x111; // BTN_LEFT / BTN_RIGHT + } + + private float toOutputX(float x) { + return inputRangeX > 0f && outputWidth > 0 + ? (x - inputMinX) * (outputWidth / inputRangeX) : x; + } + + private float toOutputY(float y) { + return inputRangeY > 0f && outputHeight > 0 + ? (y - inputMinY) * (outputHeight / inputRangeY) : y; + } + /** * Interpret one event from this device. * @@ -370,6 +413,12 @@ private boolean recognize(MotionEvent event) { int action = event.getActionMasked(); int pointerCount = event.getPointerCount(); + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN + || action == MotionEvent.ACTION_MOVE) + clickpadBaselineValid = true; + else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) + clickpadBaselineValid = false; + // Once declined, stay declined: the gesture is mid-way through being // forwarded as touch and its pointer-up events still have to get there. if (gestureUnhandled) { From d5c8eb27b505f3d28d71102a0c6321394c4b8847 Mon Sep 17 00:00:00 2001 From: superturtlee <160681686+superturtlee@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:51:32 +0800 Subject: [PATCH 2/3] app: recover failed playback streams Register an AAudio error callback for playback and rebuild the output stream when the callback or a write reports a failure. Refresh the negotiated device format and re-announce it to the producer after recovery. Keep recovery scoped to the audio stream so route changes and HAL failures do not tear down or reconnect the display pipeline. Co-authored-by: lfdevs --- app/src/main/jni/native_audio.c | 74 +++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/app/src/main/jni/native_audio.c b/app/src/main/jni/native_audio.c index 46ee354..ec20520 100644 --- a/app/src/main/jni/native_audio.c +++ b/app/src/main/jni/native_audio.c @@ -50,6 +50,10 @@ struct audio_bridge { volatile int cap_latency_ms; volatile bool resend_formats; /* a preset changed -> re-announce on the live fd */ + /* Set by the AAudio error callback or a failed playback write. The playback + * thread owns stream teardown and recreation. */ + volatile bool play_rebuild; + uint8_t rx[MAX_DGRAM]; }; @@ -63,7 +67,19 @@ static int current_fd(void) /* ---- AAudio stream helpers ---- */ -static AAudioStream *open_stream(aaudio_direction_t dir, int channels) +static void play_error_cb(AAudioStream *stream, void *user_data, + aaudio_result_t error) +{ + struct audio_bridge *b = user_data; + (void)stream; + LOGE("playback stream error: %s -- scheduling rebuild", + AAudio_convertResultToText(error)); + b->play_rebuild = true; +} + +static AAudioStream *open_stream(aaudio_direction_t dir, int channels, + AAudioStream_errorCallback error_cb, + void *user_data) { AAudioStreamBuilder *b = NULL; if (AAudio_createStreamBuilder(&b) != AAUDIO_OK || !b) @@ -76,6 +92,8 @@ static AAudioStream *open_stream(aaudio_direction_t dir, int channels) AAudioStreamBuilder_setFormat(b, AAUDIO_FORMAT_PCM_I16); AAudioStreamBuilder_setPerformanceMode(b, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); AAudioStreamBuilder_setSharingMode(b, AAUDIO_SHARING_MODE_SHARED); + if (error_cb) + AAudioStreamBuilder_setErrorCallback(b, error_cb, user_data); AAudioStream *stream = NULL; aaudio_result_t r = AAudioStreamBuilder_openStream(b, &stream); @@ -120,6 +138,26 @@ static void send_format(int fd, uint32_t role, uint32_t rate, uint32_t channels, /* ---- playback: socket -> speaker ---- */ +/* Reopen only the output stream after an AAudio route/error failure. The display + * connection and native window remain untouched. */ +static bool reopen_play_stream(void) +{ + if (g.play) { + AAudioStream_requestStop(g.play); + AAudioStream_close(g.play); + g.play = NULL; + } + g.play = open_stream(AAUDIO_DIRECTION_OUTPUT, WANT_PLAY_CHANNELS, + play_error_cb, &g); + if (!g.play) + return false; + g.play_rate = AAudioStream_getSampleRate(g.play); + g.play_channels = AAudioStream_getChannelCount(g.play); + AAudioStream_requestStart(g.play); + LOGI("playback stream opened: %d Hz x%d", g.play_rate, g.play_channels); + return true; +} + static void *play_thread_func(void *arg) { (void)arg; @@ -128,6 +166,16 @@ static void *play_thread_func(void *arg) bool had_fd = false; /* drives a one-shot format handshake per connection */ while (g.running) { + if (g.play_rebuild || !g.play) { + g.play_rebuild = false; + if (!reopen_play_stream()) { + usleep(200000); + continue; + } + had_fd = false; + g.resend_formats = true; + } + int fd = current_fd(); if (fd < 0) { had_fd = false; @@ -170,10 +218,17 @@ static void *play_thread_func(void *arg) if (frames <= 0) continue; - /* Blocking write with a short timeout: on underrun/overrun AAudio paces us; - * we never stall the loop longer than the timeout. */ - AAudioStream_write(g.play, g.rx + sizeof(struct audio_msg), frames, - 20 * 1000 * 1000L); + /* A negative write result means the stream disconnected or became invalid; + * rebuild it on the next loop iteration instead of writing into a dead + * stream indefinitely. */ + aaudio_result_t wr = AAudioStream_write( + g.play, g.rx + sizeof(struct audio_msg), frames, + 20 * 1000 * 1000L); + if (wr < 0) { + LOGE("playback write failed: %s -- rebuilding stream", + AAudio_convertResultToText(wr)); + g.play_rebuild = true; + } } LOGI("playback thread stopped"); @@ -249,18 +304,13 @@ void audio_start(void) * chose -- this is the real playback capability we negotiate with the producer. */ g.play_rate = 48000; g.play_channels = WANT_PLAY_CHANNELS; - g.play = open_stream(AAUDIO_DIRECTION_OUTPUT, WANT_PLAY_CHANNELS); - if (g.play) { - g.play_rate = AAudioStream_getSampleRate(g.play); - g.play_channels = AAudioStream_getChannelCount(g.play); - AAudioStream_requestStart(g.play); - } + reopen_play_stream(); /* Open the input stream even before the mic is enabled; it is started/stopped * by the capture thread. May be NULL if RECORD_AUDIO is not granted. */ g.cap_rate = 48000; g.cap_channels = WANT_CAP_CHANNELS; - g.rec = open_stream(AAUDIO_DIRECTION_INPUT, WANT_CAP_CHANNELS); + g.rec = open_stream(AAUDIO_DIRECTION_INPUT, WANT_CAP_CHANNELS, NULL, NULL); if (g.rec) { g.cap_rate = AAudioStream_getSampleRate(g.rec); g.cap_channels = AAudioStream_getChannelCount(g.rec); From 30998222f1dc74d2e5fc4ac37bf53b89d6e51200 Mon Sep 17 00:00:00 2001 From: FengY233 <86183769+FengY233@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:07:41 +0800 Subject: [PATCH 3/3] app: keep Android playback audio alive Drive Android playback through an AAudio data callback backed by a frame-aligned ring buffer, feeding near-silent samples during underruns and rebuilding only the output stream after errors. Add media audio focus handling and a default-off audio keep-alive setting with synchronized localized strings, while retaining idle-stop behavior to limit standby power. Co-authored-by: Goldzxcbug Co-authored-by: lfdevs --- .../java/com/anland/termux/MainActivity.java | 55 ++ .../main/java/com/anland/termux/Native.java | 1 + .../com/anland/termux/SettingsActivity.java | 20 + app/src/main/jni/native_audio.c | 654 ++++++++++++++---- app/src/main/jni/native_audio.h | 15 +- app/src/main/jni/native_consumer.c | 7 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 619 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/com/anland/termux/MainActivity.java b/app/src/main/java/com/anland/termux/MainActivity.java index 113f764..fa5c878 100644 --- a/app/src/main/java/com/anland/termux/MainActivity.java +++ b/app/src/main/java/com/anland/termux/MainActivity.java @@ -19,6 +19,9 @@ import android.graphics.Insets; import android.hardware.display.DisplayManager; import android.content.SharedPreferences; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; import android.os.Build; import android.os.Bundle; import android.os.IBinder; @@ -69,6 +72,8 @@ public class MainActivity extends Activity // Latency presets in ms; 0 = engine default. Shared with SettingsActivity. static final String KEY_SPEAKER_LATENCY_MS = "speaker_latency_ms"; static final String KEY_MIC_LATENCY_MS = "mic_latency_ms"; + // Audio keep-alive toggle. Shared with SettingsActivity. + static final String KEY_AUDIO_KEEPALIVE = "audio_keepalive"; private static final int REQ_RECORD_AUDIO = 1001; private static final int REQ_CAMERA = 1002; // Camera service fds/threads are created once and persist across reconnects; @@ -77,6 +82,10 @@ public class MainActivity extends Activity private ICompatibleBridge compatibleBridge; private boolean compatibleFdReady = false; private boolean compatibleReceiverRegistered = false; + // Media audio focus keeps volume controls and Android audio policy aligned with + // the AAudio playback stream while this window is in the foreground. + private AudioManager mAudioManager; + private AudioFocusRequest mAudioFocusRequest; private static final String DEFAULT_SOCKET_PATH = "/data/data/com.termux/files/usr/tmp/anland/display_daemon.sock"; private static final String KEY_ACCESSIBILITY_ENABLED = "accessibility_key_intercept"; private static final String KEY_EXTRA_KEYS_ENABLED = "extra_keys_bar"; @@ -288,6 +297,7 @@ private void stopNative() { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setupMediaAudio(); applyScreenOrientation(); sInstance = this; @@ -627,11 +637,47 @@ private void setupCursorHiding() { surfaceView.setPointerIcon(PointerIcon.getSystemIcon(this, PointerIcon.TYPE_NULL)); } + /* Bind hardware volume controls and media focus to the same usage as the + * AAudio output stream. This prevents Android audio policy from throttling + * short Linux UI sounds while the desktop window is foregrounded. */ + private void setupMediaAudio() { + setVolumeControlStream(AudioManager.STREAM_MUSIC); + mAudioManager = getSystemService(AudioManager.class); + if (mAudioManager == null) + return; + + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + mAudioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs) + .setWillPauseWhenDucked(false) + .setOnAudioFocusChangeListener(change -> + Log.i(TAG, "audio focus change: " + change)) + .build(); + } + + private void requestMediaAudioFocus() { + if (mAudioManager == null || mAudioFocusRequest == null) + return; + int result = mAudioManager.requestAudioFocus(mAudioFocusRequest); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) + Log.w(TAG, "media audio focus not granted: " + result); + } + + private void abandonMediaAudioFocus() { + if (mAudioManager != null && mAudioFocusRequest != null) + mAudioManager.abandonAudioFocusRequest(mAudioFocusRequest); + } + @Override protected void onResume() { super.onResume(); mPipTransitionPending = false; + requestMediaAudioFocus(); + String displayCutoutMode = DisplayCutoutMode.get( getSharedPreferences(PREFS_NAME, MODE_PRIVATE)); if (!mDisplayCutoutMode.equals(displayCutoutMode)) { @@ -697,6 +743,7 @@ && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) pushRefreshRate(); applyMicState(); applyAudioLatency(); + applyAudioKeepalive(); } // ===== 重新读取触摸板设置 ===== @@ -810,6 +857,7 @@ protected void onPause() { dm.unregisterDisplayListener(displayListener); if (!mPipTransitionPending && !isInPictureInPictureMode()) stopNative(); + abandonMediaAudioFocus(); } private boolean hasPipPermission() { @@ -856,6 +904,7 @@ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, @Override protected void onDestroy() { + abandonMediaAudioFocus(); stopNative(); if (compatibleReceiverRegistered) { unregisterReceiver(compatibleBridgeReceiver); @@ -909,6 +958,11 @@ private void applyAudioLatency() { Native.nativeSetAudioLatency(speakerMs, micMs); } + private void applyAudioKeepalive() { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + Native.nativeSetAudioKeepalive(prefs.getBoolean(KEY_AUDIO_KEEPALIVE, false)); + } + private void applyMicState() { SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); boolean want = prefs.getBoolean(KEY_MIC_ENABLED, false); @@ -967,6 +1021,7 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig pushRefreshRate(); applyMicState(); applyAudioLatency(); + applyAudioKeepalive(); // ===== 更新屏幕尺寸并重置平滑状态 ===== virtualTouchpad.onSurfaceChanged(); diff --git a/app/src/main/java/com/anland/termux/Native.java b/app/src/main/java/com/anland/termux/Native.java index 6afb7e2..466bba1 100644 --- a/app/src/main/java/com/anland/termux/Native.java +++ b/app/src/main/java/com/anland/termux/Native.java @@ -36,4 +36,5 @@ public static native void nativeConfigure(String socketPath, boolean useRoot, public static native void nativeSendTextInput(byte[] data); public static native void nativeSetMicEnabled(boolean enabled); public static native void nativeSetAudioLatency(int speakerMs, int micMs); + public static native void nativeSetAudioKeepalive(boolean enabled); } diff --git a/app/src/main/java/com/anland/termux/SettingsActivity.java b/app/src/main/java/com/anland/termux/SettingsActivity.java index e4b0a78..6be5234 100644 --- a/app/src/main/java/com/anland/termux/SettingsActivity.java +++ b/app/src/main/java/com/anland/termux/SettingsActivity.java @@ -45,6 +45,7 @@ public class SettingsActivity extends Activity { private static final String KEY_USE_ROOT = "use_root"; private static final String KEY_MIC_ENABLED = "mic_enabled"; private static final String KEY_CAMERA_ENABLED = "camera_enabled"; + private static final String KEY_AUDIO_KEEPALIVE = "audio_keepalive"; private static final String KEY_SPEAKER_LATENCY_MS = "speaker_latency_ms"; private static final String KEY_MIC_LATENCY_MS = "mic_latency_ms"; private static final String KEY_ACCESSIBILITY_ENABLED = "accessibility_key_intercept"; @@ -943,6 +944,25 @@ private void addConnectionSection(LinearLayout root) { cameraHint.setPadding(0, dp(4), 0, 0); root.addView(cameraHint); + // Keep the output stream hot for short Linux UI sounds, or let it + // idle-stop after silence to save standby power. Disabled by default. + Switch keepaliveSwitch = new Switch(this); + keepaliveSwitch.setText(R.string.audio_keepalive_switch); + keepaliveSwitch.setTextSize(14); + keepaliveSwitch.setPadding(0, dp(16), 0, 0); + keepaliveSwitch.setChecked(prefs.getBoolean(KEY_AUDIO_KEEPALIVE, false)); + keepaliveSwitch.setOnCheckedChangeListener((v, checked) -> + getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit() + .putBoolean(KEY_AUDIO_KEEPALIVE, checked).apply()); + root.addView(keepaliveSwitch); + + TextView keepaliveHint = new TextView(this); + keepaliveHint.setText(R.string.audio_keepalive_hint); + keepaliveHint.setTextSize(12); + keepaliveHint.setTextColor(getColor(R.color.settings_text_secondary)); + keepaliveHint.setPadding(0, dp(4), 0, 0); + root.addView(keepaliveHint); + // Audio latency presets, separately for the speaker (playback) and microphone // (capture) paths. The chosen buffer is forwarded to the producer's PipeWire // nodes; smaller = lower latency but more risk of audio glitches. diff --git a/app/src/main/jni/native_audio.c b/app/src/main/jni/native_audio.c index ec20520..0a07e76 100644 --- a/app/src/main/jni/native_audio.c +++ b/app/src/main/jni/native_audio.c @@ -8,9 +8,11 @@ #include #include #include +#include #include #include #include +#include #include #define TAG "AnlandAudio" @@ -24,6 +26,17 @@ #define WANT_CAP_CHANNELS 1 #define MAX_DGRAM (64 * 1024) #define MIC_MAX_FRAMES 1024 /* upper bound on frames per mic read */ +#define PLAY_POLL_MS 100 +#define PLAY_RING_MS 160 +#define PLAY_RING_MIN (32 * 1024) +#define PLAY_RING_MAX (256 * 1024) +#define PLAY_KEEPALIVE_AMPLITUDE 1 +#define PLAY_WAKE_FADE_MS 8 +/* Keep the output stream running while the desktop is producing audio (short + * UI sounds restart it instantly), but stop it once silence has lasted this + * long so the audio path can idle instead of holding the codec/HAL on forever + * (idle power). A fresh PCM datagram restarts it via ensure_play_stream_started(). */ +#define PLAY_IDLE_STOP_MS 1500 struct audio_bridge { volatile bool running; @@ -48,56 +61,296 @@ struct audio_bridge { /* Latency presets in ms (0 = engine default), set from the settings UI. */ volatile int play_latency_ms; volatile int cap_latency_ms; - volatile bool resend_formats; /* a preset changed -> re-announce on the live fd */ - - /* Set by the AAudio error callback or a failed playback write. The playback - * thread owns stream teardown and recreation. */ - volatile bool play_rebuild; + volatile bool resend_formats; /* a preset/device change -> re-announce */ + + /* User "audio keep-alive" toggle. On: keep the output stream running (fed + * near-silent keepalive) for maximum burst reliability, at a little standby + * power. Off (default): the play thread idle-stops the stream after silence + * so the audio path can sleep. */ + volatile bool keepalive_enabled; + + /* + * Playback is pull-driven by an AAudio data callback. The socket thread only + * appends incoming PCM to this ring; the callback feeds an inaudible keepalive + * on underrun. + * That keeps Android's audio mixer awake across short Linux sound effects + * without reconnecting the display pipeline. + */ + pthread_mutex_t play_lock; + uint8_t *play_ring; + size_t play_ring_size; + size_t play_ring_head; + size_t play_ring_tail; + size_t play_ring_fill; + volatile bool play_error; + bool play_idle; + int play_keepalive_phase; + int play_wake_fade_frames; uint8_t rx[MAX_DGRAM]; }; -static struct audio_bridge g = {0}; +static struct audio_bridge g = { + .play_lock = PTHREAD_MUTEX_INITIALIZER, +}; -static int current_fd(void) +static int current_fd(struct audio_bridge *b) { - display_ctx *ctx = g.ctx; + display_ctx *ctx = b->ctx; return ctx ? get_audio_fd(ctx) : -1; } -/* ---- AAudio stream helpers ---- */ +static uint64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; +} + +/* ---- playback ring helpers ---- */ + +static size_t align_down(size_t n, size_t align) +{ + return align > 0 ? n - (n % align) : n; +} + +static size_t align_up(size_t n, size_t align) +{ + if (align == 0) + return n; + size_t rem = n % align; + return rem == 0 ? n : n + align - rem; +} + +static size_t play_frame_bytes(struct audio_bridge *b) +{ + int channels = b && b->play_channels > 0 ? b->play_channels : WANT_PLAY_CHANNELS; + return (size_t)channels * sizeof(int16_t); +} + +static size_t playback_ring_bytes(int rate, int channels) +{ + if (rate <= 0) + rate = 48000; + if (channels <= 0) + channels = WANT_PLAY_CHANNELS; + + size_t frame_bytes = (size_t)channels * sizeof(int16_t); + size_t bytes = (size_t)rate * (size_t)channels * sizeof(int16_t) * PLAY_RING_MS / 1000; + if (bytes < PLAY_RING_MIN) + bytes = PLAY_RING_MIN; + if (bytes > PLAY_RING_MAX) + bytes = PLAY_RING_MAX; + bytes = align_down(bytes, frame_bytes); + if (bytes < frame_bytes) + bytes = frame_bytes; + return bytes; +} + +static void play_ring_reset_locked(struct audio_bridge *b) +{ + b->play_ring_head = 0; + b->play_ring_tail = 0; + b->play_ring_fill = 0; +} + +static bool play_ring_resize_locked(struct audio_bridge *b, size_t size, bool reset) +{ + if (b->play_ring && b->play_ring_size == size) { + if (reset) + play_ring_reset_locked(b); + return true; + } + + uint8_t *ring = malloc(size); + if (!ring) + return false; + + free(b->play_ring); + b->play_ring = ring; + b->play_ring_size = size; + play_ring_reset_locked(b); + return true; +} + +static size_t play_ring_read_locked(struct audio_bridge *b, uint8_t *dst, size_t n) +{ + if (!b->play_ring || b->play_ring_fill == 0) + return 0; + + n = align_down(n, play_frame_bytes(b)); + size_t got = n < b->play_ring_fill ? n : b->play_ring_fill; + got = align_down(got, play_frame_bytes(b)); + if (got == 0) + return 0; + + size_t first = b->play_ring_size - b->play_ring_tail; + if (first > got) + first = got; + + memcpy(dst, b->play_ring + b->play_ring_tail, first); + memcpy(dst + first, b->play_ring, got - first); + b->play_ring_tail = (b->play_ring_tail + got) % b->play_ring_size; + b->play_ring_fill -= got; + return got; +} + +static void play_ring_write_locked(struct audio_bridge *b, const uint8_t *src, size_t n) +{ + if (!b->play_ring || b->play_ring_size == 0 || n == 0) + return; + + size_t frame_bytes = play_frame_bytes(b); + n = align_down(n, frame_bytes); + if (n == 0) + return; + + if (n > b->play_ring_size) { + src += n - b->play_ring_size; + n = b->play_ring_size; + } + + if (b->play_ring_fill + n > b->play_ring_size) { + size_t drop = b->play_ring_fill + n - b->play_ring_size; + drop = align_up(drop, frame_bytes); + if (drop > b->play_ring_fill) + drop = b->play_ring_fill; + b->play_ring_tail = (b->play_ring_tail + drop) % b->play_ring_size; + b->play_ring_fill -= drop; + } + + size_t first = b->play_ring_size - b->play_ring_head; + if (first > n) + first = n; + + memcpy(b->play_ring + b->play_ring_head, src, first); + memcpy(b->play_ring, src + first, n - first); + b->play_ring_head = (b->play_ring_head + n) % b->play_ring_size; + b->play_ring_fill += n; +} + +static void queue_playback_bytes(struct audio_bridge *b, const uint8_t *data, size_t bytes) +{ + if (!b || !data || bytes == 0) + return; + + pthread_mutex_lock(&b->play_lock); + play_ring_write_locked(b, data, bytes); + pthread_mutex_unlock(&b->play_lock); +} + +static int play_wake_fade_frame_count(struct audio_bridge *b) +{ + int rate = b && b->play_rate > 0 ? b->play_rate : 48000; + int frames = rate * PLAY_WAKE_FADE_MS / 1000; + return frames > 0 ? frames : 1; +} + +static void fill_keepalive_frames(struct audio_bridge *b, int16_t *dst, + int32_t frames, int channels) +{ + if (frames <= 0 || channels <= 0) + return; + + for (int32_t f = 0; f < frames; ++f) { + int16_t v = b->play_keepalive_phase ? PLAY_KEEPALIVE_AMPLITUDE + : -PLAY_KEEPALIVE_AMPLITUDE; + b->play_keepalive_phase ^= 1; + for (int ch = 0; ch < channels; ++ch) + *dst++ = v; + } +} + +static void apply_wake_fade(struct audio_bridge *b, int16_t *samples, + int32_t frames, int channels) +{ + if (frames <= 0 || channels <= 0 || b->play_wake_fade_frames <= 0) + return; + + int total = play_wake_fade_frame_count(b); + for (int32_t f = 0; f < frames && b->play_wake_fade_frames > 0; ++f) { + int gain = total - b->play_wake_fade_frames + 1; + for (int ch = 0; ch < channels; ++ch) { + int32_t s = samples[f * channels + ch]; + samples[f * channels + ch] = (int16_t)((s * gain) / total); + } + b->play_wake_fade_frames--; + } +} -static void play_error_cb(AAudioStream *stream, void *user_data, - aaudio_result_t error) +static aaudio_data_callback_result_t play_data_cb( + AAudioStream *stream, void *userdata, void *audio_data, int32_t num_frames) { - struct audio_bridge *b = user_data; (void)stream; - LOGE("playback stream error: %s -- scheduling rebuild", - AAudio_convertResultToText(error)); - b->play_rebuild = true; + struct audio_bridge *b = userdata; + uint8_t *dst = audio_data; + int16_t *samples = audio_data; + int channels = b->play_channels > 0 ? b->play_channels : WANT_PLAY_CHANNELS; + size_t need = (size_t)num_frames * (size_t)channels * sizeof(int16_t); + size_t got = 0; + int32_t got_frames = 0; + + if (pthread_mutex_trylock(&b->play_lock) == 0) { + got = play_ring_read_locked(b, dst, need); + pthread_mutex_unlock(&b->play_lock); + } + + got_frames = (int32_t)(got / ((size_t)channels * sizeof(int16_t))); + if (got_frames > 0) { + if (b->play_idle) + b->play_wake_fade_frames = play_wake_fade_frame_count(b); + b->play_idle = false; + apply_wake_fade(b, samples, got_frames, channels); + } + + if (got < need) { + int32_t keepalive_frames = num_frames - got_frames; + fill_keepalive_frames(b, (int16_t *)(dst + got), keepalive_frames, channels); + b->play_idle = true; + } + return AAUDIO_CALLBACK_RESULT_CONTINUE; +} + +static void play_error_cb(AAudioStream *stream, void *userdata, aaudio_result_t error) +{ + (void)stream; + struct audio_bridge *b = userdata; + if (b) { + b->play_error = true; + LOGE("output stream error: %s", AAudio_convertResultToText(error)); + } } -static AAudioStream *open_stream(aaudio_direction_t dir, int channels, - AAudioStream_errorCallback error_cb, - void *user_data) +/* ---- AAudio stream helpers ---- */ + +static AAudioStream *open_stream(struct audio_bridge *bridge, + aaudio_direction_t dir, int channels) { - AAudioStreamBuilder *b = NULL; - if (AAudio_createStreamBuilder(&b) != AAUDIO_OK || !b) + AAudioStreamBuilder *bld = NULL; + if (AAudio_createStreamBuilder(&bld) != AAUDIO_OK || !bld) return NULL; - AAudioStreamBuilder_setDirection(b, dir); + AAudioStreamBuilder_setDirection(bld, dir); /* UNSPECIFIED rate: let the device pick its optimal/native rate; we read it back. */ - AAudioStreamBuilder_setSampleRate(b, AAUDIO_UNSPECIFIED); - AAudioStreamBuilder_setChannelCount(b, channels); - AAudioStreamBuilder_setFormat(b, AAUDIO_FORMAT_PCM_I16); - AAudioStreamBuilder_setPerformanceMode(b, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); - AAudioStreamBuilder_setSharingMode(b, AAUDIO_SHARING_MODE_SHARED); - if (error_cb) - AAudioStreamBuilder_setErrorCallback(b, error_cb, user_data); + AAudioStreamBuilder_setSampleRate(bld, AAUDIO_UNSPECIFIED); + AAudioStreamBuilder_setChannelCount(bld, channels); + AAudioStreamBuilder_setFormat(bld, AAUDIO_FORMAT_PCM_I16); + AAudioStreamBuilder_setSharingMode(bld, AAUDIO_SHARING_MODE_SHARED); + + if (dir == AAUDIO_DIRECTION_OUTPUT) { + AAudioStreamBuilder_setUsage(bld, AAUDIO_USAGE_MEDIA); + AAudioStreamBuilder_setContentType(bld, AAUDIO_CONTENT_TYPE_MUSIC); + AAudioStreamBuilder_setPerformanceMode(bld, AAUDIO_PERFORMANCE_MODE_NONE); + AAudioStreamBuilder_setDataCallback(bld, play_data_cb, bridge); + AAudioStreamBuilder_setErrorCallback(bld, play_error_cb, bridge); + } else { + AAudioStreamBuilder_setInputPreset(bld, AAUDIO_INPUT_PRESET_VOICE_RECOGNITION); + AAudioStreamBuilder_setPerformanceMode(bld, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY); + } AAudioStream *stream = NULL; - aaudio_result_t r = AAudioStreamBuilder_openStream(b, &stream); - AAudioStreamBuilder_delete(b); + aaudio_result_t r = AAudioStreamBuilder_openStream(bld, &stream); + AAudioStreamBuilder_delete(bld); if (r != AAUDIO_OK || !stream) { LOGE("open %s stream failed: %s", dir == AAUDIO_DIRECTION_OUTPUT ? "output" : "input", @@ -107,6 +360,116 @@ static AAudioStream *open_stream(aaudio_direction_t dir, int channels, return stream; } +static void close_play_stream(struct audio_bridge *b, bool reset_ring) +{ + if (!b || !b->play) + return; + AAudioStream_requestStop(b->play); + AAudioStream_close(b->play); + b->play = NULL; + b->play_error = false; + + if (reset_ring) { + pthread_mutex_lock(&b->play_lock); + play_ring_reset_locked(b); + pthread_mutex_unlock(&b->play_lock); + } +} + +static bool open_play_stream(struct audio_bridge *b, bool reset_ring) +{ + int old_rate = b->play_rate; + int old_channels = b->play_channels; + close_play_stream(b, reset_ring); + + if (b->play_rate <= 0) + b->play_rate = 48000; + if (b->play_channels <= 0) + b->play_channels = WANT_PLAY_CHANNELS; + + b->play = open_stream(b, AAUDIO_DIRECTION_OUTPUT, WANT_PLAY_CHANNELS); + if (!b->play) + return false; + + int rate = AAudioStream_getSampleRate(b->play); + int channels = AAudioStream_getChannelCount(b->play); + if (rate > 0) + b->play_rate = rate; + if (channels > 0) + b->play_channels = channels; + + pthread_mutex_lock(&b->play_lock); + bool format_changed = old_rate != b->play_rate || old_channels != b->play_channels; + bool ring_ok = play_ring_resize_locked( + b, playback_ring_bytes(b->play_rate, b->play_channels), + reset_ring || format_changed); + pthread_mutex_unlock(&b->play_lock); + if (!ring_ok) { + LOGE("allocate playback ring failed"); + close_play_stream(b, true); + return false; + } + + b->play_idle = true; + b->play_keepalive_phase = 0; + b->play_wake_fade_frames = 0; + + aaudio_result_t r = AAudioStream_requestStart(b->play); + if (r != AAUDIO_OK) { + LOGE("start output stream failed: %s", AAudio_convertResultToText(r)); + close_play_stream(b, true); + return false; + } + + b->play_error = false; + b->resend_formats = true; + LOGI("output stream ready: %d Hz x%d ring=%zu", + b->play_rate, b->play_channels, b->play_ring_size); + return true; +} + +static bool ensure_play_stream_started(struct audio_bridge *b) +{ + if (!b->play || b->play_error) + return open_play_stream(b, false); + + aaudio_stream_state_t state = AAudioStream_getState(b->play); + if (state == AAUDIO_STREAM_STATE_STARTED || state == AAUDIO_STREAM_STATE_STARTING) + return true; + + if (state == AAUDIO_STREAM_STATE_DISCONNECTED) { + LOGE("output stream disconnected, reopening"); + return open_play_stream(b, false); + } + + aaudio_result_t r = AAudioStream_requestStart(b->play); + if (r == AAUDIO_OK) + return true; + + LOGE("restart output stream from %s failed: %s", + AAudio_convertStreamStateToText(state), + AAudio_convertResultToText(r)); + + if (r == AAUDIO_ERROR_DISCONNECTED || r == AAUDIO_ERROR_INVALID_STATE) + return open_play_stream(b, false); + + return false; +} + +static void queue_playback_frames(struct audio_bridge *b, const uint8_t *data, + int32_t frames, int packet_channels) +{ + if (!ensure_play_stream_started(b)) + return; + + int channels = b->play_channels > 0 ? b->play_channels : WANT_PLAY_CHANNELS; + if (channels != packet_channels) + return; + + size_t bytes = (size_t)frames * (size_t)packet_channels * sizeof(int16_t); + queue_playback_bytes(b, data, bytes); +} + /* Convert a latency preset (ms) to a frame count at the given rate. 0 ms -> 0 (let * the producer pick the default quantum). */ static uint32_t ms_to_frames(int ms, int rate) @@ -138,97 +501,97 @@ static void send_format(int fd, uint32_t role, uint32_t rate, uint32_t channels, /* ---- playback: socket -> speaker ---- */ -/* Reopen only the output stream after an AAudio route/error failure. The display - * connection and native window remain untouched. */ -static bool reopen_play_stream(void) -{ - if (g.play) { - AAudioStream_requestStop(g.play); - AAudioStream_close(g.play); - g.play = NULL; - } - g.play = open_stream(AAUDIO_DIRECTION_OUTPUT, WANT_PLAY_CHANNELS, - play_error_cb, &g); - if (!g.play) - return false; - g.play_rate = AAudioStream_getSampleRate(g.play); - g.play_channels = AAudioStream_getChannelCount(g.play); - AAudioStream_requestStart(g.play); - LOGI("playback stream opened: %d Hz x%d", g.play_rate, g.play_channels); - return true; -} - static void *play_thread_func(void *arg) { - (void)arg; + struct audio_bridge *b = arg; LOGI("playback thread started"); bool had_fd = false; /* drives a one-shot format handshake per connection */ + int last_fd = -1; + /* Last time real PCM was queued; the idle-stop logic below uses it to let the + * audio path sleep once the desktop has been silent for a while. */ + uint64_t last_pcm_ms = now_ms(); + + while (b->running) { + if (b->play_error || !b->play) + open_play_stream(b, false); + + /* Power: with the keepalive callback the output stream never naturally + * idles, so once the desktop has been silent for PLAY_IDLE_STOP_MS we stop + * it ourselves. The next PCM arrival restarts it in queue_playback_frames() + * via ensure_play_stream_started(). requestStop is a no-op unless the stream + * is actually running, so this is cheap even when already stopped. + * Skipped when the user enabled "audio keep-alive" (keep the stream hot). */ + if (!b->keepalive_enabled && b->play + && now_ms() - last_pcm_ms > PLAY_IDLE_STOP_MS) { + aaudio_stream_state_t st = AAudioStream_getState(b->play); + if (st == AAUDIO_STREAM_STATE_STARTED || st == AAUDIO_STREAM_STATE_STARTING) + AAudioStream_requestStop(b->play); + } - while (g.running) { - if (g.play_rebuild || !g.play) { - g.play_rebuild = false; - if (!reopen_play_stream()) { - usleep(200000); - continue; - } + int fd = current_fd(b); + if (fd != last_fd) { + pthread_mutex_lock(&b->play_lock); + play_ring_reset_locked(b); + pthread_mutex_unlock(&b->play_lock); had_fd = false; - g.resend_formats = true; + last_fd = fd; } - int fd = current_fd(); if (fd < 0) { - had_fd = false; usleep(20000); continue; } /* Hand the producer the real device formats + latency presets for both * directions: once when the socket comes up (just left fallback), and again - * whenever a preset changes so it re-sizes its PipeWire nodes live. */ - if (!had_fd || g.resend_formats) { - g.resend_formats = false; - send_format(fd, AUDIO_ROLE_PLAYBACK, g.play_rate, g.play_channels, - ms_to_frames(g.play_latency_ms, g.play_rate)); - send_format(fd, AUDIO_ROLE_CAPTURE, g.cap_rate, g.cap_channels, - ms_to_frames(g.cap_latency_ms, g.cap_rate)); + * whenever a preset/device change needs to re-size its PipeWire nodes live. */ + if (!had_fd || b->resend_formats) { + ensure_play_stream_started(b); + b->resend_formats = false; + send_format(fd, AUDIO_ROLE_PLAYBACK, b->play_rate, b->play_channels, + ms_to_frames(b->play_latency_ms, b->play_rate)); + send_format(fd, AUDIO_ROLE_CAPTURE, b->cap_rate, b->cap_channels, + ms_to_frames(b->cap_latency_ms, b->cap_rate)); had_fd = true; } struct pollfd pfd = { .fd = fd, .events = POLLIN }; - if (poll(&pfd, 1, 200) <= 0) + int poll_result = poll(&pfd, 1, PLAY_POLL_MS); + if (poll_result == 0) + continue; + if (poll_result < 0) continue; if (pfd.revents & (POLLHUP | POLLERR)) { + pthread_mutex_lock(&b->play_lock); + play_ring_reset_locked(b); + pthread_mutex_unlock(&b->play_lock); + had_fd = false; + last_fd = -1; usleep(20000); continue; } - ssize_t n = recv(fd, g.rx, sizeof(g.rx), 0); + ssize_t n = recv(fd, b->rx, sizeof(b->rx), 0); if (n < (ssize_t)sizeof(struct audio_msg)) continue; struct audio_msg h; - memcpy(&h, g.rx, sizeof(h)); - if (h.type != AUDIO_MSG_PCM || !g.play) + memcpy(&h, b->rx, sizeof(h)); + if (h.type != AUDIO_MSG_PCM) continue; /* the producer only sends PCM back; formats flow upstream */ size_t avail = (size_t)n - sizeof(struct audio_msg); size_t bytes = h.size < avail ? h.size : avail; - int32_t frames = (int32_t)(bytes / (sizeof(int16_t) * g.play_channels)); + int play_channels = b->play_channels > 0 ? b->play_channels : WANT_PLAY_CHANNELS; + size_t frame_bytes = sizeof(int16_t) * (size_t)play_channels; + bytes -= bytes % frame_bytes; + int32_t frames = (int32_t)(bytes / frame_bytes); if (frames <= 0) continue; - /* A negative write result means the stream disconnected or became invalid; - * rebuild it on the next loop iteration instead of writing into a dead - * stream indefinitely. */ - aaudio_result_t wr = AAudioStream_write( - g.play, g.rx + sizeof(struct audio_msg), frames, - 20 * 1000 * 1000L); - if (wr < 0) { - LOGE("playback write failed: %s -- rebuilding stream", - AAudio_convertResultToText(wr)); - g.play_rebuild = true; - } + queue_playback_frames(b, b->rx + sizeof(struct audio_msg), frames, play_channels); + last_pcm_ms = now_ms(); } LOGI("playback thread stopped"); @@ -239,45 +602,50 @@ static void *play_thread_func(void *arg) static void *cap_thread_func(void *arg) { - (void)arg; + struct audio_bridge *b = arg; LOGI("capture thread started"); bool started = false; - int16_t buf[MIC_MAX_FRAMES * WANT_CAP_CHANNELS]; + int cap_channels = b->cap_channels > 0 ? b->cap_channels : WANT_CAP_CHANNELS; + int16_t *buf = malloc((size_t)MIC_MAX_FRAMES * (size_t)cap_channels * sizeof(*buf)); + if (!buf) { + LOGE("allocate capture buffer failed"); + return NULL; + } /* ~10 ms per read at the device rate, capped to the buffer. */ - int32_t mic_frames = g.cap_rate / 100; + int32_t mic_frames = b->cap_rate / 100; if (mic_frames <= 0) mic_frames = 1; if (mic_frames > MIC_MAX_FRAMES) mic_frames = MIC_MAX_FRAMES; - while (g.running) { - int fd = current_fd(); - if (!g.mic_enabled || fd < 0) { - if (started && g.rec) { - AAudioStream_requestStop(g.rec); + while (b->running) { + int fd = current_fd(b); + if (!b->mic_enabled || fd < 0) { + if (started && b->rec) { + AAudioStream_requestStop(b->rec); started = false; } usleep(20000); continue; } - if (!g.rec) { + if (!b->rec) { usleep(20000); continue; } if (!started) { - if (AAudioStream_requestStart(g.rec) != AAUDIO_OK) { + if (AAudioStream_requestStart(b->rec) != AAUDIO_OK) { usleep(50000); continue; } started = true; } - int32_t got = AAudioStream_read(g.rec, buf, mic_frames, 100 * 1000 * 1000L); + int32_t got = AAudioStream_read(b->rec, buf, mic_frames, 100 * 1000 * 1000L); if (got <= 0) continue; - uint32_t bytes = (uint32_t)got * sizeof(int16_t) * g.cap_channels; + uint32_t bytes = (uint32_t)got * sizeof(int16_t) * (uint32_t)cap_channels; struct audio_msg h = { .type = AUDIO_MSG_PCM, .size = bytes }; struct iovec iov[2] = { { .iov_base = &h, .iov_len = sizeof(h) }, @@ -287,8 +655,9 @@ static void *cap_thread_func(void *arg) sendmsg(fd, &m, MSG_DONTWAIT | MSG_NOSIGNAL); /* drop if the socket is full */ } - if (started && g.rec) - AAudioStream_requestStop(g.rec); + if (started && b->rec) + AAudioStream_requestStop(b->rec); + free(buf); LOGI("capture thread stopped"); return NULL; } @@ -297,52 +666,58 @@ static void *cap_thread_func(void *arg) void audio_start(void) { - if (g.running) + struct audio_bridge *b = &g; + if (b->running) return; - /* Open the output stream and read back the rate/channels the device actually - * chose -- this is the real playback capability we negotiate with the producer. */ - g.play_rate = 48000; - g.play_channels = WANT_PLAY_CHANNELS; - reopen_play_stream(); + /* Keep the output stream running and silence-fed. Short Linux UI sounds then + * arrive into a hot Android mixer instead of waking a cold one-shot stream. */ + b->play_rate = 48000; + b->play_channels = WANT_PLAY_CHANNELS; + b->resend_formats = true; + b->play_error = false; + open_play_stream(b, true); /* Open the input stream even before the mic is enabled; it is started/stopped * by the capture thread. May be NULL if RECORD_AUDIO is not granted. */ - g.cap_rate = 48000; - g.cap_channels = WANT_CAP_CHANNELS; - g.rec = open_stream(AAUDIO_DIRECTION_INPUT, WANT_CAP_CHANNELS, NULL, NULL); - if (g.rec) { - g.cap_rate = AAudioStream_getSampleRate(g.rec); - g.cap_channels = AAudioStream_getChannelCount(g.rec); + b->cap_rate = 48000; + b->cap_channels = WANT_CAP_CHANNELS; + b->rec = open_stream(b, AAUDIO_DIRECTION_INPUT, WANT_CAP_CHANNELS); + if (b->rec) { + b->cap_rate = AAudioStream_getSampleRate(b->rec); + b->cap_channels = AAudioStream_getChannelCount(b->rec); } LOGI("device formats: playback %d Hz x%d, capture %d Hz x%d", - g.play_rate, g.play_channels, g.cap_rate, g.cap_channels); + b->play_rate, b->play_channels, b->cap_rate, b->cap_channels); - g.running = true; - pthread_create(&g.play_thread, NULL, play_thread_func, NULL); - pthread_create(&g.cap_thread, NULL, cap_thread_func, NULL); - LOGI("audio bridge started (play=%p rec=%p)", (void *)g.play, (void *)g.rec); + b->running = true; + pthread_create(&b->play_thread, NULL, play_thread_func, b); + pthread_create(&b->cap_thread, NULL, cap_thread_func, b); + LOGI("audio bridge started (play=%p rec=%p)", (void *)b->play, (void *)b->rec); } void audio_stop(void) { - if (!g.running) + struct audio_bridge *b = &g; + if (!b->running) return; - g.running = false; - pthread_join(g.play_thread, NULL); - pthread_join(g.cap_thread, NULL); - - if (g.play) { - AAudioStream_requestStop(g.play); - AAudioStream_close(g.play); - g.play = NULL; + b->running = false; + pthread_join(b->play_thread, NULL); + pthread_join(b->cap_thread, NULL); + + close_play_stream(b, true); + if (b->rec) { + AAudioStream_requestStop(b->rec); + AAudioStream_close(b->rec); + b->rec = NULL; } - if (g.rec) { - AAudioStream_requestStop(g.rec); - AAudioStream_close(g.rec); - g.rec = NULL; - } - g.ctx = NULL; + pthread_mutex_lock(&b->play_lock); + free(b->play_ring); + b->play_ring = NULL; + b->play_ring_size = 0; + play_ring_reset_locked(b); + pthread_mutex_unlock(&b->play_lock); + b->ctx = NULL; LOGI("audio bridge stopped"); } @@ -353,14 +728,23 @@ void audio_set_ctx(display_ctx *ctx) void audio_set_mic_enabled(int enabled) { - g.mic_enabled = enabled != 0; - LOGI("mic %s", g.mic_enabled ? "enabled" : "disabled"); + struct audio_bridge *b = &g; + b->mic_enabled = enabled != 0; + LOGI("mic %s", b->mic_enabled ? "enabled" : "disabled"); } void audio_set_latency(int speaker_ms, int mic_ms) { - g.play_latency_ms = speaker_ms; - g.cap_latency_ms = mic_ms; - g.resend_formats = true; /* picked up by the playback thread on the live fd */ + struct audio_bridge *b = &g; + b->play_latency_ms = speaker_ms; + b->cap_latency_ms = mic_ms; + b->resend_formats = true; /* picked up by the playback thread on the live fd */ LOGI("latency preset: speaker=%dms mic=%dms", speaker_ms, mic_ms); } + +void audio_set_keepalive(int enabled) +{ + struct audio_bridge *b = &g; + b->keepalive_enabled = enabled != 0; + LOGI("audio keep-alive %s", b->keepalive_enabled ? "enabled" : "disabled"); +} diff --git a/app/src/main/jni/native_audio.h b/app/src/main/jni/native_audio.h index 327c218..53cee96 100644 --- a/app/src/main/jni/native_audio.h +++ b/app/src/main/jni/native_audio.h @@ -12,10 +12,13 @@ * datagrams to the socket (the producer exposes them as a Linux * recording source). Only active while the mic is enabled. * - * The AAudio streams are opened once and stay open across reconnects; the active - * display_ctx (and thus the live audio fd) is swapped via audio_set_ctx(). While - * detached the playback stream simply has nothing to play and the capture stream's - * PCM is dropped. The audio fd is owned by the display library -- never closed here. + * Playback is driven by an AAudio data callback. The socket thread appends PCM to a + * frame-aligned ring buffer and the callback supplies near-silent keepalive samples + * on underrun. The output stream is rebuilt independently after AAudio failures and + * may idle-stop after silence; the display connection is never rebuilt for this. + * The capture stream is opened once and only started while the mic is enabled. The + * active display_ctx (and thus the live audio fd) is swapped via audio_set_ctx(). + * The audio fd is owned by the display library -- never closed here. */ void audio_start(void); @@ -36,4 +39,8 @@ void audio_set_mic_enabled(int enabled); * (the formats are re-announced on the live connection). */ void audio_set_latency(int speaker_ms, int mic_ms); +/* Keep the output stream hot for burst reliability, or allow it to idle-stop after + * silence to save standby power. Disabled by default. */ +void audio_set_keepalive(int enabled); + #endif diff --git a/app/src/main/jni/native_consumer.c b/app/src/main/jni/native_consumer.c index f556d7c..ed049d2 100644 --- a/app/src/main/jni/native_consumer.c +++ b/app/src/main/jni/native_consumer.c @@ -993,3 +993,10 @@ Java_com_anland_termux_Native_nativeSetAudioLatency( { audio_set_latency(speakerMs, micMs); } + +JNIEXPORT void JNICALL +Java_com_anland_termux_Native_nativeSetAudioKeepalive( + JNIEnv *env, jclass clazz, jboolean enabled) +{ + audio_set_keepalive(enabled == JNI_TRUE); +} diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4a11137..ab8d362 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -84,6 +84,8 @@ 桌面音訊始終透過本裝置的喇叭播放。啟用此項還會把麥克風反向發送到桌面。下次連線時生效(請重新開啟應用程式)。 將相機轉發到桌面 允許桌面將本裝置的相機作為視訊來源開啟。僅在桌面正在錄製時相機才會開啟。下次返回應用程式時生效;首次使用會請求相機權限。 + 音訊保活 + 保持音訊輸出串流持續活躍,讓 Linux 的短促系統音(音量提示、按鍵音)始終即時播放;會略增待機耗電。關閉時桌面靜默約 1.5 秒後音訊路徑自動休眠,下次出聲時自動喚醒。開啟後若聽感延遲不合預期,可能需要配合調整下方的「音訊延遲」預設。 音訊延遲 喇叭(桌面 → 手機) 麥克風(手機 → 桌面) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 839f2db..a292b6b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -84,6 +84,8 @@ 桌面音频始终通过本设备的扬声器播放。启用此项还会把麦克风反向发送到桌面。下次连接时生效(请重新打开应用)。 将摄像头转发到桌面 允许桌面将本设备的摄像头作为视频源打开。仅在桌面正在录制时摄像头才会开启。下次返回应用时生效;首次使用会请求摄像头权限。 + 音频保活 + 保持音频输出流持续活跃,让 Linux 的短促系统音(音量提示、按键音)始终即时播放;会略增待机功耗。关闭时桌面静默约1.5秒后音频通路自动休眠,下次出声时自动唤醒。开启后若听感延迟不合预期,可能需要配合调整下方的「音频延迟」预设。 音频延迟 扬声器(桌面 → 手机) 麦克风(手机 → 桌面) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fda39e..72e4bb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,8 @@ Desktop audio always plays through this device\'s speaker. Enabling this also sends the mic the other way. Takes effect on next connect (reopen the app). Forward camera to desktop Lets the desktop open this device\'s camera(s) as a video source. The camera only turns on while the desktop is actively recording. Takes effect on next return to the app; first use prompts for the camera permission. + Audio keep-alive + Keep the audio output stream running so short Linux sounds (volume ticks, key clicks) always play instantly. Costs a little standby power. Off: the audio path sleeps after ~1.5 s of desktop silence and wakes for the next sound. If the audible latency does not match your preference, you may need to tune the Audio latency presets below. Audio latency Speaker (desktop → phone) Microphone (phone → desktop)