diff --git a/android/app/src/main/java/com/ts3client/android/LevelMonitor.kt b/android/app/src/main/java/com/ts3client/android/LevelMonitor.kt new file mode 100644 index 0000000..41a5153 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/LevelMonitor.kt @@ -0,0 +1,95 @@ +package com.ts3client.android + +import com.ts3client.audio.AudioBackend +import com.ts3client.audio.InputLevel +import com.ts3client.audio.VoiceInput +import com.ts3client.config.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** The microphone's level on [InputLevel]'s scale, and whether that would be sent. */ +data class MicLevel( + val db: Double = InputLevel.SILENCE_DB, + val transmitting: Boolean = false, + /** Whether a microphone is being listened to at all. */ + val live: Boolean = false, +) + +/** + * The level meter behind the voice-activation settings. While connected it listens to the + * connection's own microphone, so it shows exactly what gets sent; otherwise it runs a test + * microphone of its own for as long as someone watches. + */ +class LevelMonitor(private val settings: Settings, private val audio: AudioBackend) { + + private val _level = MutableStateFlow(MicLevel()) + val level: StateFlow = _level + + /** The connection's microphone, when that is the one listened to. */ + private var borrowed: VoiceInput? = null + private var test: VoiceInput? = null + + /** + * Starts or stops listening. + * + * @param source the connection's microphone while it captures, else null + * @param busy whether a connection is under way, which will bring its own microphone + */ + @Synchronized + fun update(watching: Boolean, source: VoiceInput?, busy: Boolean, mayCapture: Boolean) { + when { + !watching -> release() + source != null -> if (borrowed !== source) { + release() + source.setLevelListener(::onLevel) + borrowed = source + } + busy || !mayCapture -> release() + test == null -> startTest() + } + } + + /** The own microphone's transmit state, which the connection reports. */ + fun talking(transmitting: Boolean) { + if (borrowed != null) _level.value = _level.value.copy(transmitting = transmitting) + } + + /** Puts changed voice settings into effect on the test microphone. */ + @Synchronized + fun apply() { + test?.let { + it.apply(settings) + it.setMode(testMode()) + } + } + + private fun startTest() { + release() + val input = audio.createInput(settings) + input.setMode(testMode()) + input.setLevelListener(::onLevel) + input.setTalkListener { _level.value = _level.value.copy(transmitting = it) } + try { + input.start() + } catch (e: RuntimeException) { + return // no microphone to be had; the meter stays idle + } + test = input + } + + private fun release() { + borrowed?.setLevelListener(null) + borrowed = null + test?.stop() + test = null + _level.value = MicLevel() + } + + private fun onLevel(db: Double) { + _level.value = _level.value.copy(db = db, live = true) + } + + /** Push-to-talk would never open without the button, and the test is there to tune the gate. */ + private fun testMode() = + if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK) Settings.InputMode.VOICE_ACTIVATION else settings.inputMode +} 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 49df800..d168388 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -124,6 +124,11 @@ class SessionController(private val context: Context) { }) } + private val levels = LevelMonitor(settings, audio) + val micLevel: StateFlow = levels.level + @Volatile + private var watchingLevel = false + private val routePrefs = AudioRoutePrefs(context) private val _route = MutableStateFlow(routePrefs.load()) val route: StateFlow = _route @@ -224,6 +229,7 @@ class SessionController(private val context: Context) { } try { if (session.isConnected) conn.microphone?.apply(settings) + levels.apply() } catch (e: RuntimeException) { _events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable")) } @@ -238,6 +244,17 @@ class SessionController(private val context: Context) { audio.setRoute(route) } + /** Listens to the microphone for the level meter while [on]. */ + fun watchLevel(on: Boolean) { + watchingLevel = on + updateLevels() + } + + private fun updateLevels() { + val source = if (session.isConnected && conn.isMicrophoneActive) conn.microphone else null + levels.update(watchingLevel, source, session.isBusy, hasMicPermission()) + } + // ---- channels and clients ---- fun joinChannel(channelId: Int, password: String? = null) { @@ -327,6 +344,7 @@ class SessionController(private val context: Context) { private fun refresh() { _state.value = snapshotState() + updateLevels() } /** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */ @@ -383,7 +401,10 @@ class SessionController(private val context: Context) { override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List) = setHistory(ChatKey.Private(clientId), nickname, htmlEntries) - override fun onTalkStateChanged(clientId: Int, talking: Boolean) = scheduleTree(structure = false) + override fun onTalkStateChanged(clientId: Int, talking: Boolean) { + if (clientId == conn.selfClientId) levels.talking(talking) + scheduleTree(structure = false) + } override fun onError(message: String) { _events.tryEmit(SessionEvent.Error(message)) 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 e1a2dee..3a651e0 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 @@ -1,11 +1,13 @@ package com.ts3client.android.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas 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 +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -32,11 +34,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ts3client.android.MicLevel import com.ts3client.android.SessionController import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice +import com.ts3client.audio.InputLevel import com.ts3client.config.Settings import kotlin.math.roundToInt @@ -59,11 +67,17 @@ 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 threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) } 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 level by vm.micLevel.collectAsStateWithLifecycle() + LifecycleStartEffect(Unit) { + vm.watchLevel(true) + onStopOrDispose { vm.watchLevel(false) } + } val leave = { if (nickname.isNotBlank() && nickname.trim() != settings.nickname) change { this.nickname = nickname.trim() } onBack() @@ -112,6 +126,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { change { this.inputMode = mode } } } + val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC + LevelMeter(level, threshold.takeIf { gated }) + if (!level.live) { + Hint(if (vm.hasMicPermission()) "The microphone is not available right now." else "Allow the microphone to see its level.") + } if (inputMode == Settings.InputMode.VOICE_ACTIVATION) { Section("Voice activation") @@ -122,7 +141,8 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { } } if (vadMode != Settings.VadMode.AUTOMATIC) { - Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" }) { + Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" }, + onChange = { threshold = it }) { change { vadThresholdDb = it.roundToInt().toDouble() } } } @@ -252,6 +272,33 @@ private fun DevicePicker(label: String, devices: List, selected: St Picker(label, options, selected, onSelect) } +/** The microphone level, lit while it would be sent, with the volume gate's threshold marked. */ +@Composable +private fun LevelMeter(level: MicLevel, thresholdDb: Float?) { + val track = MaterialTheme.colorScheme.surfaceVariant + val fill = if (level.transmitting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + val marker = MaterialTheme.colorScheme.onSurface + fun fraction(db: Double) = + ((db - InputLevel.MIN_DB) / (InputLevel.MAX_DB - InputLevel.MIN_DB)).toFloat().coerceIn(0f, 1f) + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row { + Text("Microphone level", Modifier.weight(1f)) + if (level.live) Text("${level.db.roundToInt()}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Canvas(Modifier.fillMaxWidth().padding(top = 6.dp).height(12.dp)) { + val radius = CornerRadius(size.height / 2) + drawRoundRect(track, cornerRadius = radius) + if (level.live) { + drawRoundRect(fill, size = Size(size.width * fraction(level.db), size.height), cornerRadius = radius) + } + if (thresholdDb != null) { + val x = size.width * fraction(thresholdDb.toDouble()) + drawLine(marker, Offset(x, -2.dp.toPx()), Offset(x, size.height + 2.dp.toPx()), strokeWidth = 2.dp.toPx()) + } + } + } +} + /** A slider that applies its value once let go of. */ @Composable private fun Level( @@ -260,6 +307,7 @@ private fun Level( range: ClosedFloatingPointRange, format: (Float) -> String, steps: Int = 0, + onChange: (Float) -> Unit = {}, onDone: (Float) -> Unit, ) { var current by remember(value) { mutableFloatStateOf(value) } @@ -268,6 +316,9 @@ private fun Level( Text(label, Modifier.weight(1f)) Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant) } - Slider(current, { current = it }, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) }) + Slider(current, { + current = it + onChange(it) + }, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) }) } }