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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 18:31:31 +00:00
parent 62ced89701
commit c8e99c8946
3 changed files with 170 additions and 3 deletions

View File

@@ -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<MicLevel> = _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
}

View File

@@ -124,6 +124,11 @@ class SessionController(private val context: Context) {
}) })
} }
private val levels = LevelMonitor(settings, audio)
val micLevel: StateFlow<MicLevel> = levels.level
@Volatile
private var watchingLevel = false
private val routePrefs = AudioRoutePrefs(context) private val routePrefs = AudioRoutePrefs(context)
private val _route = MutableStateFlow(routePrefs.load()) private val _route = MutableStateFlow(routePrefs.load())
val route: StateFlow<AudioRoute> = _route val route: StateFlow<AudioRoute> = _route
@@ -224,6 +229,7 @@ class SessionController(private val context: Context) {
} }
try { try {
if (session.isConnected) conn.microphone?.apply(settings) if (session.isConnected) conn.microphone?.apply(settings)
levels.apply()
} catch (e: RuntimeException) { } catch (e: RuntimeException) {
_events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable")) _events.tryEmit(SessionEvent.Error(e.message ?: "Microphone unavailable"))
} }
@@ -238,6 +244,17 @@ class SessionController(private val context: Context) {
audio.setRoute(route) 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 ---- // ---- channels and clients ----
fun joinChannel(channelId: Int, password: String? = null) { fun joinChannel(channelId: Int, password: String? = null) {
@@ -327,6 +344,7 @@ class SessionController(private val context: Context) {
private fun refresh() { private fun refresh() {
_state.value = snapshotState() _state.value = snapshotState()
updateLevels()
} }
/** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */ /** 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<String>) = override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List<String>) =
setHistory(ChatKey.Private(clientId), nickname, htmlEntries) 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) { override fun onError(message: String) {
_events.tryEmit(SessionEvent.Error(message)) _events.tryEmit(SessionEvent.Error(message))

View File

@@ -1,11 +1,13 @@
package com.ts3client.android.ui package com.ts3client.android.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box 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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -32,11 +34,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.MicLevel
import com.ts3client.android.SessionController import com.ts3client.android.SessionController
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
import com.ts3client.audio.InputLevel
import com.ts3client.config.Settings import com.ts3client.config.Settings
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -59,11 +67,17 @@ 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 threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) }
var inputDevice by remember { mutableStateOf(settings.inputDevice) } var inputDevice by remember { mutableStateOf(settings.inputDevice) }
var outputDevice by remember { mutableStateOf(settings.outputDevice) } var outputDevice by remember { mutableStateOf(settings.outputDevice) }
val inputs by vm.inputDevices.collectAsStateWithLifecycle() val inputs by vm.inputDevices.collectAsStateWithLifecycle()
val outputs by vm.outputDevices.collectAsStateWithLifecycle() val outputs by vm.outputDevices.collectAsStateWithLifecycle()
val route by vm.route.collectAsStateWithLifecycle() val route by vm.route.collectAsStateWithLifecycle()
val level by vm.micLevel.collectAsStateWithLifecycle()
LifecycleStartEffect(Unit) {
vm.watchLevel(true)
onStopOrDispose { vm.watchLevel(false) }
}
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()
@@ -112,6 +126,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
change { this.inputMode = mode } 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) { if (inputMode == Settings.InputMode.VOICE_ACTIVATION) {
Section("Voice activation") Section("Voice activation")
@@ -122,7 +141,8 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
} }
} }
if (vadMode != Settings.VadMode.AUTOMATIC) { 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() } change { vadThresholdDb = it.roundToInt().toDouble() }
} }
} }
@@ -252,6 +272,33 @@ private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: St
Picker(label, options, selected, onSelect) 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. */ /** A slider that applies its value once let go of. */
@Composable @Composable
private fun Level( private fun Level(
@@ -260,6 +307,7 @@ private fun Level(
range: ClosedFloatingPointRange<Float>, range: ClosedFloatingPointRange<Float>,
format: (Float) -> String, format: (Float) -> String,
steps: Int = 0, steps: Int = 0,
onChange: (Float) -> Unit = {},
onDone: (Float) -> Unit, onDone: (Float) -> Unit,
) { ) {
var current by remember(value) { mutableFloatStateOf(value) } var current by remember(value) { mutableFloatStateOf(value) }
@@ -268,6 +316,9 @@ private fun Level(
Text(label, Modifier.weight(1f)) Text(label, Modifier.weight(1f))
Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant) 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) })
} }
} }