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:
2026-09-25 18:31:22 +00:00
parent 2281c125fa
commit 62ced89701
9 changed files with 582 additions and 50 deletions

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@@ -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"
}
}

View File

@@ -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<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 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) {

View File

@@ -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 <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. */
@Composable
private fun Level(