diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a6c5aae..4b53d60 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/android/app/src/main/java/com/ts3client/android/AudioRoutePrefs.kt b/android/app/src/main/java/com/ts3client/android/AudioRoutePrefs.kt new file mode 100644 index 0000000..4027b86 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/AudioRoutePrefs.kt @@ -0,0 +1,30 @@ +package com.ts3client.android + +import android.content.Context +import com.ts3client.android.audio.AudioRoute + +/** Where the Android-only audio choices are kept; the rest live in the shared settings. */ +class AudioRoutePrefs(context: Context) { + + private val prefs = context.getSharedPreferences("audio", Context.MODE_PRIVATE) + + fun load(): AudioRoute = AudioRoute( + enumOr(prefs.getString(INPUT_PRESET, null), AudioRoute.DEFAULT.inputPreset()), + enumOr(prefs.getString(USAGE, null), AudioRoute.DEFAULT.usage()), + ) + + fun save(route: AudioRoute) { + prefs.edit() + .putString(INPUT_PRESET, route.inputPreset().name) + .putString(USAGE, route.usage().name) + .apply() + } + + private inline fun > enumOr(name: String?, default: E): E = + enumValues().firstOrNull { it.name == name } ?: default + + private companion object { + const val INPUT_PRESET = "inputPreset" + const val USAGE = "usage" + } +} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index f6e8718..49df800 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -7,6 +7,8 @@ import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Network import com.ts3client.android.audio.AndroidAudioBackend +import com.ts3client.android.audio.AudioRoute +import com.ts3client.audio.AudioDevice import com.ts3client.config.Bookmark import com.ts3client.config.Bookmarks import com.ts3client.config.IdentityStore @@ -61,7 +63,8 @@ class SessionController(private val context: Context) { val settings: Settings = Settings.load() private val bookmarkStore = Bookmarks.load() private val audio = AndroidAudioBackend(context) - private val sounds = SoundNotifier(settings).also { it.setPlayer(audio.createSoundPlayer(settings)) } + private val soundPlayer = audio.createSoundPlayer(settings) + private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } private val session = ServerSession( settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener() ) @@ -121,6 +124,25 @@ class SessionController(private val context: Context) { }) } + private val routePrefs = AudioRoutePrefs(context) + private val _route = MutableStateFlow(routePrefs.load()) + val route: StateFlow = _route + + private val _inputDevices = MutableStateFlow(audio.io().inputDevices()) + val inputDevices: StateFlow> = _inputDevices + private val _outputDevices = MutableStateFlow(audio.io().outputDevices()) + val outputDevices: StateFlow> = _outputDevices + + val unprocessedSupported: Boolean get() = audio.unprocessedSupported() + + init { + audio.setRoute(_route.value) + audio.addDeviceListener { + _inputDevices.value = audio.io().inputDevices() + _outputDevices.value = audio.io().outputDevices() + } + } + val nickname: String get() = settings.nickname val lastAddress: String get() = settings.lastAddress @@ -192,17 +214,30 @@ class SessionController(private val context: Context) { if (session.isConnected) conn.microphone?.setPushToTalk(pressed) } - /** Saves the settings and puts the voice ones into effect at once. */ + /** Saves the settings and puts the voice and device ones into effect at once. */ fun applySettings() { settings.save() + soundPlayer.setOutputDevice(settings.outputDevice) if (session.isConnected) { - conn.microphone?.apply(settings) conn.playback?.setMasterVolume(settings.outputVolume) + conn.playback?.setOutputDevice(settings.outputDevice) + } + try { + if (session.isConnected) conn.microphone?.apply(settings) + } catch (e: RuntimeException) { + _events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable")) } refresh() scheduleTree(structure = false) } + /** Microphone preset and playback usage; the open lines move over at once. */ + fun setRoute(route: AudioRoute) { + routePrefs.save(route) + _route.value = route + audio.setRoute(route) + } + // ---- channels and clients ---- fun joinChannel(channelId: Int, password: String? = null) { diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index 6e58555..e1a2dee 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.ts3client.android.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +11,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,7 +33,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ts3client.android.SessionController +import com.ts3client.android.audio.AudioRoute +import com.ts3client.audio.AudioDevice import com.ts3client.config.Settings import kotlin.math.roundToInt @@ -53,6 +59,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { var vadMode by remember { mutableStateOf(settings.vadMode) } var denoise by remember { mutableStateOf(settings.denoise) } var agc by remember { mutableStateOf(settings.agc) } + var inputDevice by remember { mutableStateOf(settings.inputDevice) } + var outputDevice by remember { mutableStateOf(settings.outputDevice) } + val inputs by vm.inputDevices.collectAsStateWithLifecycle() + val outputs by vm.outputDevices.collectAsStateWithLifecycle() + val route by vm.route.collectAsStateWithLifecycle() val leave = { if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() } onBack() @@ -71,6 +82,29 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp), label = { Text("Nickname") }, singleLine = true) + Section("Devices") + DevicePicker("Microphone", inputs, inputDevice) { + inputDevice = it + change { this.inputDevice = it } + } + DevicePicker("Speaker", outputs, outputDevice) { + outputDevice = it + change { this.outputDevice = it } + } + Picker( + "Microphone preset", + INPUT_PRESETS.map { (preset, label) -> + val unsupported = preset == AudioRoute.InputPreset.UNPROCESSED && !vm.unprocessedSupported + preset to if (unsupported) "$label (not supported here)" else label + }, + route.inputPreset(), + ) { vm.setRoute(AudioRoute(it, route.usage())) } + Hint(PRESET_HINTS.getValue(route.inputPreset())) + Text("Play voices", Modifier.padding(start = 16.dp, top = 8.dp)) + for ((usage, label) in USAGES) { + Choice(label, route.usage() == usage) { vm.setRoute(AudioRoute(route.inputPreset(), usage)) } + } + Section("Transmission") for ((mode, label) in INPUT_MODES) { Choice(label, inputMode == mode) { @@ -128,6 +162,30 @@ private val INPUT_MODES = listOf( Settings.InputMode.CONTINUOUS to "Continuous", ) +private val INPUT_PRESETS = listOf( + AudioRoute.InputPreset.VOICE_RECOGNITION to "Voice recognition", + AudioRoute.InputPreset.VOICE_COMMUNICATION to "Voice communication", + AudioRoute.InputPreset.UNPROCESSED to "Unprocessed", + AudioRoute.InputPreset.VOICE_PERFORMANCE to "Voice performance", + AudioRoute.InputPreset.GENERIC to "Generic", + AudioRoute.InputPreset.CAMCORDER to "Camcorder", +) + +private val PRESET_HINTS = mapOf( + AudioRoute.InputPreset.VOICE_RECOGNITION to "Little or no processing by the phone; the client's own does the rest.", + AudioRoute.InputPreset.VOICE_COMMUNICATION to + "The phone's call processing: echo cancellation, noise suppression and gain. Works best with voices played as a call.", + AudioRoute.InputPreset.UNPROCESSED to "The raw microphone signal.", + AudioRoute.InputPreset.VOICE_PERFORMANCE to "Low latency and no processing, meant for live performance.", + AudioRoute.InputPreset.GENERIC to "The phone's default microphone source, with whatever it applies to it.", + AudioRoute.InputPreset.CAMCORDER to "Tuned for recording video, often with the rear microphone.", +) + +private val USAGES = listOf( + AudioRoute.Usage.MEDIA to "As media (Bluetooth stays in high quality)", + AudioRoute.Usage.VOICE_COMMUNICATION to "As a call (lets the phone cancel echo)", +) + private val VAD_MODES = listOf( Settings.VadMode.AUTOMATIC to "Automatic", Settings.VadMode.VOLUME_GATE to "Volume gate", @@ -158,6 +216,42 @@ private fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) } } +@Composable +private fun Hint(text: String) { + Text(text, Modifier.padding(horizontal = 16.dp, vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** One of [options], shown as its label and picked from a menu. */ +@Composable +private fun Picker(label: String, options: List>, selected: T, onSelect: (T) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + Column(Modifier.fillMaxWidth().clickable { open = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(label) + Text(options.firstOrNull { it.first == selected }?.second ?: "", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + DropdownMenu(open, { open = false }) { + for ((value, text) in options) { + DropdownMenuItem({ Text(text) }, onClick = { + open = false + onSelect(value) + }) + } + } + } +} + +/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */ +@Composable +private fun DevicePicker(label: String, devices: List, selected: String, onSelect: (String) -> Unit) { + val options = devices.map { it.id() to it.label() }.let { list -> + if (list.any { it.first == selected }) list else list + (selected to "Not connected") + } + Picker(label, options, selected, onSelect) +} + /** A slider that applies its value once let go of. */ @Composable private fun Level( diff --git a/android/audio/src/main/cpp/audio_line.cpp b/android/audio/src/main/cpp/audio_line.cpp index a9fb8fa..76dd2b4 100644 --- a/android/audio/src/main/cpp/audio_line.cpp +++ b/android/audio/src/main/cpp/audio_line.cpp @@ -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(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(preset_.load())); } else { - builder.setUsage(oboe::Usage::Media)->setContentType(oboe::ContentType::Speech); + builder.setUsage(static_cast(usage_.load()))->setContentType(oboe::ContentType::Speech); } std::shared_ptr 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 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 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 deviceId_; const int32_t channels_; + std::atomic preset_; + std::atomic usage_; SampleRing ring_; std::atomic closing_{false}; std::mutex mutex_; std::shared_ptr 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);