diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index 4a17264..cec20d2 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -67,6 +67,8 @@ class MainActivity : Activity(), SvxService.Listener { private val mainHandler = Handler(Looper.getMainLooper()) 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) @@ -164,18 +166,36 @@ class MainActivity : Activity(), SvxService.Listener { lblTalker.setOnClickListener { showTalkLogDialog() } - // Удержание на названии программы (> 2 с) — выбор профиля настроек + // Удержание на названии программы (> 2 с) — выбор профиля настроек; + // короткие тапы считаем: тройной тап — настройки lblAppTitle.setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - val r = Runnable { showProfileDialog() } + val r = Runnable { + titleHoldRunnable = null + showProfileDialog() + } titleHoldRunnable = r mainHandler.postDelayed(r, 2000) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasHold = titleHoldRunnable == null titleHoldRunnable?.let { mainHandler.removeCallbacks(it) } 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 } else -> true diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt index 92bc8d8..735bf12 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt @@ -9,6 +9,7 @@ import android.widget.Button import android.widget.CheckBox import android.widget.EditText import android.widget.Spinner +import android.widget.SeekBar import android.widget.TextView import android.widget.Toast import ru.ua1zbe.svxremote.prefs.ConnectionSettings @@ -45,6 +46,23 @@ class SettingsActivity : Activity() { findViewById(R.id.lbl_version).text = getString(R.string.app_version_fmt, BuildConfig.VERSION_NAME) + val seekMicGain = findViewById(R.id.seek_mic_gain) + val lblMicGain = findViewById(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) .map { getString(R.string.profile_name, it) } spinnerProfile.adapter = ArrayAdapter( @@ -95,6 +113,7 @@ class SettingsActivity : Activity() { Prefs.setActiveProfile(this, currentProfile) Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked) Prefs.setAprsCoords(this, inputAprsCoords.text.toString()) + Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f) toast(getString(R.string.saved)) finish() } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt index e3d200c..2e1dcd7 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt @@ -10,6 +10,7 @@ import android.os.Binder import android.os.IBinder import android.util.Log import ru.ua1zbe.svxremote.audio.OpusEngine +import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.ReflectorClient @@ -99,7 +100,7 @@ class SvxService : Service(), ReflectorClient.Callbacks { } fun startTx() { - engine.startCapture { packet -> client.sendAudio(packet) } + engine.startCapture(Prefs.micGain(this)) { packet -> client.sendAudio(packet) } } fun stopTx() { diff --git a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt index 7ad44c3..9f4cf14 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt @@ -58,10 +58,13 @@ class OpusEngine( private var dbgOut = 0 private var playStarted = false private var gateStart = 0L - private val jitterTarget = 6 // ≈ 120 мс накапливаем до первого слива + private val jitterTarget = 10 // ≈ 200 мс накапливаем до первого слива private val configBuffers = ArrayList() private var configIdx = 0 + /** Минимальный размер буфера AudioTrack (байт). 40000 ≈ 417 мс при 48 кГц. */ + private val rxBufferBytes = 40_000 + fun startDecoder() { if (decoder != null) return val format = MediaFormat() @@ -88,7 +91,7 @@ class OpusEngine( OUT_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, - maxOf(minBuf, 20000), // ~208 мс — буфер AudioTrack и есть джиттер-буфер + maxOf(minBuf, rxBufferBytes), // ~417 мс — джиттер-буфер AudioTrack AudioTrack.MODE_STREAM ) // play() включаем на первом выходном кадре — буфер успевает накопиться } catch (e: Exception) { @@ -282,11 +285,11 @@ class OpusEngine( decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) } } - /** Джиттер-гейт открыт: накопили кадры или вышло 80 мс с первого пакета. */ + /** Джиттер-гейт открыт: накопили кадры или вышло 150 мс с первого пакета. */ private fun gateOpen(): Boolean { if (playStarted) return true if (rxQueue.size >= jitterTarget) return true - return gateStart != 0L && System.currentTimeMillis() - gateStart >= 80 + return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150 } fun onAudioFlush() { @@ -394,7 +397,7 @@ class OpusEngine( // ---------- Захват (TX) ---------- /** Начать PTT: запустить запись и кодирование. */ - fun startCapture(onPacket: (ByteArray) -> Unit) { + fun startCapture(micGain: Float = 1.0f, onPacket: (ByteArray) -> Unit) { if (encoding) return val enc = createEncoder() encoder = enc @@ -417,7 +420,7 @@ class OpusEngine( rec.startRecording() encoding = true val t = Thread({ - captureLoop(enc, rec, onPacket) + captureLoop(enc, rec, micGain, onPacket) }, "svx-capture") t.isDaemon = true 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 micLevel = FloatArray(1) while (encoding) { @@ -451,6 +454,14 @@ class OpusEngine( pcmBuf.limit(n) val pcm = ByteArray(n) 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) onLevel(SRC_MIC, micLevel[0]) try { diff --git a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt index 62e71ab..fa0a61a 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt @@ -136,4 +136,12 @@ object Prefs { fun setAprsCoords(context: Context, coords: String) { 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() + } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 1478d22..946c21d 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -202,6 +202,46 @@ android:padding="12dp" android:hint="@string/pref_aprs_coords_hint" /> + + + + + + + + + + +