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
@@ -74,12 +74,22 @@ class MediaAudioProbe : Instrumentation() {
|
||||
check(b.pcm.contentEquals(unchanged)) { "PCM copies are shared" }
|
||||
check(System.nanoTime() < until) { "Profile did not settle: ${MediaAudioController.state.value}" }
|
||||
count++
|
||||
} while (count < 30 || MediaAudioController.state.value.switching)
|
||||
} while (count < 300 || MediaAudioController.state.value.switching)
|
||||
check(MediaAudioController.state.value.notice == null && MediaAudioController.state.value.inputName != null) {
|
||||
"Microphone was not confirmed: ${MediaAudioController.state.value}"
|
||||
}
|
||||
android.util.Log.i("MediaAudioProbe", "profile verified=$profile frames=$count state=${MediaAudioController.state.value}")
|
||||
}
|
||||
// Непрерывный аппаратный участок длиннее окна диагностической сводки PCM clock.
|
||||
repeat(300) {
|
||||
val a = frame(second)
|
||||
check(a.timeNs == previous + 20_000_000L) { "Steady capture PTS discontinuity" }
|
||||
previous = a.timeNs
|
||||
var b = frame(first)
|
||||
while (b.sequence < a.sequence) b = frame(first)
|
||||
check(a.sequence == b.sequence && a.pcm.contentEquals(b.pcm)) { "Steady consumers differ" }
|
||||
}
|
||||
android.util.Log.i("MediaAudioProbe", "steady hardware capture verified frames=300")
|
||||
first.close(); radio = null
|
||||
val next = frame(second)
|
||||
check(next.timeNs == previous + 20_000_000L) { "Closing radio interrupted stream capture" }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
+91
-2
@@ -5,8 +5,8 @@ import org.junit.Test
|
||||
|
||||
class MediaCaptureTimelineTest {
|
||||
private val origin = 1_000_000_000L
|
||||
private fun frame(time: Long, value: Int, epoch: Long = 1) =
|
||||
MicrophoneCapture.Frame(ShortArray(1920) { value.toShort() }, time, 0, generation = epoch)
|
||||
private fun frame(time: Long, value: Int, epoch: Long = 1, sequence: Long = (time - origin) * 48000 / 1_000_000_000) =
|
||||
MicrophoneCapture.Frame(ShortArray(1920) { value.toShort() }, time, sequence, generation = epoch)
|
||||
private fun pull(timeline: MediaCaptureTimeline) = timeline.pull(timeline.deadlineNs)
|
||||
|
||||
@Test fun switchingKeepsTimeAndSequenceAndRejectsOldDevice() {
|
||||
@@ -68,4 +68,93 @@ class MediaCaptureTimelineTest {
|
||||
assertFalse(timeline.offer(frame(origin + 480_000_000L, 2)))
|
||||
assertEquals(1L, timeline.lateFrames)
|
||||
}
|
||||
|
||||
@Test fun timestampJitterDoesNotInsertSilenceOrChangeAnySample() {
|
||||
for (jitter in listOf(20_000L, 100_000L, 350_000L)) {
|
||||
val timeline = MediaCaptureTimeline(origin)
|
||||
timeline.reset(1)
|
||||
repeat(510) { index ->
|
||||
val pcm = ShortArray(1920) { ((index * 1920 + it) % 30000 + 1).toShort() }
|
||||
val offset = if (index == 0) 0 else if (index % 2 == 0) jitter else -jitter
|
||||
assertTrue(timeline.offer(MicrophoneCapture.Frame(pcm, origin + index * 20_000_000L + offset,
|
||||
index * 960L, generation = 1)))
|
||||
if (index >= 10) {
|
||||
val output = pull(timeline)
|
||||
val expected = ShortArray(1920) { (((index - 10) * 1920 + it) % 30000 + 1).toShort() }
|
||||
assertArrayEquals("jitter=$jitter block=$index", expected, output.pcm)
|
||||
assertEquals(0L, timeline.missingFrames)
|
||||
assertEquals(0, timeline.ratePpm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun actualSequenceGapIsSilenceAndLaterSoundKeepsItsPosition() {
|
||||
val timeline = MediaCaptureTimeline(origin)
|
||||
timeline.reset(1)
|
||||
assertTrue(timeline.offer(frame(origin, 11)))
|
||||
assertTrue(timeline.offer(frame(origin + 40_000_000, 22)))
|
||||
assertTrue(pull(timeline).pcm.all { it == 11.toShort() })
|
||||
assertTrue(pull(timeline).pcm.all { it == 0.toShort() })
|
||||
assertTrue(pull(timeline).pcm.all { it == 22.toShort() })
|
||||
assertEquals(960L, timeline.missingFrames)
|
||||
}
|
||||
|
||||
@Test fun slowClockDriftIsResampledWithoutHolesOrSharpEdges() {
|
||||
for (ppm in listOf(-300.0, 300.0)) {
|
||||
val timeline = MediaCaptureTimeline(origin)
|
||||
timeline.reset(1)
|
||||
var sent = 0
|
||||
var previous = 0
|
||||
repeat(15000) { output ->
|
||||
val until = output * 20_000_000L + MediaCaptureTimeline.RESERVE_NS
|
||||
while (sent * 20_000_000.0 * (1 + ppm / 1_000_000) <= until) {
|
||||
val pcm = ShortArray(1920) { channel ->
|
||||
(10000 + 4000 * kotlin.math.sin(2 * Math.PI * 200 * (sent * 960L + channel / 2) / 48000)).toInt().toShort()
|
||||
}
|
||||
assertTrue(timeline.offer(MicrophoneCapture.Frame(pcm,
|
||||
origin + (sent * 20_000_000.0 * (1 + ppm / 1_000_000)).toLong(), sent * 960L, generation = 1)))
|
||||
sent++
|
||||
}
|
||||
val frame = pull(timeline)
|
||||
for (i in 0 until 960) {
|
||||
val sample = frame.pcm[i * 2].toInt()
|
||||
assertTrue("zero/hole ppm=$ppm block=$output", sample >= 5999)
|
||||
if (previous != 0) assertTrue("click ppm=$ppm block=$output", kotlin.math.abs(sample - previous) <= 110)
|
||||
assertEquals(frame.pcm[i * 2], frame.pcm[i * 2 + 1])
|
||||
previous = sample
|
||||
}
|
||||
assertEquals(0L, timeline.missingFrames)
|
||||
}
|
||||
assertTrue("clock correction ppm=$ppm actual=${timeline.ratePpm}", kotlin.math.abs(timeline.ratePpm + ppm) < 20)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun bluetoothTimestampJumpsDoNotInterruptContinuousPcm() {
|
||||
val timeline = MediaCaptureTimeline(origin)
|
||||
timeline.reset(1)
|
||||
assertTrue(timeline.offer(frame(origin, 11)))
|
||||
assertTrue(timeline.offer(frame(origin + 247_000_000, 22, sequence = 960)))
|
||||
assertTrue(timeline.offer(frame(origin + 40_000_000, 33, sequence = 1920)))
|
||||
assertTrue(pull(timeline).pcm.all { it == 11.toShort() })
|
||||
assertTrue(pull(timeline).pcm.all { it == 22.toShort() })
|
||||
assertTrue(pull(timeline).pcm.all { it == 33.toShort() })
|
||||
assertEquals(2L, timeline.timestampJumps)
|
||||
assertEquals(0L, timeline.missingFrames)
|
||||
assertEquals(0, timeline.ratePpm)
|
||||
}
|
||||
|
||||
@Test fun realCaptureStallReanchorsFreshPcmInsteadOfRejectingEveryFollowingBlock() {
|
||||
val timeline = MediaCaptureTimeline(origin)
|
||||
timeline.reset(1)
|
||||
assertTrue(timeline.offer(MicrophoneCapture.Frame(ShortArray(1920) { 11 }, origin, 0, origin + 20_000_000, 1)))
|
||||
assertTrue(pull(timeline).pcm.all { it == 11.toShort() })
|
||||
repeat(10) { pull(timeline) }
|
||||
assertTrue(timeline.offer(MicrophoneCapture.Frame(ShortArray(1920) { 22 }, origin + 240_000_000,
|
||||
960, origin + 260_000_000, 1)))
|
||||
assertTrue(pull(timeline).pcm.all { it == 0.toShort() })
|
||||
assertTrue(pull(timeline).pcm.all { it == 22.toShort() })
|
||||
assertEquals(1L, timeline.timestampJumps)
|
||||
assertEquals(0L, timeline.lateFrames)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user