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.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" />
|
||||
|
||||
@@ -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.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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user