1.0.12: запись приёма в OGG с пропуском тишины
- 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. Пустой файл (весь сеанс — тишина) удаляется.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
179
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
179
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
@@ -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<ByteArray>()
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/drawable/ic_record.xml
Normal file
9
app/src/main/res/drawable/ic_record.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#8B949E"
|
||||
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
|
||||
</vector>
|
||||
@@ -85,10 +85,22 @@
|
||||
android:ellipsize="end"
|
||||
android:text="@string/settings_not_configured" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_record"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_record"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/btn_record"
|
||||
android:scaleType="centerInside"
|
||||
android:tint="@color/text_secondary" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_monitor"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_headset"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/btn_monitor"
|
||||
|
||||
@@ -41,10 +41,14 @@
|
||||
<string name="status_disconnected">Отключено</string>
|
||||
<string name="app_title">SVXHomyak</string>
|
||||
<string name="btn_power">Выключить: отключиться и закрыть</string>
|
||||
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
|
||||
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
|
||||
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
|
||||
<string name="monitor_off">Обычный режим</string>
|
||||
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
|
||||
<string name="record_started">Запись приёма начата</string>
|
||||
<string name="record_saved">Сохранено: %1$s</string>
|
||||
<string name="record_failed">Не удалось начать запись (формат OGG недоступен)</string>
|
||||
<string name="status_monitoring">Мониторинг: %1$s</string>
|
||||
<string name="talker_now">Говорит TG%1$d</string>
|
||||
<string name="duplex_rx">RX</string>
|
||||
|
||||
Reference in New Issue
Block a user