diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index ee7c43aa..8cca923d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -101,6 +101,13 @@ public class FT8TransmitSignal { private AudioFormat myFormat = null; private AudioTrack audioTrack = null; + // Held for the duration of each AudioTrack TX so other apps' sounds + // (touch clicks, notifications) aren't mixed into the audio feeding the + // rig. Acquired in playFT8Signal's sound-card branch, released in + // afterPlayAudio (a no-op for the CAT/USB-direct paths, which never + // acquire it). The tune worker uses its own instance. + private final TxAudioFocus txAudioFocus = new TxAudioFocus(); + // Milliseconds we are late into the current cycle; leading audio is clipped // so the TX still finishes on the cycle boundary. Set just before playback. private volatile int lateStartSkipMs = 0; @@ -747,6 +754,13 @@ private void playFT8Signal(Ft8Message msg) { GeneralVariables.fileLog( "playFT8Signal: using AudioTrack output (Android default sink)"); + // This branch shares Android's mixer with every other app, so claim + // exclusive focus for the transmission. Denial is log-only: TX must + // still go out. + boolean focusGranted = txAudioFocus.acquire(GeneralVariables.getMainContext()); + GeneralVariables.fileLog("playFT8Signal: audio focus " + + (focusGranted ? "granted (exclusive)" : "NOT granted — other-app audio may mix into TX")); + Log.d(TAG, String.format("playFT8Signal: Preparing sound card playback... bit depth: %s, sample rate: %d" , GeneralVariables.audioOutput32Bit ? "Float32" : "Int16" , GeneralVariables.audioSampleRate)); @@ -1009,6 +1023,7 @@ private void afterPlayAudio() { audioTrack.release(); audioTrack = null; } + txAudioFocus.release(); // One-shot free text (WSJT-X Tx5 style) just finished sending: stop here // instead of repeating it every cycle, and revert to standard messages so // the next CQ is a normal CQ. Safe to deactivate now — the audio has already @@ -2549,6 +2564,9 @@ private void playTuneTone() { float offsetHz = GeneralVariables.getBaseFrequency(); AudioTrack track = null; boolean keyed = false; + // Own instance (not the TX field): tune and FT8 playback teardowns + // must not release each other's focus. + TxAudioFocus tuneFocus = new TxAudioFocus(); try { GeneralVariables.fileLog(String.format( "TUNE: start offset=%.0fHz level=%d%% maxOn=%ds rate=%d", @@ -2559,6 +2577,12 @@ private void playTuneTone() { keyed = true; mutableIsTuning.postValue(true); + // Same mixer-sharing exposure as the FT8 AudioTrack branch; keep + // other apps' audio out of the carrier. Denial is log-only. + boolean tuneFocusGranted = tuneFocus.acquire(GeneralVariables.getMainContext()); + GeneralVariables.fileLog("TUNE: audio focus " + + (tuneFocusGranted ? "granted (exclusive)" : "NOT granted")); + int sampleRate = GeneralVariables.audioSampleRate; AudioAttributes tuneAttributes = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) @@ -2633,6 +2657,7 @@ private void playTuneTone() { } track.release(); } + tuneFocus.release(); if (keyed) { try { onDoTransmitted.onTuneKeyUp(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TxAudioFocus.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TxAudioFocus.java new file mode 100644 index 00000000..9c6f084d --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TxAudioFocus.java @@ -0,0 +1,111 @@ +package com.k1af.ft8af.ft8transmit; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import com.k1af.ft8af.GeneralVariables; + +/** + * Holds transient-exclusive audio focus for the duration of one transmission. + * + * The AudioTrack TX path plays through Android's shared mixer, so any other + * app's sound (touch clicks, notifications, media) routed to the same output + * device gets mixed into the audio feeding the rig and transmitted on air. + * AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE asks the system to suppress notification + * sounds and other apps to pause while we hold it. Focus is advisory — a + * denied request must never block TX, so acquire() failures are log-only. + * + * The direct-USB (libusb) TX path bypasses the mixer entirely and does not + * need this. + */ +public class TxAudioFocus { + + private final Object lock = new Object(); + private AudioManager audioManager; // non-null only while focus is held + private AudioFocusRequest focusRequest; // API 26+ handle, valid while held + + // Focus loss is log-only: an in-flight FT8 transmission must complete on + // the cycle boundary no matter what another app requests. + private final AudioManager.OnAudioFocusChangeListener focusChangeListener = + focusChange -> GeneralVariables.fileLog( + "TxAudioFocus: focus change during TX: " + focusChange); + + // acquire() is called from worker threads (e.g. the TuneTone `new Thread(...)` + // path) that have no Looper. Both the AudioFocusRequest.Builder listener and + // the legacy requestAudioFocus() register their callback on a Handler; without + // an explicit one they can bind to the calling thread's (missing) Looper. Pin + // the callbacks to the main looper so acquire() is safe from any thread. + private final Handler focusListenerHandler = new Handler(Looper.getMainLooper()); + + /** + * Request transient-exclusive focus. Idempotent while held. + * + * @return true if focus is now held; false if the context/service is + * unavailable or the system denied the request (TX proceeds regardless). + */ + public boolean acquire(Context context) { + synchronized (lock) { + if (audioManager != null) { + return true; // already held + } + if (context == null) { + return false; + } + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return false; + } + int result; + AudioFocusRequest request = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + request = new AudioFocusRequest.Builder( + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build()) + .setOnAudioFocusChangeListener(focusChangeListener, focusListenerHandler) + .build(); + result = am.requestAudioFocus(request); + } else { + result = am.requestAudioFocus(focusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE); + } + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioManager = am; + focusRequest = request; + return true; + } + return false; + } + } + + /** Abandon focus if held; safe no-op otherwise. */ + public void release() { + synchronized (lock) { + if (audioManager == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager.abandonAudioFocusRequest(focusRequest); + } else { + audioManager.abandonAudioFocus(focusChangeListener); + } + audioManager = null; + focusRequest = null; + } + } + + /** Whether focus is currently held. */ + public boolean isHeld() { + synchronized (lock) { + return audioManager != null; + } + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/TxAudioFocusTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/TxAudioFocusTest.java new file mode 100644 index 00000000..382b70ea --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/TxAudioFocusTest.java @@ -0,0 +1,133 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.media.AudioManager; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowAudioManager; + +@RunWith(RobolectricTestRunner.class) +public class TxAudioFocusTest { + + private Context context; + private ShadowAudioManager shadowAudioManager; + private TxAudioFocus focus; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + shadowAudioManager = shadowOf(am); + focus = new TxAudioFocus(); + } + + @Test + public void acquire_granted_isHeld() { + assertThat(focus.acquire(context)).isTrue(); + assertThat(focus.isHeld()).isTrue(); + } + + @Test + public void acquire_requestsTransientExclusiveGain() { + focus.acquire(context); + + assertThat(shadowAudioManager.getLastAudioFocusRequest().durationHint) + .isEqualTo(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE); + } + + @Test + public void acquire_fromLooperlessWorkerThread_isHeld() throws Exception { + // acquire() runs on worker threads with no Looper (e.g. the TuneTone + // thread). The focus-change listener is registered on an explicit + // main-looper Handler so this can't depend on the calling thread's + // Looper. Drive acquire() from a raw Thread and confirm it succeeds. + final boolean[] granted = {false}; + final Throwable[] thrown = {null}; + Thread worker = new Thread(() -> { + try { + granted[0] = focus.acquire(context); + } catch (Throwable t) { + thrown[0] = t; + } + }); + worker.start(); + worker.join(); + + assertThat(thrown[0]).isNull(); + assertThat(granted[0]).isTrue(); + assertThat(focus.isHeld()).isTrue(); + } + + @Test + public void acquire_denied_notHeld() { + shadowAudioManager.setNextFocusRequestResponse( + AudioManager.AUDIOFOCUS_REQUEST_FAILED); + + assertThat(focus.acquire(context)).isFalse(); + assertThat(focus.isHeld()).isFalse(); + } + + @Test + public void acquire_nullContext_notHeld() { + assertThat(focus.acquire(null)).isFalse(); + assertThat(focus.isHeld()).isFalse(); + } + + @Test + public void acquire_whileHeld_isIdempotent() { + assertThat(focus.acquire(context)).isTrue(); + assertThat(focus.acquire(context)).isTrue(); + + // A single release fully drops the focus — the second acquire must not + // have stacked a second request. + focus.release(); + assertThat(focus.isHeld()).isFalse(); + } + + @Test + public void release_abandonsTheHeldRequest() { + focus.acquire(context); + + focus.release(); + + assertThat(focus.isHeld()).isFalse(); + assertThat(shadowAudioManager.getLastAbandonedAudioFocusRequest()).isNotNull(); + } + + @Test + public void release_withoutAcquire_isNoOp() { + focus.release(); // must not throw + + assertThat(focus.isHeld()).isFalse(); + assertThat(shadowAudioManager.getLastAbandonedAudioFocusRequest()).isNull(); + } + + @Test + public void release_thenReacquire_works() { + focus.acquire(context); + focus.release(); + + assertThat(focus.acquire(context)).isTrue(); + assertThat(focus.isHeld()).isTrue(); + } + + @Test + public void deniedAcquire_thenRelease_abandonsNothing() { + shadowAudioManager.setNextFocusRequestResponse( + AudioManager.AUDIOFOCUS_REQUEST_FAILED); + focus.acquire(context); + + focus.release(); + + assertThat(shadowAudioManager.getLastAbandonedAudioFocusRequest()).isNull(); + } +}