From c8e99c894651f4205ddd4a26d242b66981c89059 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 25 Sep 2026 18:31:31 +0000 Subject: [PATCH] Show the microphone level for tuning voice activation The settings show the level on TeamSpeak's scale, lit while it would be sent, with the volume gate's threshold marked as the slider moves. While connected it listens to the connection's own microphone, so it shows exactly what goes out; otherwise it runs a test microphone of its own for as long as the settings are on screen. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/android/LevelMonitor.kt | 95 +++++++++++++++++++ .../ts3client/android/SessionController.kt | 23 ++++- .../ts3client/android/ui/SettingsScreen.kt | 55 ++++++++++- 3 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/LevelMonitor.kt 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) }) } }