Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -2633,6 +2657,7 @@ private void playTuneTone() {
}
track.release();
}
tuneFocus.release();
if (keyed) {
try {
onDoTransmitted.onTuneKeyUp();
Expand Down
102 changes: 102 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TxAudioFocus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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 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);

/**
* 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)
.build();
Comment on lines +57 to +64
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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);
}
Comment on lines +39 to +45

@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();
}
}
Loading