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:
@@ -4,6 +4,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|||||||
@@ -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 <reified E : Enum<E>> enumOr(name: String?, default: E): E =
|
||||||
|
enumValues<E>().firstOrNull { it.name == name } ?: default
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val INPUT_PRESET = "inputPreset"
|
||||||
|
const val USAGE = "usage"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import android.content.pm.PackageManager
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import com.ts3client.android.audio.AndroidAudioBackend
|
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.Bookmark
|
||||||
import com.ts3client.config.Bookmarks
|
import com.ts3client.config.Bookmarks
|
||||||
import com.ts3client.config.IdentityStore
|
import com.ts3client.config.IdentityStore
|
||||||
@@ -61,7 +63,8 @@ class SessionController(private val context: Context) {
|
|||||||
val settings: Settings = Settings.load()
|
val settings: Settings = Settings.load()
|
||||||
private val bookmarkStore = Bookmarks.load()
|
private val bookmarkStore = Bookmarks.load()
|
||||||
private val audio = AndroidAudioBackend(context)
|
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(
|
private val session = ServerSession(
|
||||||
settings, IdentityStore.load(settings), audio, sounds, ContactStore.load(), Listener()
|
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<AudioRoute> = _route
|
||||||
|
|
||||||
|
private val _inputDevices = MutableStateFlow(audio.io().inputDevices())
|
||||||
|
val inputDevices: StateFlow<List<AudioDevice>> = _inputDevices
|
||||||
|
private val _outputDevices = MutableStateFlow(audio.io().outputDevices())
|
||||||
|
val outputDevices: StateFlow<List<AudioDevice>> = _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 nickname: String get() = settings.nickname
|
||||||
val lastAddress: String get() = settings.lastAddress
|
val lastAddress: String get() = settings.lastAddress
|
||||||
|
|
||||||
@@ -192,17 +214,30 @@ class SessionController(private val context: Context) {
|
|||||||
if (session.isConnected) conn.microphone?.setPushToTalk(pressed)
|
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() {
|
fun applySettings() {
|
||||||
settings.save()
|
settings.save()
|
||||||
|
soundPlayer.setOutputDevice(settings.outputDevice)
|
||||||
if (session.isConnected) {
|
if (session.isConnected) {
|
||||||
conn.microphone?.apply(settings)
|
|
||||||
conn.playback?.setMasterVolume(settings.outputVolume)
|
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()
|
refresh()
|
||||||
scheduleTree(structure = false)
|
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 ----
|
// ---- channels and clients ----
|
||||||
|
|
||||||
fun joinChannel(channelId: Int, password: String? = null) {
|
fun joinChannel(channelId: Int, password: String? = null) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ts3client.android.ui
|
|||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -10,6 +11,8 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -30,7 +33,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.ts3client.android.SessionController
|
import com.ts3client.android.SessionController
|
||||||
|
import com.ts3client.android.audio.AudioRoute
|
||||||
|
import com.ts3client.audio.AudioDevice
|
||||||
import com.ts3client.config.Settings
|
import com.ts3client.config.Settings
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -53,6 +59,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
|||||||
var vadMode by remember { mutableStateOf(settings.vadMode) }
|
var vadMode by remember { mutableStateOf(settings.vadMode) }
|
||||||
var denoise by remember { mutableStateOf(settings.denoise) }
|
var denoise by remember { mutableStateOf(settings.denoise) }
|
||||||
var agc by remember { mutableStateOf(settings.agc) }
|
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 = {
|
val leave = {
|
||||||
if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() }
|
if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() }
|
||||||
onBack()
|
onBack()
|
||||||
@@ -71,6 +82,29 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
|||||||
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
label = { Text("Nickname") }, singleLine = true)
|
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")
|
Section("Transmission")
|
||||||
for ((mode, label) in INPUT_MODES) {
|
for ((mode, label) in INPUT_MODES) {
|
||||||
Choice(label, inputMode == mode) {
|
Choice(label, inputMode == mode) {
|
||||||
@@ -128,6 +162,30 @@ private val INPUT_MODES = listOf(
|
|||||||
Settings.InputMode.CONTINUOUS to "Continuous",
|
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(
|
private val VAD_MODES = listOf(
|
||||||
Settings.VadMode.AUTOMATIC to "Automatic",
|
Settings.VadMode.AUTOMATIC to "Automatic",
|
||||||
Settings.VadMode.VOLUME_GATE to "Volume gate",
|
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 <T> Picker(label: String, options: List<Pair<T, String>>, 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<AudioDevice>, 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. */
|
/** A slider that applies its value once let go of. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun Level(
|
private fun Level(
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ private:
|
|||||||
|
|
||||||
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
|
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
|
||||||
public:
|
public:
|
||||||
AudioLine(bool input, int32_t deviceId, int32_t channels)
|
/** {@code preset} is the input preset of a capture line, {@code usage} a playback line's usage. */
|
||||||
: input_(input), deviceId_(deviceId), channels_(channels),
|
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)) {}
|
ring_(static_cast<size_t>(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {}
|
||||||
|
|
||||||
int32_t channels() const { return channels_; }
|
int32_t channels() const { return channels_; }
|
||||||
@@ -87,12 +88,12 @@ public:
|
|||||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
|
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
|
||||||
->setDataCallback(this)
|
->setDataCallback(this)
|
||||||
->setErrorCallback(this);
|
->setErrorCallback(this);
|
||||||
if (deviceId_ > 0) builder.setDeviceId(deviceId_);
|
int32_t deviceId = deviceId_.load();
|
||||||
|
if (deviceId > 0) builder.setDeviceId(deviceId);
|
||||||
if (input_) {
|
if (input_) {
|
||||||
// The platform's own voice processing stays off: the client runs TS3's.
|
builder.setInputPreset(static_cast<oboe::InputPreset>(preset_.load()));
|
||||||
builder.setInputPreset(oboe::InputPreset::VoiceRecognition);
|
|
||||||
} else {
|
} 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;
|
std::shared_ptr<oboe::AudioStream> stream;
|
||||||
oboe::Result result = builder.openStream(stream);
|
oboe::Result result = builder.openStream(stream);
|
||||||
@@ -102,15 +103,35 @@ public:
|
|||||||
stream->close();
|
stream->close();
|
||||||
return oboe::Result::ErrorClosed;
|
return oboe::Result::ErrorClosed;
|
||||||
}
|
}
|
||||||
|
if (stream_) stream_->close(); // a concurrent reopen got there first
|
||||||
stream_ = stream;
|
stream_ = stream;
|
||||||
|
if (started_) result = stream_->requestStart();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
oboe::Result start() {
|
oboe::Result start() {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
started_ = true;
|
||||||
return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed;
|
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. */
|
/** Blocks until {@code count} samples are captured; fewer only once closing. */
|
||||||
size_t read(int16_t *data, size_t count) {
|
size_t read(int16_t *data, size_t count) {
|
||||||
size_t done = 0;
|
size_t done = 0;
|
||||||
@@ -157,18 +178,21 @@ public:
|
|||||||
if (closing_.load()) return;
|
if (closing_.load()) return;
|
||||||
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
|
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
|
||||||
oboe::convertToText(error));
|
oboe::convertToText(error));
|
||||||
deviceId_ = 0;
|
deviceId_.store(0);
|
||||||
if (open() == oboe::Result::OK) start();
|
open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const bool input_;
|
const bool input_;
|
||||||
int32_t deviceId_;
|
std::atomic<int32_t> deviceId_;
|
||||||
const int32_t channels_;
|
const int32_t channels_;
|
||||||
|
std::atomic<int32_t> preset_;
|
||||||
|
std::atomic<int32_t> usage_;
|
||||||
SampleRing ring_;
|
SampleRing ring_;
|
||||||
std::atomic<bool> closing_{false};
|
std::atomic<bool> closing_{false};
|
||||||
std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
std::shared_ptr<oboe::AudioStream> stream_;
|
std::shared_ptr<oboe::AudioStream> stream_;
|
||||||
|
bool started_ = false; // guarded by mutex_
|
||||||
};
|
};
|
||||||
|
|
||||||
AudioLine *line(jlong handle) {
|
AudioLine *line(jlong handle) {
|
||||||
@@ -183,8 +207,8 @@ extern "C" {
|
|||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
JNIEXPORT jlong JNICALL
|
||||||
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
|
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
|
||||||
jint channels) {
|
jint channels, jint preset, jint usage) {
|
||||||
auto *created = new AudioLine(input, deviceId, channels);
|
auto *created = new AudioLine(input, deviceId, channels, preset, usage);
|
||||||
oboe::Result result = created->open();
|
oboe::Result result = created->open();
|
||||||
if (result != oboe::Result::OK) {
|
if (result != oboe::Result::OK) {
|
||||||
delete created;
|
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
|
JNIEXPORT jint JNICALL
|
||||||
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
|
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
|
||||||
jint offset, jint length) {
|
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. */
|
/** Android's audio stack: Oboe for the devices, libopus built with the NDK. */
|
||||||
public final class AndroidAudioBackend implements AudioBackend {
|
public final class AndroidAudioBackend implements AudioBackend {
|
||||||
|
|
||||||
private final AudioIo io;
|
private final AndroidAudioIo io;
|
||||||
private final OpusCodec opus = new NativeOpus();
|
private final OpusCodec opus = new NativeOpus();
|
||||||
|
|
||||||
public AndroidAudioBackend(Context context) {
|
public AndroidAudioBackend(Context context) {
|
||||||
@@ -22,6 +22,26 @@ public final class AndroidAudioBackend implements AudioBackend {
|
|||||||
return io;
|
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
|
@Override
|
||||||
public OpusCodec opus() {
|
public OpusCodec opus() {
|
||||||
return opus;
|
return opus;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.ts3client.android.audio;
|
package com.ts3client.android.audio;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.media.AudioDeviceCallback;
|
||||||
import android.media.AudioDeviceInfo;
|
import android.media.AudioDeviceInfo;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
import com.ts3client.audio.AudioCapture;
|
import com.ts3client.audio.AudioCapture;
|
||||||
import com.ts3client.audio.AudioDevice;
|
import com.ts3client.audio.AudioDevice;
|
||||||
@@ -10,77 +13,320 @@ import com.ts3client.audio.AudioIo;
|
|||||||
import com.ts3client.audio.AudioPlayback;
|
import com.ts3client.audio.AudioPlayback;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
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 {
|
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 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) {
|
AndroidAudioIo(Context context) {
|
||||||
this.audioManager = context.getSystemService(AudioManager.class);
|
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
|
@Override
|
||||||
public List<AudioDevice> inputDevices() {
|
public List<AudioDevice> inputDevices() {
|
||||||
return devices(AudioManager.GET_DEVICES_INPUTS);
|
return devices(AudioManager.GET_DEVICES_INPUTS, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AudioDevice> outputDevices() {
|
public List<AudioDevice> outputDevices() {
|
||||||
return devices(AudioManager.GET_DEVICES_OUTPUTS);
|
return devices(AudioManager.GET_DEVICES_OUTPUTS, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<AudioDevice> devices(int flags) {
|
/** Runs on the main thread whenever a device comes or goes. */
|
||||||
List<AudioDevice> out = new ArrayList<>();
|
void addDeviceListener(Runnable listener) {
|
||||||
out.add(AudioDevice.DEFAULT);
|
synchronized (deviceListeners) {
|
||||||
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
|
deviceListeners.add(listener);
|
||||||
out.add(new AudioDevice(Integer.toString(info.getId()), info.getProductName() + " (" + typeName(info) + ")"));
|
|
||||||
}
|
}
|
||||||
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
|
@Override
|
||||||
public AudioCapture openCapture(String deviceId, int preferredChannels) {
|
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
|
@Override
|
||||||
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
|
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
|
||||||
return OboeLine.playback(parseId(deviceId), preferredChannels);
|
opening(false, deviceId);
|
||||||
}
|
|
||||||
|
|
||||||
/** 0 lets the system choose, as it does for the default device. */
|
|
||||||
private static int parseId(String deviceId) {
|
|
||||||
try {
|
try {
|
||||||
return deviceId == null || deviceId.isEmpty() ? 0 : Integer.parseInt(deviceId);
|
return register(OboeLine.playback(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_OUTPUTS),
|
||||||
} catch (NumberFormatException e) {
|
preferredChannels, route, this::closed));
|
||||||
return 0;
|
} 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()) {
|
switch (info.getType()) {
|
||||||
case AudioDeviceInfo.TYPE_BUILTIN_MIC:
|
case AudioDeviceInfo.TYPE_BUILTIN_MIC:
|
||||||
return "built-in microphone";
|
return "Phone microphone" + (address.isEmpty() ? "" : " (" + address + ")");
|
||||||
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
|
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
|
||||||
return "speaker";
|
return "Phone speaker";
|
||||||
case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
|
case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
|
||||||
return "earpiece";
|
return "Earpiece";
|
||||||
case AudioDeviceInfo.TYPE_WIRED_HEADSET:
|
case AudioDeviceInfo.TYPE_WIRED_HEADSET:
|
||||||
return "wired headset";
|
return input ? "Wired headset microphone" : "Wired headset";
|
||||||
case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
|
case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
|
||||||
return "wired headphones";
|
return "Wired headphones";
|
||||||
case AudioDeviceInfo.TYPE_USB_HEADSET:
|
case AudioDeviceInfo.TYPE_USB_HEADSET:
|
||||||
case AudioDeviceInfo.TYPE_USB_DEVICE:
|
case AudioDeviceInfo.TYPE_USB_DEVICE:
|
||||||
return "USB";
|
case AudioDeviceInfo.TYPE_USB_ACCESSORY:
|
||||||
case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
|
return product + " (USB)";
|
||||||
case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
|
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_HEADSET:
|
||||||
case AudioDeviceInfo.TYPE_BLE_SPEAKER:
|
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:
|
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. */
|
/** Held across native reads and writes, so {@link #close} never frees a line in use. */
|
||||||
private final Object io = new Object();
|
private final Object io = new Object();
|
||||||
private final int channels;
|
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 volatile long handle;
|
||||||
|
|
||||||
private OboeLine(boolean input, int deviceId, int channels) {
|
private OboeLine(boolean input, String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
this.handle = nativeOpen(input, deviceId, channels);
|
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);
|
this.channels = nativeChannels(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OboeLine capture(int deviceId, int channels) {
|
static OboeLine capture(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
return new OboeLine(true, deviceId, channels);
|
return new OboeLine(true, device, deviceId, channels, route, onClose);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OboeLine playback(int deviceId, int channels) {
|
static OboeLine playback(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
|
||||||
return new OboeLine(false, deviceId, channels);
|
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
|
@Override
|
||||||
@@ -73,9 +94,12 @@ final class OboeLine implements AudioCapture, AudioPlayback {
|
|||||||
handle = 0;
|
handle = 0;
|
||||||
nativeDelete(h);
|
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);
|
private static native int nativeChannels(long handle);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user