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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user