From b63ef121f96fc3cfab7c3bfc943bad4e1837d469 Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Tue, 18 Aug 2026 15:07:53 +0300 Subject: [PATCH] =?UTF-8?q?Sound=20meter=20preview=20dialog=20(hold=20on?= =?UTF-8?q?=20'=D0=9D=D0=B0=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8'):=20S-meter,?= =?UTF-8?q?=20clipping,=20jitter/loss=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/ru/ua1zbe/svxremote/MainActivity.kt | 17 ++ .../svxremote/ui/SoundMeterPreviewView.kt | 156 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 3 files changed, 175 insertions(+) create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/ui/SoundMeterPreviewView.kt diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index d165d38..463ee1a 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -35,6 +35,7 @@ import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.ui.PttButton +import ru.ua1zbe.svxremote.ui.SoundMeterPreviewView private const val REQ_MIC = 1001 private const val REQ_NOTIF = 1002 @@ -166,6 +167,12 @@ class MainActivity : Activity(), SvxService.Listener { lblTalker.setOnClickListener { showTalkLogDialog() } + // Удержание на надписи «На связи» — демо-окно индикаторов звука + lblTxState.setOnLongClickListener { + showSoundMetersPreview() + true + } + // Удержание на названии программы (> 2 с) — выбор профиля настроек; // короткие тапы считаем: тройной тап — настройки lblAppTitle.setOnTouchListener { _, event -> @@ -659,6 +666,16 @@ class MainActivity : Activity(), SvxService.Listener { .show() } + /** Демо-окно с примерами индикаторов звука (удержание на «На связи»). */ + private fun showSoundMetersPreview() { + AlertDialog.Builder(this) + .setTitle(R.string.meter_preview_title) + .setMessage(R.string.meter_preview_hint) + .setView(SoundMeterPreviewView(this)) + .setPositiveButton(R.string.close, null) + .show() + } + private fun showGroupsDialog() { val groups = (settings.groups + settings.talkGroup).distinct() if (groups.isEmpty()) { diff --git a/app/src/main/java/ru/ua1zbe/svxremote/ui/SoundMeterPreviewView.kt b/app/src/main/java/ru/ua1zbe/svxremote/ui/SoundMeterPreviewView.kt new file mode 100644 index 0000000..9bfd1ae --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/ui/SoundMeterPreviewView.kt @@ -0,0 +1,156 @@ +package ru.ua1zbe.svxremote.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.View +import kotlin.random.Random + +/** + * Временное демо-окно с примерами индикаторов звука (S-метр, клиппинг, + * джиттер/потери). Открывается удержанием на надписи «На связи». + * Уровни симулируются, чтобы было видно, как индикаторы работают. + */ +class SoundMeterPreviewView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + private val bg = Paint().apply { color = 0xFF0D1117.toInt() } + private val track = Paint().apply { color = 0xFF21262D.toInt() } + private val fill = Paint().apply { color = 0xFF58A6FF.toInt() } + private val fillHot = Paint().apply { color = 0xFFF85149.toInt() } + private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2) } + private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1) } + private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF8B949E.toInt() + textSize = sp(11) + } + private val labelSmall = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF8B949E.toInt() + textSize = sp(9) + } + private val clipDot = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFFF85149.toInt() + } + private val clipDotOff = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF30363D.toInt() + } + + private val handler = Handler(Looper.getMainLooper()) + private var simLevel = 0.0f + private var simPeak = 0.0f + private var simJitter = 30.0f + private var simLoss = 0.1f + private var t = 0.0 + + private val anim = object : Runnable { + override fun run() { + t += 0.08 + val wave = ((Math.sin(t) + Math.sin(t * 2.3)) / 2 + 1) / 2 + simLevel = (wave * 0.62 + Random.nextFloat() * 0.12).toFloat() + // Периодически «перегруз» — клиппинг и пики + if (Random.nextFloat() > 0.92f) simLevel = 0.98f + simPeak = maxOf(simLevel, simPeak - 0.02f) + simJitter = 25 + Random.nextFloat() * 120 + simLoss = (Random.nextFloat() * 0.5f).coerceAtLeast(0.03f) + invalidate() + handler.postDelayed(this, 50) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + handler.post(anim) + } + + override fun onDetachedFromWindow() { + handler.removeCallbacksAndMessages(null) + super.onDetachedFromWindow() + } + + private fun dp(v: Int): Float = v * resources.displayMetrics.density + private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity + + override fun onDraw(canvas: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + canvas.drawRect(0f, 0f, w, h, bg) + + // --- S-метр --- + var y = dp(18) + canvas.drawText("S-метр (peak-hold)", dp(16), y, label) + y += dp(8) + drawSMeter(canvas, w, y) + + // --- Уровень + клиппинг --- + y += dp(58) + canvas.drawText("Уровень + клиппинг", dp(16), y, label) + y += dp(8) + drawVUMeter(canvas, w, y) + + // --- Джиттер / потери --- + y += dp(58) + canvas.drawText("Джиттер и потери пакетов", dp(16), y, label) + y += dp(18) + canvas.drawText( + "джиттер %d мс · потери %.1f%%".format(simJitter.toInt(), simLoss), + dp(16), y, label + ) + val bar = RectF(dp(16), y + dp(6), w - dp(16), y + dp(16)) + canvas.drawRect(bar, track) + val jw = (simJitter / 250f).coerceIn(0f, 1f) * bar.width() + canvas.drawRect(bar.left, bar.top, bar.left + jw, bar.bottom, fill) + } + + /** S-метр: полоса со шкалой S1..S9, +20..+60 и пик-меткой. */ + private fun drawSMeter(canvas: Canvas, w: Float, top: Float) { + val left = dp(16) + val right = w - dp(16) + val bandTop = top + dp(2) + val bandH = dp(14) + canvas.drawRect(left, bandTop, right, bandTop + bandH, track) + val level = (simLevel).coerceIn(0f, 1f) + val fw = level * (right - left) + canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, if (level > 0.85f) fillHot else fill) + // пик-метка + val px = left + simPeak.coerceIn(0f, 1f) * (right - left) + canvas.drawLine(px, bandTop - dp(3), px, bandTop + bandH + dp(3), peakPaint) + // деления и шкала + val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f, + "S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f) + for ((text, pos) in marks) { + val mx = left + pos * (right - left) + canvas.drawLine(mx, bandTop, mx, bandTop + bandH, tick) + canvas.drawText(text, mx - dp(10), bandTop + bandH + dp(12), labelSmall) + } + canvas.drawText("+%.0f".format(level * 60), left, bandTop - dp(4), labelSmall) + } + + /** VU-полоса с зоной перегрузки и красной точкой клиппинга. */ + private fun drawVUMeter(canvas: Canvas, w: Float, top: Float) { + val left = dp(16) + val right = w - dp(52) + val bandTop = top + dp(2) + val bandH = dp(14) + canvas.drawRect(left, bandTop, right, bandTop + bandH, track) + val level = simLevel.coerceIn(0f, 1f) + val fw = level * (right - left) + canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, fill) + // красная зона последних 15% + canvas.drawRect( + right - (right - left) * 0.15f, bandTop, right, bandTop + bandH, + if (level > 0.85f) fillHot else track + ) + // клиппинг-точка + val cxp = w - dp(24) + val cyp = bandTop + bandH / 2 + canvas.drawCircle(cxp, cyp, dp(7), if (level > 0.92f) clipDot else clipDotOff) + canvas.drawText("CLIP", cxp - dp(20), cyp + dp(10), labelSmall) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d0e346..bdc676c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ MIC --- +%1$d dB + Примеры индикаторов звука + Временное демо (уровни симулируются). Скажи, что реализовать по-настоящему. TG%1$d ▾ Последний: — Подключение…