Pick the Android audio devices, microphone preset and playback usage
Microphone and speaker are chosen separately, so a Bluetooth headset can play in media quality while the phone's own microphone listens, instead of the headset dropping into its call profile. Devices are kept by type and address: Android hands out new ids whenever one reconnects, and a line that lost its device comes back to it when it returns. The microphone preset (voice recognition by default, or the platform's call processing, unprocessed, ...) and whether voices play as media or as a call are Android's own settings. Changing either reopens the open streams in place. Communication mode, and the headset's call link that a Bluetooth microphone needs, are held only while a stream needs them, and are set up before that stream opens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,8 +68,9 @@ private:
|
||||
|
||||
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
|
||||
public:
|
||||
AudioLine(bool input, int32_t deviceId, int32_t channels)
|
||||
: input_(input), deviceId_(deviceId), channels_(channels),
|
||||
/** {@code preset} is the input preset of a capture line, {@code usage} a playback line's usage. */
|
||||
AudioLine(bool input, int32_t deviceId, int32_t channels, int32_t preset, int32_t usage)
|
||||
: input_(input), deviceId_(deviceId), channels_(channels), preset_(preset), usage_(usage),
|
||||
ring_(static_cast<size_t>(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {}
|
||||
|
||||
int32_t channels() const { return channels_; }
|
||||
@@ -87,12 +88,12 @@ public:
|
||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
|
||||
->setDataCallback(this)
|
||||
->setErrorCallback(this);
|
||||
if (deviceId_ > 0) builder.setDeviceId(deviceId_);
|
||||
int32_t deviceId = deviceId_.load();
|
||||
if (deviceId > 0) builder.setDeviceId(deviceId);
|
||||
if (input_) {
|
||||
// The platform's own voice processing stays off: the client runs TS3's.
|
||||
builder.setInputPreset(oboe::InputPreset::VoiceRecognition);
|
||||
builder.setInputPreset(static_cast<oboe::InputPreset>(preset_.load()));
|
||||
} else {
|
||||
builder.setUsage(oboe::Usage::Media)->setContentType(oboe::ContentType::Speech);
|
||||
builder.setUsage(static_cast<oboe::Usage>(usage_.load()))->setContentType(oboe::ContentType::Speech);
|
||||
}
|
||||
std::shared_ptr<oboe::AudioStream> stream;
|
||||
oboe::Result result = builder.openStream(stream);
|
||||
@@ -102,15 +103,35 @@ public:
|
||||
stream->close();
|
||||
return oboe::Result::ErrorClosed;
|
||||
}
|
||||
if (stream_) stream_->close(); // a concurrent reopen got there first
|
||||
stream_ = stream;
|
||||
if (started_) result = stream_->requestStart();
|
||||
return result;
|
||||
}
|
||||
|
||||
oboe::Result start() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
started_ = true;
|
||||
return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed;
|
||||
}
|
||||
|
||||
/** Moves the line to another device or configuration; the ring and whoever blocks on it stay. */
|
||||
oboe::Result reopen(int32_t deviceId, int32_t preset, int32_t usage) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (closing_.load()) return oboe::Result::ErrorClosed;
|
||||
if (stream_) {
|
||||
stream_->stop();
|
||||
stream_->close();
|
||||
stream_.reset();
|
||||
}
|
||||
}
|
||||
deviceId_.store(deviceId);
|
||||
preset_.store(preset);
|
||||
usage_.store(usage);
|
||||
return open();
|
||||
}
|
||||
|
||||
/** Blocks until {@code count} samples are captured; fewer only once closing. */
|
||||
size_t read(int16_t *data, size_t count) {
|
||||
size_t done = 0;
|
||||
@@ -157,18 +178,21 @@ public:
|
||||
if (closing_.load()) return;
|
||||
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
|
||||
oboe::convertToText(error));
|
||||
deviceId_ = 0;
|
||||
if (open() == oboe::Result::OK) start();
|
||||
deviceId_.store(0);
|
||||
open();
|
||||
}
|
||||
|
||||
private:
|
||||
const bool input_;
|
||||
int32_t deviceId_;
|
||||
std::atomic<int32_t> deviceId_;
|
||||
const int32_t channels_;
|
||||
std::atomic<int32_t> preset_;
|
||||
std::atomic<int32_t> usage_;
|
||||
SampleRing ring_;
|
||||
std::atomic<bool> closing_{false};
|
||||
std::mutex mutex_;
|
||||
std::shared_ptr<oboe::AudioStream> 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<OboeLine> 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<Runnable> 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<AudioDevice> inputDevices() {
|
||||
return devices(AudioManager.GET_DEVICES_INPUTS);
|
||||
return devices(AudioManager.GET_DEVICES_INPUTS, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AudioDevice> outputDevices() {
|
||||
return devices(AudioManager.GET_DEVICES_OUTPUTS);
|
||||
return devices(AudioManager.GET_DEVICES_OUTPUTS, false);
|
||||
}
|
||||
|
||||
private List<AudioDevice> devices(int flags) {
|
||||
List<AudioDevice> 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<AudioDevice> devices(int flags, boolean input) {
|
||||
Map<String, AudioDevice> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user