From 99a867df6f70ff89225a55a71dfa82435d22bb27 Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Fri, 21 Aug 2026 11:09:01 +0300 Subject: [PATCH] =?UTF-8?q?1.0.12:=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=B0=20=D0=B2=20OGG=20=D1=81?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=82=D0=B8=D1=88=D0=B8=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RxRecorder: MediaCodec(Opus) + MediaMuxer(OGG), пишет декодированный RX PCM 48 кГц; pts не продвигается во время тишины, поэтому паузы в эфире не попадают в файл. - OpusEngine.rxTap: перехват PCM из выхода декодера. - SvxService: startRecording()/stopRecording()/isRecording(). - MainActivity: кнопка btn_record между позывным и монитором, индикация (иконка краснеет), тосты о старте/сохранении. - Файлы: Android/data/ru.ua1zbe.svxremote/files/recordings/rec_*.ogg. Пустой файл (весь сеанс — тишина) удаляется. --- app/build.gradle.kts | 4 +- .../java/ru/ua1zbe/svxremote/MainActivity.kt | 28 +++ .../java/ru/ua1zbe/svxremote/SvxService.kt | 29 ++- .../ru/ua1zbe/svxremote/audio/OpusEngine.kt | 4 + .../ru/ua1zbe/svxremote/audio/RxRecorder.kt | 179 ++++++++++++++++++ app/src/main/res/drawable/ic_record.xml | 9 + app/src/main/res/layout/activity_main.xml | 12 ++ app/src/main/res/values/strings.xml | 4 + 8 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt create mode 100644 app/src/main/res/drawable/ic_record.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d1732e..f0d79d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "ru.ua1zbe.svxremote" minSdk = 26 targetSdk = 34 - versionCode = 13 - versionName = "1.0.11" + versionCode = 14 + versionName = "1.0.12" } buildTypes { diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index 48fc57e..c593981 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -66,6 +66,7 @@ class MainActivity : Activity(), SvxService.Listener { private lateinit var pttButton: PttButton private lateinit var btnSettings: ImageButton private lateinit var btnMonitor: ImageButton + private lateinit var btnRecord: ImageButton private val mainHandler = Handler(Looper.getMainLooper()) private var titleHoldRunnable: Runnable? = null @@ -91,6 +92,7 @@ class MainActivity : Activity(), SvxService.Listener { override fun onServiceConnected(name: ComponentName, binder: IBinder) { svc = (binder as SvxService.LocalBinder).service() svc?.listener = this@MainActivity + updateRecordUi(svc?.isRecording() ?: false) } override fun onServiceDisconnected(name: ComponentName) { @@ -148,9 +150,11 @@ class MainActivity : Activity(), SvxService.Listener { pttButton = findViewById(R.id.ptt_button) btnSettings = findViewById(R.id.btn_settings) btnMonitor = findViewById(R.id.btn_monitor) + btnRecord = findViewById(R.id.btn_record) pttButton.enabled2 = false btnMonitor.setOnClickListener { toggleMonitoring() } + btnRecord.setOnClickListener { toggleRecording() } updateMonitorButton() val testText = intent?.getStringExtra("node_toast_test") if (testText != null) { @@ -669,6 +673,30 @@ class MainActivity : Activity(), SvxService.Listener { getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor) } + private fun toggleRecording() { + val s = svc ?: return + if (s.isRecording()) { + val path = s.stopRecording() + updateRecordUi(false) + toast(getString(R.string.record_saved, path ?: "")) + } else { + val path = s.startRecording() + if (path != null) { + updateRecordUi(true) + toast(getString(R.string.record_started)) + } else { + toast(getString(R.string.record_failed)) + } + } + } + + private fun updateRecordUi(active: Boolean) { + btnRecord.imageTintList = ColorStateList.valueOf( + if (active) Color.parseColor("#F85149") else Color.parseColor("#8B949E") + ) + btnRecord.contentDescription = getString(R.string.btn_record) + } + /** Переключиться на другую группу: отправить серверу по живому соединению. */ private fun switchGroup(tg: Long) { settings = settings.copy(talkGroup = tg) diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt index 6c56a5b..e5a8206 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.audio.RxRecorder import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.ReflectorClient @@ -40,10 +41,13 @@ class SvxService : Service(), ReflectorClient.Callbacks { OpusEngine( onLevel = { source, level -> listener?.onLevel(source, level) }, onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) } - ) + ).also { it.rxTap = { pcm, real -> recorder.feed(pcm, real) } } } val client: ReflectorClient by lazy { ReflectorClient(this) } + /** Запись приёма в OGG (управляется из UI). */ + private val recorder = RxRecorder() + private val binder = LocalBinder() inner class LocalBinder : Binder() { @@ -189,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks { override fun onError(detail: String) { listener?.onError(detail) } + + // ---------- Запись приёма в файл ---------- + + fun isRecording(): Boolean = recorder.isRecording() + + /** Начать запись. Возвращает путь к файлу или null при ошибке. */ + fun startRecording(): String? { + if (recorder.isRecording()) return recorder.getFilePath() + val dir = getExternalFilesDir(null) ?: filesDir + return if (recorder.start(dir)) recorder.getFilePath() else null + } + + /** Остановить запись. Возвращает путь к сохранённому файлу или null. */ + fun stopRecording(): String? { + if (!recorder.isRecording()) return null + recorder.stop() + return recorder.getFilePath() + } + + override fun onDestroy() { + recorder.stop() + super.onDestroy() + } } 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 9f3a944..35b46ac 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt @@ -72,6 +72,9 @@ class OpusEngine( // Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). @Volatile var txBitrateKbps = 0 + // Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём? + @Volatile + var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null // Текущий битрейт в режиме Авто (пересчитывается по статистике приёма) @Volatile private var autoKbps = 16 @@ -242,6 +245,7 @@ class OpusEngine( // Уровень показываем только для реальных пакетов: во время // скрытия потерь (conceal) шкала должна быть в нуле val real = System.currentTimeMillis() - lastRealPacketTime < 400 + rxTap?.invoke(pcm, real) val lvl = if (real) pcmLevel(pcm) else 0f // Порог тишины: шум декодера и паузы речи — в ноль onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl) diff --git a/app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt b/app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt new file mode 100644 index 0000000..5584be1 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt @@ -0,0 +1,179 @@ +package ru.ua1zbe.svxremote.audio + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import android.util.Log +import java.io.File +import java.nio.ByteBuffer +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Запись декодированного RX-аудио (PCM 48 кГц моно 16 бит) в OGG/Opus. + * Тишина (отсутствие приёма) не пишется: во время пауз pts не продвигается, + * поэтому речь в файле склеивается без пропусков/пустых участков. + */ +class RxRecorder { + + companion object { + const val TAG = "SVXRemote" + const val SAMPLE_RATE = 48000 + const val CHANNELS = 1 + } + + @Volatile + private var muxer: MediaMuxer? = null + @Volatile + private var encoder: MediaCodec? = null + private val recording = AtomicBoolean(false) + private val queue = LinkedBlockingQueue() + private var worker: Thread? = null + private var filePath = "" + // Накопленный pts (мкс). Растёт только при подаче реального аудио (без тишины). + private var ptsUs = 0L + private var trackIndex = -1 + private var muxStarted = false + private var wroteAny = false + private val info = MediaCodec.BufferInfo() + + fun isRecording(): Boolean = recording.get() + + fun getFilePath(): String = filePath + + /** Начать запись. dir — базовая директория (используется подкаталог recordings). */ + fun start(dir: File): Boolean { + if (recording.get()) return true + val outDir = File(dir, "recordings") + if (!outDir.exists()) outDir.mkdirs() + val f = File(outDir, "rec_%016x.ogg".format(System.currentTimeMillis())) + filePath = f.absolutePath + val mux = try { + MediaMuxer(filePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG) + } catch (e: Exception) { + Log.e(TAG, "recorder muxer create failed: ${e.message}") + return false + } + val fmt = MediaFormat() + fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) + fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) + fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) + fmt.setInteger(MediaFormat.KEY_BIT_RATE, 32000) + fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1920) + val enc = try { + MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { + it.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + it.start() + } + } catch (e: Exception) { + Log.e(TAG, "recorder encoder create failed: ${e.message}") + try { mux.release() } catch (_: Exception) {} + return false + } + muxer = mux + encoder = enc + trackIndex = -1 + muxStarted = false + ptsUs = 0L + queue.clear() + recording.set(true) + worker = Thread({ workerLoop() }, "rx-recorder").also { it.isDaemon = true; it.start() } + Log.d(TAG, "recorder started -> $filePath") + return true + } + + /** Подача декодированного PCM. isReal == false (тишина/скрытие потерь) — не пишем. */ + fun feed(pcm: ByteArray, isReal: Boolean) { + if (!recording.get() || !isReal) return + queue.offer(pcm) + } + + private fun workerLoop() { + val enc = encoder ?: return + val mux = muxer ?: return + val timeout = 1000L + try { + while (recording.get() || queue.isNotEmpty()) { + val pcm = queue.poll() + if (pcm != null) { + var inIdx = enc.dequeueInputBuffer(timeout) + if (inIdx >= 0) { + val buf = enc.getInputBuffer(inIdx) ?: continue + buf.clear() + buf.put(pcm) + val pts = ptsUs + enc.queueInputBuffer(inIdx, 0, pcm.size, pts, 0) + // Продвигаем pts только для реального аудио (без тишины) + ptsUs += (pcm.size / 2) * 1_000_000L / SAMPLE_RATE + } else { + queue.offer(pcm) + Thread.sleep(2) + } + } + drainEncoder(enc, mux, false) + if (!recording.get() && queue.isEmpty()) break + } + } catch (e: Exception) { + Log.e(TAG, "recorder worker error: ${e.message}") + } finally { + // Дренаж остатков после остановки + try { + drainEncoder(enc, mux, true) + } catch (_: Exception) { + } + try { enc.stop() } catch (_: Exception) {} + try { enc.release() } catch (_: Exception) {} + if (muxStarted) { + try { mux.stop() } catch (_: Exception) {} + } + try { mux.release() } catch (_: Exception) {} + encoder = null + muxer = null + Log.d(TAG, "recorder stopped -> $filePath") + } + } + + private fun drainEncoder(enc: MediaCodec, mux: MediaMuxer, drainAll: Boolean) { + val timeout = if (drainAll) 0L else 1000L + var outIdx = enc.dequeueOutputBuffer(info, timeout) + while (outIdx >= 0) { + if (info.size > 0) { + if (trackIndex < 0) { + trackIndex = mux.addTrack(enc.outputFormat) + mux.start() + muxStarted = true + Log.d(TAG, "recorder muxer track added, started") + } + val outBuf = enc.getOutputBuffer(outIdx) ?: run { + enc.releaseOutputBuffer(outIdx, false) + outIdx = enc.dequeueOutputBuffer(info, timeout) + continue + } + outBuf.position(info.offset) + outBuf.limit(info.offset + info.size) + mux.writeSampleData(trackIndex, outBuf, info) + wroteAny = true + } + enc.releaseOutputBuffer(outIdx, false) + outIdx = enc.dequeueOutputBuffer(info, timeout) + } + } + + fun stop() { + if (!recording.get()) return + recording.set(false) + val w = worker + if (w != null && w.isAlive) { + try { w.join(4000) } catch (_: InterruptedException) {} + } + worker = null + // Если ни одного кадра не записалось (весь сеанс — тишина) — удаляем пустой файл. + if (!wroteAny) { + try { + val f = File(filePath) + if (f.exists()) f.delete() + } catch (_: Exception) {} + } + } +} diff --git a/app/src/main/res/drawable/ic_record.xml b/app/src/main/res/drawable/ic_record.xml new file mode 100644 index 0000000..8d7eef0 --- /dev/null +++ b/app/src/main/res/drawable/ic_record.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fc97e49..463d421 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -85,10 +85,22 @@ android:ellipsize="end" android:text="@string/settings_not_configured" /> + + Отключено SVXHomyak Выключить: отключиться и закрыть + Запись приёма в файл (нажмите, чтобы начать/остановить) Мониторинг групп (нажмите, чтобы включить) Мониторинг выбранных групп! Обычный режим Мониторинг групп включен (нажмите, чтобы выключить) + Запись приёма начата + Сохранено: %1$s + Не удалось начать запись (формат OGG недоступен) Мониторинг: %1$s Говорит TG%1$d RX