From ca7d64ac2be912aad181386af3266067efaa5ab4 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 30 Oct 2026 21:25:11 +0100 Subject: [PATCH] Add stacked live audio graphs to the Android call screen --- .../utun/chat/ui/screens/CallAudioGraphs.kt | 178 ++++++++++++++++++ .../com/utun/chat/ui/screens/CallScreen.kt | 8 + 2 files changed, 186 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallAudioGraphs.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallAudioGraphs.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallAudioGraphs.kt new file mode 100644 index 00000000..47749cda --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallAudioGraphs.kt @@ -0,0 +1,178 @@ +package com.utun.chat.ui.screens + +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.utun.chat.R +import com.utun.chat.data.CallAudioHistory +import com.utun.chat.data.LogManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlin.math.max + +private val graphBlue = Color(0xFF3498DB) +private val graphGreen = Color(0xFF27AE60) +private val graphOrange = Color(0xFFE67E22) +private val graphPurple = Color(0xFF9B59B6) +private val graphRed = Color(0xFFE74C3C) +private class HistoryLine(val count: Int, val color: Color, val time: (Int) -> Long, val value: (Int) -> Float, + val connected: (Int) -> Boolean = { true }) + +/** Только копии native-снимков: закрытие/фон останавливают UI-таймер, сбор истории остаётся у аудиомодуля. */ +@Composable +internal fun CallAudioGraphsDialog(callId: Long, onDismiss: () -> Unit) { + var history by remember(callId) { mutableStateOf(null) } + var live by remember(callId) { mutableStateOf(false) } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(callId) { + LogManager.addLog("INFO", "call", "audio graphs opened call=$callId window_ms=30000 half_life_ms=10000") + onDispose { LogManager.addLog("DEBUG", "call", "audio graphs closed call=$callId") } + } + LaunchedEffect(callId, lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + val snapshot = withContext(Dispatchers.Default) { CallAudioHistory.read(callId) } + live = snapshot != null + if (snapshot != null) history = snapshot + delay(250) + } + } + } + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(Modifier.fillMaxWidth().fillMaxHeight(.94f).padding(10.dp), shape = MaterialTheme.shapes.large) { + Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth()) { + Text(stringResource(R.string.audio_graphs), Modifier.weight(1f), style = MaterialTheme.typography.titleLarge) + IconButton(onClick = onDismiss) { Icon(Icons.Filled.Close, stringResource(R.string.audio_graph_close)) } + } + Column(Modifier.verticalScroll(rememberScrollState())) { + Text(stringResource(R.string.audio_graph_window), style = MaterialTheme.typography.bodySmall) + Text(stringResource(if (live) R.string.audio_graph_live else R.string.audio_graph_waiting), + style = MaterialTheme.typography.bodySmall) + val data = history + if (data != null) { + val fraction = if (data.observedUs > 0) 100.0 * data.missingUs / data.observedUs else 0.0 + Text(stringResource(R.string.audio_graph_missing, fraction), style = MaterialTheme.typography.bodySmall) + val rxTime: (Int) -> Long = { data.rx(it, 0) } + val txTime: (Int) -> Long = { data.tx(it, 0) } + AudioHistoryPlot(stringResource(R.string.audio_graph_rx), stringResource(R.string.audio_graph_rx_legend), data.nowMs, + listOf(HistoryLine(data.rxCount, graphBlue, rxTime, { data.rx(it, 4).toFloat() }), + HistoryLine(data.rxCount, graphBlue, rxTime, { data.rx(it, 5).toFloat() }), + HistoryLine(data.rxCount, graphGreen, rxTime, { data.rx(it, 2).toFloat() }), + HistoryLine(data.rxCount, graphRed, rxTime, { if (data.rx(it, 7) > 0) data.rx(it, 4).toFloat() else Float.NaN })), + 100f, stringResource(R.string.audio_graph_ms), threshold = true) + AudioHistoryPlot(stringResource(R.string.audio_graph_tx), stringResource(R.string.audio_graph_tx_legend), data.nowMs, + listOf(HistoryLine(data.txCount, graphBlue, txTime, { (data.tx(it, 1) + data.tx(it, 2)).toFloat() }), + HistoryLine(data.txCount, graphOrange, txTime, { data.tx(it, 2).toFloat() }), + HistoryLine(data.txCount, graphPurple, txTime, { data.tx(it, 3).toFloat() })), + 1024f, stringResource(R.string.audio_graph_bytes), stringResource(R.string.audio_graph_kib)) + AudioHistoryPlot(stringResource(R.string.audio_graph_rate), stringResource(R.string.audio_graph_rate_legend), data.nowMs, + listOf(HistoryLine(data.txCount, graphGreen, txTime, { data.tx(it, 4).toFloat().takeIf { v -> v >= 0 } ?: Float.NaN }, + { it > 0 && data.tx(it, 5) == data.tx(it - 1, 5) && data.tx(it, 6) == data.tx(it - 1, 6) })), + 256f, stringResource(R.string.audio_graph_bps), stringResource(R.string.audio_graph_kibps)) + AudioHistoryHistogram(data) + } + } + } + } + } +} + +@Composable +private fun AudioHistoryPlot(title: String, legend: String, now: Long, lines: List, minimumSpan: Float, + smallUnit: String, largeUnit: String = "", threshold: Boolean = false) { + var low = Float.POSITIVE_INFINITY; var high = Float.NEGATIVE_INFINITY + for (line in lines) for (i in 0 until line.count) { + val v = line.value(i) + if (v.isFinite()) { low = minOf(low, v); high = maxOf(high, v) } + } + val have = low.isFinite() + if (!have) { low = 0f; high = 0f } + val factor = if (largeUnit.isNotEmpty() && high >= 1024) 1024f else 1f + val unit = if (factor == 1f) smallUnit else largeUnit + val span = max(high - low, minimumSpan) + val bottom = max(0f, (high + low - span) / 2 - span * .05f) + val top = max(high, bottom + span) + span * .05f + val foreground = MaterialTheme.colorScheme.onSurfaceVariant + Spacer(Modifier.height(16.dp)); Text(title, style = MaterialTheme.typography.titleSmall) + Text(if (have) stringResource(R.string.audio_graph_minmax, formatGraph(low / factor), formatGraph(high / factor), unit) + else stringResource(R.string.audio_graph_empty), style = MaterialTheme.typography.bodySmall) + Text(legend, style = MaterialTheme.typography.bodySmall) + Canvas(Modifier.fillMaxWidth().height(142.dp)) { + val left = 52.dp.toPx(); val right = size.width - 12.dp.toPx(); val base = size.height - 20.dp.toPx() + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = foreground.toArgb(); textSize = 10.sp.toPx() } + fun x(at: Long) = left + (30000f - (now - at).toFloat()) / 30000 * (right - left) + fun y(v: Float) = base - (v - bottom) / (top - bottom) * (base - 8.dp.toPx()) + drawContext.canvas.nativeCanvas.drawText(formatGraph(top / factor), 0f, 12.dp.toPx(), paint) + drawContext.canvas.nativeCanvas.drawText(formatGraph(bottom / factor), 0f, base, paint) + for (s in 0..30 step 10) { + val px = left + s / 30f * (right - left) + drawLine(foreground.copy(alpha = .2f), Offset(px, 0f), Offset(px, base)) + val label = "${s - 30} s" + drawContext.canvas.nativeCanvas.drawText(label, px - paint.measureText(label) / 2, size.height - 2.dp.toPx(), paint) + } + if (threshold && top >= 3000 && bottom <= 3000) + drawLine(graphRed.copy(alpha = .5f), Offset(left, y(3000f)), Offset(right, y(3000f))) + for (line in lines) { + var previous: Offset? = null; var previousAt = 0L + for (i in 0 until line.count) { + val value = line.value(i); val at = line.time(i) + if (!value.isFinite()) { previous = null; continue } + val point = Offset(x(at), y(value)) + if (previous != null && at - previousAt <= 150 && line.connected(i)) drawLine(line.color, previous, point, 1.5.dp.toPx()) + else drawCircle(line.color, 1.5.dp.toPx(), point) + previous = point; previousAt = at + } + } + } +} + +private fun formatGraph(value: Float): String = if (value >= 100 || value == 0f) "%.0f".format(value) else "%.1f".format(value) + +@Composable +private fun AudioHistoryHistogram(data: CallAudioHistory) { + var first = (0..60).firstOrNull { data.histogram(it) > 0 } ?: 0 + var last = (0..60).lastOrNull { data.histogram(it) > 0 } ?: 1 + if (first == last) { first = maxOf(0, first - 1); last = minOf(60, last + 1) } + val peak = (0..60).maxOf { data.histogram(it) }; val scale = maxOf(5f, peak) * 1.05f + val foreground = MaterialTheme.colorScheme.onSurfaceVariant; val ms = stringResource(R.string.audio_graph_ms) + Spacer(Modifier.height(16.dp)); Text(stringResource(R.string.audio_graph_distribution), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.audio_graph_minmax, "0", formatGraph(peak), "%"), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.audio_graph_weights), style = MaterialTheme.typography.bodySmall) + Canvas(Modifier.fillMaxWidth().height(142.dp)) { + val left = 52.dp.toPx(); val right = size.width - 12.dp.toPx(); val base = size.height - 20.dp.toPx() + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = foreground.toArgb(); textSize = 10.sp.toPx() } + val width = (right - left) / (last - first + 1) + for (i in first..last) { + val h = data.histogram(i) / scale * (base - 8.dp.toPx()) + drawRect(graphBlue, Offset(left + (i - first) * width, base - h), Size(maxOf(1f, width - 1.dp.toPx()), h)) + } + drawContext.canvas.nativeCanvas.drawText("${formatGraph(scale)}%", 0f, 12.dp.toPx(), paint) + drawContext.canvas.nativeCanvas.drawText("0%", 0f, base, paint) + drawContext.canvas.nativeCanvas.drawText("${first * 50} $ms", left, size.height - 2.dp.toPx(), paint) + val end = (if (last == 60) "3000+" else "${(last + 1) * 50}") + " $ms" + drawContext.canvas.nativeCanvas.drawText(end, right - paint.measureText(end), size.height - 2.dp.toPx(), paint) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 2e9001aa..2b5bec57 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic @@ -182,6 +183,7 @@ fun CallScreen( val controlInsets = WindowInsets.systemBarsIgnoringVisibility.union(WindowInsets.displayCutout) var controlsVisible by rememberSaveable(st?.callId) { mutableStateOf(true) } var settingsVisible by remember(st?.callId) { mutableStateOf(false) } + var audioGraphsVisible by rememberSaveable(st?.callId) { mutableStateOf(false) } var videoOptionsRequested by rememberSaveable(st?.callId) { mutableStateOf(false) } var videoSlot by remember { mutableStateOf(Rect.Zero) } var rootPosition by remember { mutableStateOf(Offset.Zero) } @@ -452,6 +454,9 @@ fun CallScreen( } Text(name, modifier = Modifier.weight(1f).padding(start = 8.dp), color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (phase == CallPhase.ACTIVE && !locked) IconButton(onClick = { audioGraphsVisible = true }) { + Icon(Icons.Filled.BarChart, stringResource(R.string.audio_graphs), tint = Color.White) + } } if (phase == CallPhase.ACTIVE && (videoOptionsRequested || transmitting) && !locked) video?.let { CallVideoToolbar(it, videoMenu, onMenuChange = { videoMenu = it }, @@ -461,6 +466,9 @@ fun CallScreen( } } + if (audioGraphsVisible && !locked && phase == CallPhase.ACTIVE) st?.let { call -> + CallAudioGraphsDialog(call.callId) { audioGraphsVisible = false } + } if (locked) { Box( modifier = Modifier