stream_;
+ bool started_ = false; // guarded by mutex_
};
AudioLine *line(jlong handle) {
@@ -183,8 +207,8 @@ extern "C" {
JNIEXPORT jlong JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
- jint channels) {
- auto *created = new AudioLine(input, deviceId, channels);
+ jint channels, jint preset, jint usage) {
+ auto *created = new AudioLine(input, deviceId, channels, preset, usage);
oboe::Result result = created->open();
if (result != oboe::Result::OK) {
delete created;
@@ -207,6 +231,15 @@ Java_com_ts3client_android_audio_OboeLine_nativeStart(JNIEnv *env, jclass, jlong
}
}
+JNIEXPORT void JNICALL
+Java_com_ts3client_android_audio_OboeLine_nativeReopen(JNIEnv *env, jclass, jlong handle, jint deviceId,
+ jint preset, jint usage) {
+ oboe::Result result = line(handle)->reopen(deviceId, preset, usage);
+ if (result != oboe::Result::OK && result != oboe::Result::ErrorClosed) {
+ env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
+ }
+}
+
JNIEXPORT jint JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
jint offset, jint length) {
diff --git a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java
index 654ff9b..5dd11f2 100644
--- a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java
+++ b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioBackend.java
@@ -10,7 +10,7 @@ import com.ts3client.audio.opus.OpusCodec;
/** Android's audio stack: Oboe for the devices, libopus built with the NDK. */
public final class AndroidAudioBackend implements AudioBackend {
- private final AudioIo io;
+ private final AndroidAudioIo io;
private final OpusCodec opus = new NativeOpus();
public AndroidAudioBackend(Context context) {
@@ -22,6 +22,26 @@ public final class AndroidAudioBackend implements AudioBackend {
return io;
}
+ /** How the lines meet Android's audio: microphone preset, and media or call playback. */
+ public AudioRoute route() {
+ return io.route();
+ }
+
+ /** Applies at once to the lines that are open. */
+ public void setRoute(AudioRoute route) {
+ io.setRoute(route);
+ }
+
+ /** Whether the device offers {@link AudioRoute.InputPreset#UNPROCESSED} truly unprocessed. */
+ public boolean unprocessedSupported() {
+ return io.unprocessedSupported();
+ }
+
+ /** Runs on the main thread whenever an audio device comes or goes. */
+ public void addDeviceListener(Runnable listener) {
+ io.addDeviceListener(listener);
+ }
+
@Override
public OpusCodec opus() {
return opus;
diff --git a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java
index 63acc5c..e098c38 100644
--- a/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java
+++ b/android/audio/src/main/java/com/ts3client/android/audio/AndroidAudioIo.java
@@ -1,8 +1,11 @@
package com.ts3client.android.audio;
import android.content.Context;
+import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
import com.ts3client.audio.AudioCapture;
import com.ts3client.audio.AudioDevice;
@@ -10,77 +13,320 @@ import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
-/** The device's microphones and speakers, opened through Oboe. Device ids are Android's. */
+/**
+ * The device's microphones and speakers, opened through Oboe.
+ *
+ * Android's device ids are handed out anew whenever a device appears, so a Bluetooth
+ * headset has another one each time it reconnects. What the settings keep is the device's
+ * type and address instead, which stay the same, and each open looks the id up.
+ *
+ *
Also owns the phone's call state: communication mode and the communication device are
+ * only set while a line needs them, i.e. playback as a call or a headset microphone.
+ */
final class AndroidAudioIo implements AudioIo {
+ /** AudioDeviceInfo.TYPE_ECHO_REFERENCE, which the SDK keeps as a system API. */
+ private static final int TYPE_ECHO_REFERENCE = 28;
+
private final AudioManager audioManager;
+ private final Set open = ConcurrentHashMap.newKeySet();
+ /** Reopening streams takes a moment; it happens off the caller's thread, one at a time. */
+ private final ExecutorService reopener = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "audio-reopen");
+ t.setDaemon(true);
+ return t;
+ });
+ private final List deviceListeners = new ArrayList<>();
+ private volatile AudioRoute route = AudioRoute.DEFAULT;
+ /** Whether this class put the phone into communication mode, and so must take it out. */
+ private boolean inCall;
+ /** The line being opened, which counts towards the call before it exists; null when none is. */
+ private String openingDevice;
+ private boolean openingInput;
AndroidAudioIo(Context context) {
this.audioManager = context.getSystemService(AudioManager.class);
+ audioManager.registerAudioDeviceCallback(new AudioDeviceCallback() {
+ @Override
+ public void onAudioDevicesAdded(AudioDeviceInfo[] added) {
+ returnToDevices(added);
+ notifyDevices();
+ }
+
+ @Override
+ public void onAudioDevicesRemoved(AudioDeviceInfo[] removed) {
+ notifyDevices();
+ }
+ }, new Handler(Looper.getMainLooper()));
}
+ // ---- devices ----
+
@Override
public List inputDevices() {
- return devices(AudioManager.GET_DEVICES_INPUTS);
+ return devices(AudioManager.GET_DEVICES_INPUTS, true);
}
@Override
public List outputDevices() {
- return devices(AudioManager.GET_DEVICES_OUTPUTS);
+ return devices(AudioManager.GET_DEVICES_OUTPUTS, false);
}
- private List devices(int flags) {
- List out = new ArrayList<>();
- out.add(AudioDevice.DEFAULT);
- for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
- out.add(new AudioDevice(Integer.toString(info.getId()), info.getProductName() + " (" + typeName(info) + ")"));
+ /** Runs on the main thread whenever a device comes or goes. */
+ void addDeviceListener(Runnable listener) {
+ synchronized (deviceListeners) {
+ deviceListeners.add(listener);
}
- return out;
+ }
+
+ private void notifyDevices() {
+ synchronized (deviceListeners) {
+ for (Runnable listener : deviceListeners) listener.run();
+ }
+ }
+
+ private List devices(int flags, boolean input) {
+ Map out = new LinkedHashMap<>();
+ out.put(AudioDevice.DEFAULT.id(), AudioDevice.DEFAULT);
+ for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
+ String label = label(info, input);
+ if (label != null) out.putIfAbsent(key(info), new AudioDevice(key(info), label));
+ }
+ return new ArrayList<>(out.values());
+ }
+
+ /** What the settings store for a device. */
+ private static String key(AudioDeviceInfo info) {
+ return info.getType() + ":" + info.getAddress();
+ }
+
+ /** The device's current id, or 0 to let the system choose when it is not there. */
+ private int resolve(String device, int flags) {
+ if (device == null || device.isEmpty()) return 0;
+ for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
+ if (key(info).equals(device)) return info.getId();
+ }
+ return 0;
+ }
+
+ private static int flags(OboeLine line) {
+ return line.isInput() ? AudioManager.GET_DEVICES_INPUTS : AudioManager.GET_DEVICES_OUTPUTS;
+ }
+
+ /** A picked device that dropped out took its lines to the default; bring them back. */
+ private void returnToDevices(AudioDeviceInfo[] added) {
+ for (AudioDeviceInfo info : added) {
+ for (OboeLine line : open) {
+ if (key(info).equals(line.device) && info.isSource() == line.isInput()) {
+ reopen(line, info.getId());
+ }
+ }
+ }
+ }
+
+ // ---- lines ----
+
+ AudioRoute route() {
+ return route;
+ }
+
+ /** Takes on a new route, moving the lines it concerns over at once. */
+ void setRoute(AudioRoute newRoute) {
+ AudioRoute old = route;
+ route = newRoute;
+ synchronized (this) {
+ updateCall();
+ }
+ for (OboeLine line : open) {
+ boolean affected = line.isInput()
+ ? old.inputPreset() != newRoute.inputPreset()
+ : old.usage() != newRoute.usage();
+ if (affected) reopen(line, resolve(line.device, flags(line)));
+ }
+ }
+
+ private void reopen(OboeLine line, int deviceId) {
+ reopener.execute(() -> {
+ try {
+ line.reopen(deviceId, route);
+ } catch (IllegalStateException ignored) {
+ // The stream could not come back on those terms; the line reads silence until closed.
+ }
+ });
}
@Override
public AudioCapture openCapture(String deviceId, int preferredChannels) {
- return OboeLine.capture(parseId(deviceId), preferredChannels);
+ opening(true, deviceId);
+ try {
+ return register(OboeLine.capture(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_INPUTS),
+ preferredChannels, route, this::closed));
+ } finally {
+ opened();
+ }
}
@Override
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
- return OboeLine.playback(parseId(deviceId), preferredChannels);
- }
-
- /** 0 lets the system choose, as it does for the default device. */
- private static int parseId(String deviceId) {
+ opening(false, deviceId);
try {
- return deviceId == null || deviceId.isEmpty() ? 0 : Integer.parseInt(deviceId);
- } catch (NumberFormatException e) {
- return 0;
+ return register(OboeLine.playback(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_OUTPUTS),
+ preferredChannels, route, this::closed));
+ } finally {
+ opened();
}
}
- private static String typeName(AudioDeviceInfo info) {
+ /** A stream opens on the call as it will be, so the call is set up for it first. */
+ private synchronized void opening(boolean input, String device) {
+ openingInput = input;
+ openingDevice = device == null ? "" : device;
+ updateCall();
+ }
+
+ private synchronized void opened() {
+ openingDevice = null;
+ updateCall();
+ }
+
+ private synchronized OboeLine register(OboeLine line) {
+ open.add(line);
+ return line;
+ }
+
+ private synchronized void closed() {
+ open.removeIf(line -> !line.isOpen());
+ updateCall();
+ }
+
+ /**
+ * Puts the phone into communication mode while a line needs it, pointing the call at
+ * the picked device, and leaves it again once none does.
+ */
+ private void updateCall() {
+ AudioDeviceInfo headsetMic = null;
+ String output = null;
+ List inputs = new ArrayList<>();
+ for (OboeLine line : open) {
+ if (line.isInput()) inputs.add(line.device);
+ else if (output == null) output = line.device;
+ }
+ if (openingDevice != null) {
+ if (openingInput) inputs.add(openingDevice);
+ else if (output == null) output = openingDevice;
+ }
+ for (String input : inputs) {
+ AudioDeviceInfo mic = find(input, AudioManager.GET_DEVICES_INPUTS);
+ if (mic != null && isHeadsetMic(mic)) headsetMic = mic;
+ }
+ // Held while anything is open, the microphone included: a notification sound coming
+ // and going must not take the phone in and out of a call.
+ boolean call = route.usage() == AudioRoute.Usage.VOICE_COMMUNICATION && (!open.isEmpty() || openingDevice != null);
+ boolean needed = headsetMic != null || call;
+ if (needed && !inCall) {
+ audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
+ inCall = true;
+ } else if (!needed && inCall) {
+ audioManager.clearCommunicationDevice();
+ audioManager.setMode(AudioManager.MODE_NORMAL);
+ inCall = false;
+ }
+ if (!needed) return;
+
+ // A headset microphone only works over the headset's call link, so it decides.
+ AudioDeviceInfo target = headsetMic != null
+ ? communicationDevice(headsetMic.getAddress(), headsetMic.getType())
+ : communicationDevice(find(output, AudioManager.GET_DEVICES_OUTPUTS));
+ if (target != null) audioManager.setCommunicationDevice(target);
+ else audioManager.clearCommunicationDevice();
+ }
+
+ private AudioDeviceInfo find(String device, int flags) {
+ if (device == null || device.isEmpty()) return null;
+ for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
+ if (key(info).equals(device)) return info;
+ }
+ return null;
+ }
+
+ private static boolean isHeadsetMic(AudioDeviceInfo info) {
+ return info.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || info.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET;
+ }
+
+ /** The communication device that is {@code output}, if calls can go there. */
+ private AudioDeviceInfo communicationDevice(AudioDeviceInfo output) {
+ if (output == null) return null;
+ // Calls cannot go over A2DP; the same headset's call profile can.
+ int type = output.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ? AudioDeviceInfo.TYPE_BLUETOOTH_SCO : output.getType();
+ return communicationDevice(output.getAddress(), type);
+ }
+
+ private AudioDeviceInfo communicationDevice(String address, int inputType) {
+ for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
+ if (info.getType() == inputType && info.getAddress().equals(address)) return info;
+ }
+ // Wired and USB headsets, and the built-in microphone, have no address in common with an output.
+ for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
+ if (info.getType() == inputType) return info;
+ }
+ return null;
+ }
+
+ boolean unprocessedSupported() {
+ return "true".equals(audioManager.getProperty(AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED));
+ }
+
+ /** How a device is offered in the settings, or null for one no user would pick. */
+ private static String label(AudioDeviceInfo info, boolean input) {
+ String product = info.getProductName() == null ? "" : info.getProductName().toString();
+ String address = info.getAddress();
switch (info.getType()) {
case AudioDeviceInfo.TYPE_BUILTIN_MIC:
- return "built-in microphone";
+ return "Phone microphone" + (address.isEmpty() ? "" : " (" + address + ")");
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
- return "speaker";
+ return "Phone speaker";
case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
- return "earpiece";
+ return "Earpiece";
case AudioDeviceInfo.TYPE_WIRED_HEADSET:
- return "wired headset";
+ return input ? "Wired headset microphone" : "Wired headset";
case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
- return "wired headphones";
+ return "Wired headphones";
case AudioDeviceInfo.TYPE_USB_HEADSET:
case AudioDeviceInfo.TYPE_USB_DEVICE:
- return "USB";
- case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+ case AudioDeviceInfo.TYPE_USB_ACCESSORY:
+ return product + " (USB)";
case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
+ return product + " (Bluetooth, media quality)";
+ case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+ return product + " (Bluetooth, call quality)";
case AudioDeviceInfo.TYPE_BLE_HEADSET:
case AudioDeviceInfo.TYPE_BLE_SPEAKER:
- return "Bluetooth";
+ return product + " (Bluetooth LE)";
+ case AudioDeviceInfo.TYPE_HEARING_AID:
+ return product + " (hearing aid)";
+ case AudioDeviceInfo.TYPE_HDMI:
+ case AudioDeviceInfo.TYPE_HDMI_ARC:
+ case AudioDeviceInfo.TYPE_HDMI_EARC:
+ return "HDMI";
+ case AudioDeviceInfo.TYPE_TELEPHONY:
+ case AudioDeviceInfo.TYPE_REMOTE_SUBMIX:
+ case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE:
+ case TYPE_ECHO_REFERENCE:
+ case AudioDeviceInfo.TYPE_FM_TUNER:
+ case AudioDeviceInfo.TYPE_TV_TUNER:
+ case AudioDeviceInfo.TYPE_BUS:
+ case AudioDeviceInfo.TYPE_BLE_BROADCAST:
+ return null;
default:
- return "type " + info.getType();
+ return product.isEmpty() ? "Device " + info.getId() : product;
}
}
}
diff --git a/android/audio/src/main/java/com/ts3client/android/audio/AudioRoute.java b/android/audio/src/main/java/com/ts3client/android/audio/AudioRoute.java
new file mode 100644
index 0000000..d01429e
--- /dev/null
+++ b/android/audio/src/main/java/com/ts3client/android/audio/AudioRoute.java
@@ -0,0 +1,49 @@
+package com.ts3client.android.audio;
+
+/**
+ * How the app's audio meets Android's: which processing the platform applies to the
+ * microphone, and whether playback is media or a call.
+ */
+public record AudioRoute(InputPreset inputPreset, Usage usage) {
+
+ /** The client runs TS3's own processing, so by default the platform adds as little as it can. */
+ public static final AudioRoute DEFAULT = new AudioRoute(InputPreset.VOICE_RECOGNITION, Usage.MEDIA);
+
+ /** Oboe's input presets, which are Android's audio sources. */
+ public enum InputPreset {
+ /** Tuned for speech with light or no processing; what the client expects. */
+ VOICE_RECOGNITION(6),
+ /** The platform's call processing: echo cancellation, noise suppression, gain control. */
+ VOICE_COMMUNICATION(7),
+ /** No processing at all, where the device supports it. */
+ UNPROCESSED(9),
+ /** Low latency and no processing, meant for live performance. */
+ VOICE_PERFORMANCE(10),
+ /** The plain microphone source, with whatever the vendor applies to it. */
+ GENERIC(1),
+ CAMCORDER(5);
+
+ final int oboeValue;
+
+ InputPreset(int oboeValue) {
+ this.oboeValue = oboeValue;
+ }
+ }
+
+ /** Oboe's playback usages. */
+ public enum Usage {
+ /** Plays like music: stays on A2DP headphones and leaves the phone out of call mode. */
+ MEDIA(1),
+ /**
+ * Plays as a call: Android goes into communication mode, which is what its echo
+ * canceller works against, and Bluetooth headsets switch to their headset profile.
+ */
+ VOICE_COMMUNICATION(2);
+
+ final int oboeValue;
+
+ Usage(int oboeValue) {
+ this.oboeValue = oboeValue;
+ }
+ }
+}
diff --git a/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java b/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java
index 0e28338..9dbf707 100644
--- a/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java
+++ b/android/audio/src/main/java/com/ts3client/android/audio/OboeLine.java
@@ -16,19 +16,40 @@ final class OboeLine implements AudioCapture, AudioPlayback {
/** Held across native reads and writes, so {@link #close} never frees a line in use. */
private final Object io = new Object();
private final int channels;
+ private final boolean input;
+ /** The device as the settings name it, to find it again when the line is reopened. */
+ final String device;
+ private final Runnable onClose;
private volatile long handle;
- private OboeLine(boolean input, int deviceId, int channels) {
- this.handle = nativeOpen(input, deviceId, channels);
+ private OboeLine(boolean input, String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
+ this.input = input;
+ this.device = device;
+ this.onClose = onClose;
+ this.handle = nativeOpen(input, deviceId, channels, route.inputPreset().oboeValue, route.usage().oboeValue);
this.channels = nativeChannels(handle);
}
- static OboeLine capture(int deviceId, int channels) {
- return new OboeLine(true, deviceId, channels);
+ static OboeLine capture(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
+ return new OboeLine(true, device, deviceId, channels, route, onClose);
}
- static OboeLine playback(int deviceId, int channels) {
- return new OboeLine(false, deviceId, channels);
+ static OboeLine playback(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
+ return new OboeLine(false, device, deviceId, channels, route, onClose);
+ }
+
+ boolean isInput() {
+ return input;
+ }
+
+ boolean isOpen() {
+ return handle != 0;
+ }
+
+ /** Moves the open stream onto {@code deviceId} with {@code route}; readers and writers carry on. */
+ void reopen(int deviceId, AudioRoute route) {
+ long h = handle;
+ if (h != 0) nativeReopen(h, deviceId, route.inputPreset().oboeValue, route.usage().oboeValue);
}
@Override
@@ -73,9 +94,12 @@ final class OboeLine implements AudioCapture, AudioPlayback {
handle = 0;
nativeDelete(h);
}
+ onClose.run();
}
- private static native long nativeOpen(boolean input, int deviceId, int channels);
+ private static native long nativeOpen(boolean input, int deviceId, int channels, int preset, int usage);
+
+ private static native void nativeReopen(long handle, int deviceId, int preset, int usage);
private static native int nativeChannels(long handle);