Fix shared microphone crackle from timestamp jitter
This commit is contained in:
1 parent
c9a3a1334b
commit
bb380441a3
4 files changed
+216
-31
No files matched your search
@@ -281,9 +281,11 @@ object MediaAudioController {
|
||||
/** Постоянный stereo-вход с запасом времени на аппаратный reader и смену маршрута. */
|
||||
private class InputBridge : MicrophoneCapture.Device {
|
||||
private val lock = Object()
|
||||
private val timeline = MediaCaptureTimeline(System.nanoTime())
|
||||
private val timeline = MediaCaptureTimeline(System.nanoTime()) { level, message -> LogManager.addLog(level, "media_audio", message) }
|
||||
private var running = true
|
||||
private var reportedLate = 0L
|
||||
private var reportedMissing = 0L
|
||||
private var reportNs = 0L
|
||||
override val channels = 2
|
||||
override val inputId: Int? get() = null
|
||||
fun reset(epoch: Long) = synchronized(lock) { timeline.reset(epoch) }
|
||||
@@ -299,7 +301,17 @@ object MediaAudioController {
|
||||
while (running) {
|
||||
val now = System.nanoTime()
|
||||
val wait = timeline.deadlineNs - now
|
||||
if (wait <= 0) return@synchronized timeline.pull(now)
|
||||
if (wait <= 0) {
|
||||
val frame = timeline.pull(now)
|
||||
if (now - reportNs >= 5_000_000_000L || (reportedMissing == 0L && timeline.missingFrames > 0)) {
|
||||
val level = if (timeline.missingFrames > reportedMissing) "WARN" else "DEBUG"
|
||||
LogManager.addLog(level, "media_audio", "PCM clock generation=${timeline.generation} " +
|
||||
"jitterMaxUs=${timeline.timestampJitterMaxNs / 1000} jumps=${timeline.timestampJumps} ratePpm=${timeline.ratePpm} " +
|
||||
"missingFrames=${timeline.missingFrames} silenceFrames=${timeline.silentFrames} lateBlocks=${timeline.lateFrames}")
|
||||
reportedMissing = timeline.missingFrames; reportNs = now
|
||||
}
|
||||
return@synchronized frame
|
||||
}
|
||||
lock.wait(wait / 1_000_000, (wait % 1_000_000).toInt())
|
||||
}
|
||||
error("Shared microphone stopped")
|
||||
|
||||
+100
-26
@@ -1,39 +1,100 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import java.util.ArrayDeque
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** Общая сетка PCM 48k stereo. Аппаратные timestamps определяют положение отсчётов;
|
||||
* отсутствующие участки — тишина. Никакого переноса старого звука на новое время.
|
||||
* Все методы вызываются под lock владельца. */
|
||||
internal class MediaCaptureTimeline(val originNs: Long) {
|
||||
/** Общая сетка PCM 48k stereo. Внутри поколения отсчёты идут по sequence, без перекладки
|
||||
* отдельных блоков по шумным timestamps. Медленный дрейф часов компенсируется непрерывной
|
||||
* интерполяцией; только реальные пропуски дают тишину. Все методы — под lock владельца. */
|
||||
internal class MediaCaptureTimeline(val originNs: Long, private val log: (String, String) -> Unit = { _, _ -> }) {
|
||||
companion object {
|
||||
const val FRAMES = 960
|
||||
const val PERIOD_NS = 20_000_000L
|
||||
const val RESERVE_NS = 200_000_000L
|
||||
private const val NS_PER_FRAME = 1_000_000_000.0 / 48000
|
||||
}
|
||||
private val pending = ArrayDeque<MicrophoneCapture.Frame>()
|
||||
private var position = 0L
|
||||
private var lastInputNs = -1L
|
||||
private var lastSequence = -1L
|
||||
private var lastCreatedNs = 0L
|
||||
private var anchorNs = 0L
|
||||
private var anchorSequence = 0L
|
||||
private var startPosition = 0L
|
||||
private var anchored = false
|
||||
private var cursor = Double.NaN
|
||||
private var clockOffset = 0.0
|
||||
private var timestampBiasNs = 0L
|
||||
var generation = 0L
|
||||
private set
|
||||
var lateFrames = 0L
|
||||
private set
|
||||
var silentFrames = 0L
|
||||
private set
|
||||
var missingFrames = 0L
|
||||
private set
|
||||
var timestampJitterMaxNs = 0L
|
||||
private set
|
||||
var timestampJumps = 0L
|
||||
private set
|
||||
var ratePpm = 0
|
||||
private set
|
||||
val nextTimeNs: Long get() = originNs + position * 1_000_000_000 / 48000
|
||||
val deadlineNs: Long get() = nextTimeNs + PERIOD_NS + RESERVE_NS
|
||||
|
||||
fun reset(epoch: Long) {
|
||||
generation = epoch; pending.clear(); lastInputNs = -1
|
||||
generation = epoch; pending.clear(); lastInputNs = -1; lastSequence = -1; lastCreatedNs = 0
|
||||
anchored = false; cursor = Double.NaN; clockOffset = 0.0; ratePpm = 0; timestampJitterMaxNs = 0; timestampBiasNs = 0; timestampJumps = 0
|
||||
}
|
||||
|
||||
fun offer(frame: MicrophoneCapture.Frame): Boolean {
|
||||
require(frame.pcm.size == FRAMES * 2)
|
||||
if (frame.generation != generation || frame.timeNs <= lastInputNs) return false
|
||||
lastInputNs = frame.timeNs
|
||||
if (frame.timeNs + PERIOD_NS <= nextTimeNs) { lateFrames++; return false }
|
||||
if (pending.size >= 24) { lateFrames++; return false }
|
||||
require(frame.pcm.size == FRAMES * 2 && frame.sequence >= 0 && frame.timeNs >= 0)
|
||||
if (frame.generation != generation) return false
|
||||
if (lastSequence >= 0) {
|
||||
if (frame.sequence <= lastSequence) {
|
||||
log("WARN", "microphone stale PCM generation=$generation sequence=${frame.sequence}/$lastSequence time=${frame.timeNs}/$lastInputNs")
|
||||
return false
|
||||
}
|
||||
val elapsed = (frame.sequence - lastSequence) * 1_000_000_000 / 48000
|
||||
val jitter = frame.timeNs - lastInputNs - elapsed
|
||||
timestampJitterMaxNs = maxOf(timestampJitterMaxNs, abs(jitter))
|
||||
// Отличаем перестановку timestamp от остановки аппаратного reader при запуске Bluetooth.
|
||||
// Свежий звук после реальной остановки привязываем заново, не воспроизводя старую очередь.
|
||||
if (abs(jitter) > PERIOD_NS * 2) {
|
||||
val deliveryGap = frame.createdNs - lastCreatedNs - elapsed
|
||||
val latePcm = !cursor.isNaN() && frame.sequence + FRAMES <= cursor
|
||||
val reanchor = frame.timeNs + PERIOD_NS > nextTimeNs && (deliveryGap > PERIOD_NS * 2 || latePcm)
|
||||
if (reanchor) { pending.clear(); anchored = false; cursor = Double.NaN; clockOffset = 0.0; timestampBiasNs = 0 }
|
||||
else timestampBiasNs += jitter
|
||||
timestampJumps++
|
||||
if (timestampJumps == 1L || timestampJumps % 50L == 0L)
|
||||
log("WARN", "microphone timestamp jump generation=$generation deltaNs=$jitter deliveryGapNs=$deliveryGap " +
|
||||
"late=$latePcm reanchor=$reanchor count=$timestampJumps")
|
||||
}
|
||||
if (frame.sequence != lastSequence + FRAMES)
|
||||
log("WARN", "microphone sequence gap generation=$generation expected=${lastSequence + FRAMES} actual=${frame.sequence}")
|
||||
}
|
||||
lastInputNs = frame.timeNs; lastSequence = frame.sequence; lastCreatedNs = frame.createdNs
|
||||
val late = if (anchored && !cursor.isNaN()) frame.sequence + FRAMES <= cursor else frame.timeNs + PERIOD_NS <= nextTimeNs
|
||||
if (late || pending.size >= 24) {
|
||||
lateFrames++
|
||||
if (lateFrames == 1L || lateFrames % 50L == 0L)
|
||||
log("WARN", "microphone PCM rejected generation=$generation late=$late queue=${pending.size} " +
|
||||
"sequence=${frame.sequence} cursor=$cursor ageUs=${(frame.createdNs - frame.timeNs) / 1000} count=$lateFrames")
|
||||
return false
|
||||
}
|
||||
if (!anchored) {
|
||||
anchorNs = frame.timeNs; anchorSequence = frame.sequence
|
||||
startPosition = ((anchorNs - originNs) / NS_PER_FRAME).roundToLong()
|
||||
anchored = true
|
||||
log("INFO", "microphone clock anchored generation=$generation sequence=$anchorSequence position=$startPosition")
|
||||
}
|
||||
// Фильтр около 1с подавляет джиттер измерений; фазовая ошибка не двигает уже прочитанный PCM.
|
||||
val measured = (frame.timeNs - anchorNs - timestampBiasNs) / NS_PER_FRAME - (frame.sequence - anchorSequence)
|
||||
clockOffset += (measured - clockOffset) * 0.02
|
||||
pending.addLast(frame)
|
||||
return true
|
||||
}
|
||||
@@ -42,24 +103,37 @@ internal class MediaCaptureTimeline(val originNs: Long) {
|
||||
check(nowNs >= deadlineNs)
|
||||
val start = nextTimeNs
|
||||
val pcm = ShortArray(FRAMES * 2)
|
||||
var filled = 0
|
||||
while (pending.isNotEmpty()) {
|
||||
val frame = pending.first
|
||||
val offset = ((frame.timeNs - start).toDouble() * 48000 / 1_000_000_000).roundToInt()
|
||||
if (offset >= FRAMES) break
|
||||
val source = maxOf(0, -offset)
|
||||
val target = maxOf(0, offset, filled)
|
||||
val skip = target - maxOf(0, offset)
|
||||
val count = minOf(FRAMES - source - skip, FRAMES - target)
|
||||
if (count > 0) {
|
||||
frame.pcm.copyInto(pcm, target * 2, (source + skip) * 2, (source + skip + count) * 2)
|
||||
silentFrames += target - filled
|
||||
filled = target + count
|
||||
val leading = if (anchored) (startPosition - position).coerceIn(0, FRAMES.toLong()).toInt() else FRAMES
|
||||
if (anchored && leading < FRAMES && cursor.isNaN()) cursor = anchorSequence + maxOf(0, position - startPosition).toDouble()
|
||||
var step = 1.0
|
||||
if (!cursor.isNaN()) {
|
||||
val error = anchorSequence + (position + leading - startPosition).toDouble() - clockOffset - cursor
|
||||
// 1мс мёртвой зоны: обычный джиттер не меняет PCM. Коррекция дрейфа ограничена ±0.2%.
|
||||
val correction = if (abs(error) <= 48) 0.0 else (error - if (error > 0) 48 else -48) / 96000
|
||||
step += correction.coerceIn(-0.002, 0.002)
|
||||
}
|
||||
ratePpm = ((step - 1) * 1_000_000).roundToInt()
|
||||
for (i in 0 until FRAMES) {
|
||||
if (i < leading) { silentFrames++; continue }
|
||||
val sample = floor(cursor).toLong()
|
||||
while (pending.isNotEmpty() && pending.first.sequence + FRAMES <= sample) pending.removeFirst()
|
||||
val frame = pending.peekFirst()
|
||||
val offset = if (frame == null) -1 else sample - frame.sequence
|
||||
if (frame == null || offset < 0 || offset >= FRAMES) {
|
||||
silentFrames++; missingFrames++
|
||||
} else {
|
||||
val index = offset.toInt()
|
||||
val fraction = cursor - sample
|
||||
val next = if (index + 1 < FRAMES) frame else pending.elementAtOrNull(1)?.takeIf { it.sequence == frame.sequence + FRAMES }
|
||||
val nextIndex = if (index + 1 < FRAMES) index + 1 else 0
|
||||
for (channel in 0..1) {
|
||||
val a = frame.pcm[index * 2 + channel].toInt()
|
||||
val b = next?.pcm?.get(nextIndex * 2 + channel)?.toInt() ?: a
|
||||
pcm[i * 2 + channel] = (a + (b - a) * fraction).roundToInt().toShort()
|
||||
}
|
||||
}
|
||||
if (offset + FRAMES > FRAMES) break
|
||||
pending.removeFirst()
|
||||
cursor += step
|
||||
}
|
||||
silentFrames += FRAMES - filled
|
||||
return MicrophoneCapture.Frame(pcm, start, position, nowNs, generation).also { position += FRAMES }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user