Triple-tap on app title opens settings; add mic gain slider (100-500%)

This commit is contained in:
ua1zbe
2026-08-18 13:10:43 +03:00
parent 0f827f7a9c
commit 467dabe509
7 changed files with 111 additions and 10 deletions

View File

@@ -67,6 +67,8 @@ class MainActivity : Activity(), SvxService.Listener {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null private var titleHoldRunnable: Runnable? = null
private var titleResetRunnable: Runnable? = null
private var titleTaps = 0
private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long) private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long)
@@ -164,18 +166,36 @@ class MainActivity : Activity(), SvxService.Listener {
lblTalker.setOnClickListener { showTalkLogDialog() } lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на названии программы (> 2 с) — выбор профиля настроек // Удержание на названии программы (> 2 с) — выбор профиля настроек;
// короткие тапы считаем: тройной тап — настройки
lblAppTitle.setOnTouchListener { _, event -> lblAppTitle.setOnTouchListener { _, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
val r = Runnable { showProfileDialog() } val r = Runnable {
titleHoldRunnable = null
showProfileDialog()
}
titleHoldRunnable = r titleHoldRunnable = r
mainHandler.postDelayed(r, 2000) mainHandler.postDelayed(r, 2000)
true true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val wasHold = titleHoldRunnable == null
titleHoldRunnable?.let { mainHandler.removeCallbacks(it) } titleHoldRunnable?.let { mainHandler.removeCallbacks(it) }
titleHoldRunnable = null titleHoldRunnable = null
if (!wasHold) {
titleTaps++
// Скользящее окно: сброс от последнего тапа
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
val reset = Runnable { titleTaps = 0 }
titleResetRunnable = reset
mainHandler.postDelayed(reset, 1200)
if (titleTaps >= 3) {
titleTaps = 0
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
startActivity(Intent(this, SettingsActivity::class.java))
}
}
true true
} }
else -> true else -> true

View File

@@ -9,6 +9,7 @@ import android.widget.Button
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.Spinner import android.widget.Spinner
import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
@@ -45,6 +46,23 @@ class SettingsActivity : Activity() {
findViewById<TextView>(R.id.lbl_version).text = findViewById<TextView>(R.id.lbl_version).text =
getString(R.string.app_version_fmt, BuildConfig.VERSION_NAME) getString(R.string.app_version_fmt, BuildConfig.VERSION_NAME)
val seekMicGain = findViewById<SeekBar>(R.id.seek_mic_gain)
val lblMicGain = findViewById<TextView>(R.id.lbl_mic_gain_value)
fun updateMicGainLabel() {
lblMicGain.text = "${100 + seekMicGain.progress * 10}%"
}
seekMicGain.progress =
((Prefs.micGain(this) - 1.0f) * 10).toInt().coerceIn(0, 40)
updateMicGainLabel()
seekMicGain.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
updateMicGainLabel()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
val profileNames = (1..Prefs.PROFILE_COUNT) val profileNames = (1..Prefs.PROFILE_COUNT)
.map { getString(R.string.profile_name, it) } .map { getString(R.string.profile_name, it) }
spinnerProfile.adapter = ArrayAdapter( spinnerProfile.adapter = ArrayAdapter(
@@ -95,6 +113,7 @@ class SettingsActivity : Activity() {
Prefs.setActiveProfile(this, currentProfile) Prefs.setActiveProfile(this, currentProfile)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked) Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
Prefs.setAprsCoords(this, inputAprsCoords.text.toString()) Prefs.setAprsCoords(this, inputAprsCoords.text.toString())
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
toast(getString(R.string.saved)) toast(getString(R.string.saved))
finish() finish()
} }

View File

@@ -10,6 +10,7 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -99,7 +100,7 @@ class SvxService : Service(), ReflectorClient.Callbacks {
} }
fun startTx() { fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) } engine.startCapture(Prefs.micGain(this)) { packet -> client.sendAudio(packet) }
} }
fun stopTx() { fun stopTx() {

View File

@@ -58,10 +58,13 @@ class OpusEngine(
private var dbgOut = 0 private var dbgOut = 0
private var playStarted = false private var playStarted = false
private var gateStart = 0L private var gateStart = 0L
private val jitterTarget = 6 // ≈ 120 мс накапливаем до первого слива private val jitterTarget = 10 // ≈ 200 мс накапливаем до первого слива
private val configBuffers = ArrayList<ByteArray>() private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0 private var configIdx = 0
/** Минимальный размер буфера AudioTrack (байт). 40000 ≈ 417 мс при 48 кГц. */
private val rxBufferBytes = 40_000
fun startDecoder() { fun startDecoder() {
if (decoder != null) return if (decoder != null) return
val format = MediaFormat() val format = MediaFormat()
@@ -88,7 +91,7 @@ class OpusEngine(
OUT_SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, 20000), // ~208 мс — буфер AudioTrack и есть джиттер-буфер maxOf(minBuf, rxBufferBytes), // ~417 мс — джиттер-буфер AudioTrack
AudioTrack.MODE_STREAM AudioTrack.MODE_STREAM
) // play() включаем на первом выходном кадре — буфер успевает накопиться ) // play() включаем на первом выходном кадре — буфер успевает накопиться
} catch (e: Exception) { } catch (e: Exception) {
@@ -282,11 +285,11 @@ class OpusEngine(
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) } decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
} }
/** Джиттер-гейт открыт: накопили кадры или вышло 80 мс с первого пакета. */ /** Джиттер-гейт открыт: накопили кадры или вышло 150 мс с первого пакета. */
private fun gateOpen(): Boolean { private fun gateOpen(): Boolean {
if (playStarted) return true if (playStarted) return true
if (rxQueue.size >= jitterTarget) return true if (rxQueue.size >= jitterTarget) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 80 return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150
} }
fun onAudioFlush() { fun onAudioFlush() {
@@ -394,7 +397,7 @@ class OpusEngine(
// ---------- Захват (TX) ---------- // ---------- Захват (TX) ----------
/** Начать PTT: запустить запись и кодирование. */ /** Начать PTT: запустить запись и кодирование. */
fun startCapture(onPacket: (ByteArray) -> Unit) { fun startCapture(micGain: Float = 1.0f, onPacket: (ByteArray) -> Unit) {
if (encoding) return if (encoding) return
val enc = createEncoder() val enc = createEncoder()
encoder = enc encoder = enc
@@ -417,7 +420,7 @@ class OpusEngine(
rec.startRecording() rec.startRecording()
encoding = true encoding = true
val t = Thread({ val t = Thread({
captureLoop(enc, rec, onPacket) captureLoop(enc, rec, micGain, onPacket)
}, "svx-capture") }, "svx-capture")
t.isDaemon = true t.isDaemon = true
recThread = t recThread = t
@@ -441,7 +444,7 @@ class OpusEngine(
} }
} }
private fun captureLoop(enc: MediaCodec, rec: AudioRecord, onPacket: (ByteArray) -> Unit) { private fun captureLoop(enc: MediaCodec, rec: AudioRecord, micGain: Float, onPacket: (ByteArray) -> Unit) {
val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN) val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN)
val micLevel = FloatArray(1) val micLevel = FloatArray(1)
while (encoding) { while (encoding) {
@@ -451,6 +454,14 @@ class OpusEngine(
pcmBuf.limit(n) pcmBuf.limit(n)
val pcm = ByteArray(n) val pcm = ByteArray(n)
pcmBuf.get(pcm) pcmBuf.get(pcm)
if (micGain != 1.0f) {
for (i in 0 until n step 2) {
val s = ((pcm[i + 1].toInt() shl 8) or (pcm[i].toInt() and 0xFF)).toShort()
val g = (s * micGain).toInt().coerceIn(-32768, 32767)
pcm[i] = (g and 0xFF).toByte()
pcm[i + 1] = ((g shr 8) and 0xFF).toByte()
}
}
micLevel[0] = pcmLevel(pcm) micLevel[0] = pcmLevel(pcm)
onLevel(SRC_MIC, micLevel[0]) onLevel(SRC_MIC, micLevel[0])
try { try {

View File

@@ -136,4 +136,12 @@ object Prefs {
fun setAprsCoords(context: Context, coords: String) { fun setAprsCoords(context: Context, coords: String) {
sp(context).edit().putString("aprs_coords", coords.trim()).apply() sp(context).edit().putString("aprs_coords", coords.trim()).apply()
} }
/** Усиление микрофона (1.0 = без усиления). */
fun micGain(context: Context): Float =
sp(context).getFloat("mic_gain", 1.0f)
fun setMicGain(context: Context, gain: Float) {
sp(context).edit().putFloat("mic_gain", gain.coerceIn(0.5f, 5.0f)).apply()
}
} }

View File

@@ -202,6 +202,46 @@
android:padding="12dp" android:padding="12dp"
android:hint="@string/pref_aprs_coords_hint" /> android:hint="@string/pref_aprs_coords_hint" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_audio_label"
android:textColor="@color/accent"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/pref_mic_gain"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_mic_gain_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="100%"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_mic_gain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="40"
android:layout_marginBottom="24dp" />
<Button <Button
android:id="@+id/btn_save" android:id="@+id/btn_save"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -19,6 +19,8 @@
<string name="pref_aprs_hint">Экспериментально: принимает метеостанции в радиусе 250 км. Последняя станция показывается вместо строки сервера на главном экране.</string> <string name="pref_aprs_hint">Экспериментально: принимает метеостанции в радиусе 250 км. Последняя станция показывается вместо строки сервера на главном экране.</string>
<string name="pref_aprs_coords">Координаты (широта, долгота)</string> <string name="pref_aprs_coords">Координаты (широта, долгота)</string>
<string name="pref_aprs_coords_hint">пусто = центр Санкт-Петербурга, например 59.95, 30.30</string> <string name="pref_aprs_coords_hint">пусто = центр Санкт-Петербурга, например 59.95, 30.30</string>
<string name="pref_audio_label">Звук</string>
<string name="pref_mic_gain">Усиление микрофона</string>
<string name="toast_node_joined">Подключился: %1$s%2$s</string> <string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string> <string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string> <string name="node_tg_suffix"> (группа %1$d)</string>